mod core;
mod range;
mod variants;
use std::rc::Rc;
use frust::authoring::text::{TextContext, TextLayout, TextStyle};
use frust::authoring::{
BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
PaintScene, PointerPhase, Role, SemanticsCtx, View, Widget,
};
use frust::{AnimationController, Curve, IconData, IconSource, Theme};
use kurbo::{Affine, Point, RoundedRect, Shape, Size, Vec2};
use peniko::{Brush, Color};
use crate::interaction::{HapticSignal, InteractionState, MaterialHaptics};
use crate::press::presses;
use self::core::{
DEFAULT_EXTENT, DISABLED_ACTIVE_OPACITY, DISABLED_INACTIVE_OPACITY, Drag, DragCtx, DragOutcome,
FOCUS_STROKE, HANDLE_GAP, HANDLE_HEIGHT, HANDLE_WIDTH, HapticCadence, HapticScheduler,
ICON_EDGE_INSET, ICON_SIZE_DEFAULT, IconDockInput, PRESS_MORPH_DURATION, PRESSED_HANDLE_WIDTH,
STOP_INDICATOR_SIZE, STOP_INDICATOR_TRAILING_SPACE, SegmentRole, SliderGeometry, TICK_SIZE,
TRACK_CORNER_RADIUS, TRACK_HEIGHT, TRACK_ICON_SIZE, TrackMetrics, VALUE_INDICATOR_PAD_X,
VALUE_INDICATOR_PAD_Y, VALUE_INDICATOR_RADIUS, ValueSpec,
};
use self::variants::{AxisMap, SliderAxis, WAVE_AMPLITUDE, WaveClock, WaveOverrides};
pub use self::core::{SliderIconPosition, SliderTrackKind};
pub use self::range::{
RangeSliderView, RangeSliderWidget, SliderRange, range_slider, wavy_range_slider,
};
const PATH_TOLERANCE: f64 = 0.1;
const LABEL_LARGE_SIZE: f32 = 14.0;
const SHAPING_INK: Color = Color::BLACK;
const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
const INVERSE_SURFACE: Color = Color::from_rgb8(0x32, 0x2F, 0x35);
const INVERSE_ON_SURFACE: Color = Color::from_rgb8(0xF5, 0xEF, 0xF7);
#[derive(Clone, Copy, Debug, PartialEq)]
struct SliderColors {
thumb: Color,
active_track: Color,
inactive_track: Color,
active_tick: Color,
inactive_tick: Color,
value_indicator: Color,
value_indicator_label: Color,
on_inactive: Color,
on_active: Color,
}
fn resolve_colors(theme: Option<&Theme>, enabled: bool) -> SliderColors {
let (
primary,
on_primary,
secondary_container,
on_surface,
on_surface_variant,
inverse,
on_inverse,
) = match theme {
Some(theme) => {
let s = theme.scheme();
(
s.primary,
s.on_primary,
s.secondary_container,
s.on_surface,
s.on_surface_variant,
s.inverse_surface,
s.inverse_on_surface,
)
}
None => (
PRIMARY,
ON_PRIMARY,
SECONDARY_CONTAINER,
ON_SURFACE,
ON_SURFACE_VARIANT,
INVERSE_SURFACE,
INVERSE_ON_SURFACE,
),
};
let active = |c: Color| {
if enabled {
c
} else {
on_surface.multiply_alpha(DISABLED_ACTIVE_OPACITY)
}
};
let inactive = |c: Color| {
if enabled {
c
} else {
on_surface.multiply_alpha(DISABLED_INACTIVE_OPACITY)
}
};
let active_track = active(primary);
let inactive_track = inactive(secondary_container);
SliderColors {
thumb: active(primary),
active_track,
inactive_track,
active_tick: inactive_track,
inactive_tick: active_track,
value_indicator: inverse,
value_indicator_label: on_inverse,
on_inactive: active(on_surface_variant),
on_active: active(on_primary),
}
}
#[derive(Clone, Copy, Debug)]
pub struct SliderTrackIcons {
pub active_start: Option<IconSource>,
pub active_end: Option<IconSource>,
pub inactive_start: Option<IconSource>,
pub inactive_end: Option<IconSource>,
pub size: f64,
}
impl PartialEq for SliderTrackIcons {
fn eq(&self, other: &Self) -> bool {
self.size == other.size
&& same_icon_source(self.active_start, other.active_start)
&& same_icon_source(self.active_end, other.active_end)
&& same_icon_source(self.inactive_start, other.inactive_start)
&& same_icon_source(self.inactive_end, other.inactive_end)
}
}
impl Default for SliderTrackIcons {
fn default() -> Self {
Self::new()
}
}
impl SliderTrackIcons {
pub fn new() -> Self {
Self {
active_start: None,
active_end: None,
inactive_start: None,
inactive_end: None,
size: TRACK_ICON_SIZE,
}
}
fn present(&self) -> [bool; 4] {
[
self.active_start.is_some(),
self.active_end.is_some(),
self.inactive_start.is_some(),
self.inactive_end.is_some(),
]
}
fn size(&self) -> f64 {
if self.size > 0.0 {
self.size
} else {
TRACK_ICON_SIZE
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Overrides {
track_thickness: Option<f64>,
corner_radius: Option<f64>,
thumb_length: Option<f64>,
dot_size: Option<f64>,
dot_spacing: Option<f64>,
icon_size: Option<f64>,
icon_edge_inset: Option<f64>,
}
impl Overrides {
fn track_thickness(&self) -> f64 {
self.track_thickness.unwrap_or(TRACK_HEIGHT)
}
fn corner_radius(&self) -> f64 {
self.corner_radius.unwrap_or(TRACK_CORNER_RADIUS)
}
fn thumb_length(&self) -> f64 {
self.thumb_length.unwrap_or(HANDLE_HEIGHT)
}
fn dot_size(&self) -> f64 {
self.dot_size.unwrap_or(STOP_INDICATOR_SIZE)
}
fn tick_size(&self) -> f64 {
self.dot_size.unwrap_or(TICK_SIZE)
}
fn dot_spacing(&self) -> f64 {
self.dot_spacing.unwrap_or(STOP_INDICATOR_TRAILING_SPACE)
}
fn icon_size(&self) -> f64 {
self.icon_size.unwrap_or(ICON_SIZE_DEFAULT)
}
fn icon_edge_inset(&self) -> f64 {
self.icon_edge_inset.unwrap_or(ICON_EDGE_INSET)
}
}
struct Run {
content: String,
layout: Option<TextLayout>,
shaped_style: Option<TextStyle>,
}
impl Run {
fn new() -> Self {
Self {
content: String::new(),
layout: None,
shaped_style: None,
}
}
fn set_content(&mut self, content: &str) {
if self.content != content {
self.content = content.to_string();
self.layout = None;
}
}
fn size(&self) -> Size {
self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
}
fn layout(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) {
if self.layout.is_some() && self.shaped_style.as_ref() == Some(style) {
return;
}
let text_ctx = ctx.text_context::<TextContext>();
self.layout = Some(text_ctx.layout(&self.content, style, None));
self.shaped_style = Some(style.clone());
}
fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
let Some(layout) = &self.layout else {
return;
};
for mut run in layout.to_scene_runs(origin) {
run.brush = Brush::Solid(color);
scene.draw_glyph_run(run);
}
}
}
fn label_large(theme: Option<&Theme>) -> TextStyle {
let mut style = match theme {
Some(theme) => theme.type_scale.label_large.clone(),
None => TextStyle::new(LABEL_LARGE_SIZE, SHAPING_INK),
};
style.color = SHAPING_INK;
style
}
fn resolve_icon(source: Option<IconSource>) -> Option<(kurbo::BezPath, f64)> {
source.map(|s| IconData::from(s).resolve())
}
fn same_icon_source(a: Option<IconSource>, b: Option<IconSource>) -> bool {
match (a, b) {
(Some(x), Some(y)) => x.d == y.d && x.design == y.design,
(None, None) => true,
_ => false,
}
}
type OnValue<State> = Rc<dyn Fn(&mut State, f64)>;
pub(crate) type AmplitudeCurve = Rc<dyn Fn(f64) -> f64>;
pub struct SliderView<State: 'static> {
value: f64,
spec: ValueSpec,
kind: SliderTrackKind,
axis: SliderAxis,
top_to_bottom: bool,
wavy: bool,
wave: WaveOverrides,
amplitude_for_progress: Option<AmplitudeCurve>,
enabled: bool,
label: Option<String>,
haptic: HapticSignal,
icon: Option<IconSource>,
icon_position: SliderIconPosition,
track_icons: Option<SliderTrackIcons>,
overrides: Overrides,
on_changed: OnValue<State>,
on_change_end: Option<OnValue<State>>,
haptic_sink: fn(HapticSignal),
}
pub fn slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
value: f64,
on_changed: F,
) -> SliderView<State> {
SliderView {
value,
spec: ValueSpec::default(),
kind: SliderTrackKind::Standard,
axis: SliderAxis::Horizontal,
top_to_bottom: true,
wavy: false,
wave: WaveOverrides::default(),
amplitude_for_progress: None,
enabled: true,
label: None,
haptic: HapticSignal::None,
icon: None,
icon_position: SliderIconPosition::End,
track_icons: None,
overrides: Overrides::default(),
on_changed: Rc::new(on_changed),
on_change_end: None,
haptic_sink: MaterialHaptics::fire,
}
}
#[allow(non_snake_case)]
pub fn Slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
value: f64,
on_changed: F,
) -> SliderView<State> {
slider(value, on_changed)
}
pub fn centered_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
value: f64,
on_changed: F,
) -> SliderView<State> {
SliderView {
kind: SliderTrackKind::Centered,
..slider(value, on_changed)
}
}
pub fn wavy_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
value: f64,
on_changed: F,
) -> SliderView<State> {
SliderView {
wavy: true,
..slider(value, on_changed)
}
}
pub fn wavy_centered_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
value: f64,
on_changed: F,
) -> SliderView<State> {
SliderView {
kind: SliderTrackKind::Centered,
wavy: true,
..slider(value, on_changed)
}
}
pub fn vertical_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
value: f64,
on_changed: F,
) -> SliderView<State> {
SliderView {
axis: SliderAxis::Vertical,
top_to_bottom: false,
..slider(value, on_changed)
}
}
pub fn vertical_centered_slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
value: f64,
on_changed: F,
) -> SliderView<State> {
SliderView {
kind: SliderTrackKind::Centered,
..vertical_slider(value, on_changed)
}
}
impl<State: 'static> SliderView<State> {
pub fn range(mut self, min: f64, max: f64) -> Self {
debug_assert!(max > min, "a slider's max must be greater than its min");
self.spec.min = min;
self.spec.max = max;
self
}
pub fn divisions(mut self, divisions: u32) -> Self {
self.spec.divisions = Some(divisions);
self
}
pub fn on_change_end<F: Fn(&mut State, f64) + 'static>(mut self, on_change_end: F) -> Self {
self.on_change_end = Some(Rc::new(on_change_end));
self
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn haptic(mut self, haptic: HapticSignal) -> Self {
self.haptic = haptic;
self
}
pub fn top_to_bottom(mut self, top_to_bottom: bool) -> Self {
self.top_to_bottom = top_to_bottom;
self
}
pub fn wavelength(mut self, wavelength: f64) -> Self {
self.wave.wavelength = Some(wavelength);
self
}
pub fn wave_speed(mut self, wave_speed: f64) -> Self {
self.wave.wave_speed = Some(wave_speed);
self
}
pub fn amplitude(mut self, amplitude: f64) -> Self {
self.wave.amplitude = Some(amplitude);
self
}
pub fn amplitude_for_progress<F: Fn(f64) -> f64 + 'static>(mut self, curve: F) -> Self {
self.amplitude_for_progress = Some(Rc::new(curve));
self
}
pub fn icon(mut self, icon: IconSource) -> Self {
self.icon = Some(icon);
self
}
pub fn icon_position(mut self, position: SliderIconPosition) -> Self {
self.icon_position = position;
self
}
pub fn icon_size(mut self, size: f64) -> Self {
self.overrides.icon_size = Some(size);
self
}
pub fn icon_edge_inset(mut self, inset: f64) -> Self {
self.overrides.icon_edge_inset = Some(inset);
self
}
pub fn track_icons(mut self, icons: SliderTrackIcons) -> Self {
self.track_icons = Some(icons);
self
}
pub fn track_thickness(mut self, thickness: f64) -> Self {
self.overrides.track_thickness = Some(thickness);
self
}
pub fn corner_radius(mut self, radius: f64) -> Self {
self.overrides.corner_radius = Some(radius);
self
}
pub fn thumb_length(mut self, length: f64) -> Self {
self.overrides.thumb_length = Some(length);
self
}
pub fn dot_size(mut self, size: f64) -> Self {
self.overrides.dot_size = Some(size);
self
}
pub fn dot_spacing(mut self, spacing: f64) -> Self {
self.overrides.dot_spacing = Some(spacing);
self
}
#[cfg(test)]
fn haptic_sink(mut self, sink: fn(HapticSignal)) -> Self {
self.haptic_sink = sink;
self
}
fn reverse(&self) -> bool {
self.axis == SliderAxis::Vertical && !self.top_to_bottom
}
fn indicator_text(&self) -> String {
match &self.label {
Some(label) => label.clone(),
None if self.spec.divisions.is_some() => format!("{}", self.value.round()),
None => format!("{:.2}", self.value),
}
}
}
pub struct SliderWidget {
value: f64,
spec: ValueSpec,
ticks: Vec<f64>,
kind: SliderTrackKind,
axis: SliderAxis,
reverse: bool,
wavy: bool,
wave: WaveOverrides,
amplitude_for_progress: Option<AmplitudeCurve>,
wave_clock: WaveClock,
enabled: bool,
overrides: Overrides,
haptic: HapticSignal,
icon: Option<IconSource>,
icon_geom: Option<(kurbo::BezPath, f64)>,
icon_position: SliderIconPosition,
track_icons: Option<SliderTrackIcons>,
track_icon_geom: [Option<(kurbo::BezPath, f64)>; 4],
drag: Drag,
interaction: InteractionState,
last_reported: Option<f64>,
press_anim: AnimationController,
press_target: bool,
dock_anim: AnimationController,
dock_target: bool,
haptics: HapticScheduler,
haptic_sink: fn(HapticSignal),
indicator: Run,
on_changed: frust::authoring::ErasedArgCallback<f64>,
on_change_end: Option<frust::authoring::ErasedArgCallback<f64>>,
}
impl<State: 'static> View<State> for SliderView<State> {
type Element = SliderWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> SliderWidget {
debug_assert!(
self.icon.is_none() || self.spec.divisions.is_none(),
"a relocating slider icon and discrete divisions are mutually exclusive"
);
let mut indicator = Run::new();
indicator.set_content(&self.indicator_text());
SliderWidget {
value: self.value,
spec: self.spec,
ticks: self.spec.tick_fractions(),
kind: self.kind,
axis: self.axis,
reverse: self.reverse(),
wavy: self.wavy,
wave: self.wave,
amplitude_for_progress: self.amplitude_for_progress.clone(),
wave_clock: WaveClock::new(),
enabled: self.enabled,
overrides: self.overrides,
haptic: self.haptic,
icon: self.icon,
icon_geom: resolve_icon(self.icon),
icon_position: self.icon_position,
track_icons: self.track_icons,
track_icon_geom: resolve_track_icons(self.track_icons.as_ref()),
drag: Drag::default(),
interaction: InteractionState::new(),
last_reported: None,
press_anim: AnimationController::new(PRESS_MORPH_DURATION).with_curve(Curve::EaseOut),
press_target: false,
dock_anim: AnimationController::new(std::time::Duration::ZERO),
dock_target: false,
haptics: HapticScheduler::default(),
haptic_sink: self.haptic_sink,
indicator,
on_changed: frust::authoring::erase_callback_arg(&self.on_changed),
on_change_end: self
.on_change_end
.as_ref()
.map(frust::authoring::erase_callback_arg),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut SliderWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.on_changed = frust::authoring::erase_callback_arg(&self.on_changed);
element.on_change_end = self
.on_change_end
.as_ref()
.map(frust::authoring::erase_callback_arg);
element.haptic_sink = self.haptic_sink;
element.amplitude_for_progress = self.amplitude_for_progress.clone();
let mut flags = ChangeFlags::NONE;
let text = self.indicator_text();
if element.indicator.content != text {
element.indicator.set_content(&text);
flags |= ChangeFlags::LAYOUT;
}
if prev.value != self.value {
element.value = self.value;
element.last_reported = None;
flags |= ChangeFlags::PAINT;
}
if prev.spec != self.spec {
element.spec = self.spec;
element.ticks = self.spec.tick_fractions();
flags |= ChangeFlags::PAINT;
}
if prev.kind != self.kind {
element.kind = self.kind;
flags |= ChangeFlags::PAINT;
}
if prev.axis != self.axis || prev.top_to_bottom != self.top_to_bottom {
element.axis = self.axis;
element.reverse = self.reverse();
flags |= ChangeFlags::LAYOUT;
}
if prev.wavy != self.wavy || prev.wave != self.wave {
element.wavy = self.wavy;
element.wave = self.wave;
flags |= ChangeFlags::PAINT;
}
if prev.enabled != self.enabled {
element.enabled = self.enabled;
if !self.enabled {
element.drag = Drag::default();
element.interaction.set_pressed(false);
element.interaction.set_dragged(false);
element.last_reported = None;
}
flags |= ChangeFlags::PAINT;
}
if prev.haptic != self.haptic {
element.haptic = self.haptic;
}
if prev.overrides != self.overrides {
element.overrides = self.overrides;
flags |= ChangeFlags::LAYOUT;
}
if !same_icon_source(prev.icon, self.icon) {
element.icon = self.icon;
element.icon_geom = resolve_icon(self.icon);
flags |= ChangeFlags::PAINT;
}
if prev.icon_position != self.icon_position {
element.icon_position = self.icon_position;
flags |= ChangeFlags::PAINT;
}
if prev.track_icons != self.track_icons {
element.track_icons = self.track_icons;
element.track_icon_geom = resolve_track_icons(self.track_icons.as_ref());
flags |= ChangeFlags::PAINT;
}
flags
}
}
fn resolve_track_icons(icons: Option<&SliderTrackIcons>) -> [Option<(kurbo::BezPath, f64)>; 4] {
let Some(icons) = icons else {
return [None, None, None, None];
};
[
resolve_icon(icons.active_start),
resolve_icon(icons.active_end),
resolve_icon(icons.inactive_start),
resolve_icon(icons.inactive_end),
]
}
impl SliderWidget {
fn drag_ctx(&self, extent: f64) -> DragCtx {
DragCtx {
spec: self.spec,
current: self.last_reported.unwrap_or(self.value),
extent,
reverse: self.reverse,
}
}
fn axis_map(&self, size: Size) -> AxisMap {
AxisMap::new(self.axis, size, self.reverse)
}
fn apply(&mut self, out: DragOutcome, ctx: &mut EventCtx) -> EventResult {
if !out.handled {
return EventResult::Ignored;
}
if out.capture {
ctx.capture_pointer();
}
if let Some(value) = out.value {
if self.haptic != HapticSignal::None {
let cadence = if self.spec.divisions.is_some() {
HapticCadence::Immediate
} else {
HapticCadence::Throttled
};
self.haptics.queue(self.haptic, cadence);
}
self.last_reported = Some(value);
(self.on_changed)(ctx, value);
}
if out.end {
let settled = self.last_reported.unwrap_or(self.value);
if let Some(on_change_end) = &mut self.on_change_end {
on_change_end(ctx, settled);
}
}
self.interaction.set_pressed(self.drag.pressed);
self.interaction.set_dragged(self.drag.dragging);
ctx.request_redraw();
EventResult::Handled
}
}
impl Widget for SliderWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let style = label_large(Theme::from_layout_ctx(ctx));
self.indicator.layout(ctx, &style);
let cross = HANDLE_HEIGHT.max(self.overrides.thumb_length());
let available = match self.axis {
SliderAxis::Horizontal => bc.max().width,
SliderAxis::Vertical => bc.max().height,
};
let extent = if available.is_finite() {
available
} else {
DEFAULT_EXTENT
};
let size = match self.axis {
SliderAxis::Horizontal => Size::new(extent, cross),
SliderAxis::Vertical => Size::new(cross, extent),
};
bc.constrain(size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
let colors = resolve_colors(theme, self.enabled);
let focused = ctx.has_focus();
let o = ctx.origin();
let map = self.axis_map(ctx.size());
let extent = map.extent();
let fraction = self.spec.fraction(self.value);
let thumb_primary = fraction * extent;
let handle_thickness = if self.drag.pressed {
PRESSED_HANDLE_WIDTH
} else {
HANDLE_WIDTH
};
let track_thickness = self.overrides.track_thickness();
let geometry = SliderGeometry {
extent,
active_start_fraction: 0.0,
active_end_fraction: fraction,
reverse: self.reverse,
kind: self.kind,
handle_thickness,
handle_gap: HANDLE_GAP
+ if focused {
core::FOCUS_HANDLE_GAP_EXTRA
} else {
0.0
},
track_thickness,
corner_radius: self.overrides.corner_radius(),
};
let dock = self.icon_dock(&geometry, map.flip(thumb_primary));
if let Some(dock) = &dock
&& dock.docked != self.dock_target
{
let velocity = if dock.docked { 1e-3 } else { -1e-3 };
self.dock_anim.fling(velocity, core::DOCK_SPRING);
self.dock_target = dock.docked;
}
if self.drag.pressed != self.press_target {
self.press_anim
.animate_to(if self.drag.pressed { 1.0 } else { 0.0 });
self.press_target = self.drag.pressed;
}
let press_animating = self.press_anim.advance(ctx.frame_time());
let dock_animating = self.dock_anim.advance(ctx.frame_time());
if press_animating || dock_animating {
ctx.request_frame();
}
let wave = self.wavy.then(|| {
let factor = self.amplitude_factor(fraction);
wave_paint(&mut self.wave_clock, self.wave, factor, ctx, reduce_motion)
});
if let Some(signal) = self.haptics.take(ctx.frame_time()) {
(self.haptic_sink)(signal);
}
if let Some(metrics) = TrackMetrics::resolve(&geometry) {
paint_track(scene, o, &map, &metrics, track_thickness, wave, &colors);
if self.icon.is_none() {
let spec = core::DotSpec {
stop_size: self.overrides.dot_size(),
tick_size: self.overrides.tick_size(),
edge_inset: self.overrides.dot_spacing(),
};
paint_dots(scene, o, &map, &metrics, &self.ticks, &spec, &colors);
}
}
self.paint_track_icons(scene, o, &map, fraction, &colors);
let thumb_thickness =
HANDLE_WIDTH + (PRESSED_HANDLE_WIDTH - HANDLE_WIDTH) * self.press_anim.value_clamped();
let thumb = map.place_thumb(
thumb_primary,
thumb_thickness,
self.overrides.thumb_length(),
);
paint_thumb(
scene,
o,
thumb,
focused,
self.interaction.resolve_opacity(),
&colors,
);
if let (Some(dock), Some((path, design))) = (dock, &self.icon_geom) {
let center = dock.center_at(self.dock_anim.value_clamped());
let icon_size = self.overrides.icon_size();
let color = if dock.over_active(center, map.flip(thumb_primary), self.reverse) {
colors.on_active
} else {
colors.on_inactive
};
paint_icon(
scene,
o,
path,
*design,
map.widget_square(center, icon_size),
icon_size,
color,
);
}
if self.drag.pressed {
paint_value_indicator(scene, o, &map, &self.indicator, thumb_primary, &colors);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
let map = self.axis_map(ctx.size());
let extent = map.extent();
let primary = map.event_primary(p.position);
match p.phase {
PointerPhase::Down => {
if !self.enabled || !presses(p) {
return EventResult::Ignored;
}
let out = self.drag.down(primary, &self.drag_ctx(extent));
self.apply(out, ctx)
}
PointerPhase::Move => {
let out = self.drag.moved(primary, &self.drag_ctx(extent));
self.apply(out, ctx)
}
PointerPhase::Up => {
let out = self.drag.up();
self.apply(out, ctx)
}
PointerPhase::Cancel => {
let out = self.drag.cancel();
if !out.handled {
return EventResult::Ignored;
}
self.interaction.set_pressed(false);
self.interaction.set_dragged(false);
ctx.request_redraw();
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_node(Role::Slider, |node| {
node.set_numeric_value(self.value);
node.set_min_numeric_value(self.spec.min);
node.set_max_numeric_value(self.spec.max);
});
}
}
impl SliderWidget {
fn icon_dock(&self, geometry: &SliderGeometry, thumb_primary: f64) -> Option<core::IconDock> {
if self.icon.is_none()
|| self.kind != SliderTrackKind::Standard
|| self.spec.divisions.is_some()
{
return None;
}
Some(core::icon_dock(&IconDockInput {
extent: geometry.extent,
thumb_primary,
icon_size: self.overrides.icon_size(),
edge_inset: self.overrides.icon_edge_inset(),
handle_thickness: geometry.handle_thickness,
position: self.icon_position,
reverse: geometry.reverse,
}))
}
fn paint_track_icons(
&self,
scene: &mut dyn PaintScene,
origin: Point,
map: &AxisMap,
fraction: f64,
colors: &SliderColors,
) {
let Some(icons) = &self.track_icons else {
return;
};
let size = icons.size();
for placement in
core::track_icon_placements(map.extent(), fraction, self.kind, size, icons.present())
{
let Some((path, design)) = &self.track_icon_geom[placement.slot.index()] else {
continue;
};
let color = if placement.active {
colors.on_active
} else {
colors.on_inactive
};
paint_icon(
scene,
origin,
path,
*design,
map.place_leading_square(placement.primary, size),
size,
color,
);
}
}
fn amplitude_factor(&self, progress: f64) -> f64 {
variants::amplitude_factor(
progress,
self.wave.amplitude,
self.amplitude_for_progress.as_deref(),
)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct WavePaint {
amplitude: f64,
wavelength: f64,
phase: f64,
}
fn wave_paint(
clock: &mut WaveClock,
wave: WaveOverrides,
amplitude_factor: f64,
ctx: &mut PaintCtx,
reduce_motion: bool,
) -> WavePaint {
if !reduce_motion {
clock.step(ctx.frame_time());
ctx.request_frame_paced();
}
WavePaint {
amplitude: WAVE_AMPLITUDE * amplitude_factor,
wavelength: wave.wavelength(),
phase: clock.radians(&wave),
}
}
fn paint_track(
scene: &mut dyn PaintScene,
origin: Point,
map: &AxisMap,
metrics: &TrackMetrics,
track_thickness: f64,
wave: Option<WavePaint>,
colors: &SliderColors,
) {
for segment in metrics.segments(false) {
let color = match segment.role {
SegmentRole::Active => colors.active_track,
SegmentRole::Inactive => colors.inactive_track,
};
if let Some(wave) = wave.filter(|_| segment.role == SegmentRole::Active)
&& let Some(path) = variants::wave_path(
map,
segment.start,
segment.end,
track_thickness,
wave.amplitude,
wave.wavelength,
wave.phase,
)
{
scene.stroke_path(origin, &path, track_thickness, &Brush::Solid(color));
continue;
}
let placed = map.place_span(
segment.start,
segment.end,
track_thickness,
segment.start_corner,
segment.end_corner,
);
scene.fill_rounded_rect_radii(
Point::new(origin.x + placed.origin.x, origin.y + placed.origin.y),
placed.size,
placed.radii,
color,
);
}
}
fn paint_dots(
scene: &mut dyn PaintScene,
origin: Point,
map: &AxisMap,
metrics: &TrackMetrics,
ticks: &[f64],
spec: &core::DotSpec,
colors: &SliderColors,
) {
for dot in core::dots(metrics, ticks, spec) {
let color = if dot.active {
colors.active_tick
} else {
colors.inactive_tick
};
let at = map.place_square(dot.primary, dot.size);
scene.fill_rounded_rect(
Point::new(origin.x + at.x, origin.y + at.y),
Size::new(dot.size, dot.size),
dot.size / 2.0,
color,
);
}
}
fn paint_thumb(
scene: &mut dyn PaintScene,
origin: Point,
thumb: kurbo::Rect,
focused: bool,
overlay: f32,
colors: &SliderColors,
) {
if overlay > 0.0 {
let halo = core::focus_ring_rect(thumb);
scene.fill_rounded_rect(
Point::new(origin.x + halo.x0, origin.y + halo.y0),
halo.size(),
core::thumb_radius(halo),
colors.thumb.multiply_alpha(overlay),
);
}
scene.fill_rounded_rect(
Point::new(origin.x + thumb.x0, origin.y + thumb.y0),
thumb.size(),
core::thumb_radius(thumb),
colors.thumb,
);
if focused {
let ring = core::focus_ring_rect(thumb);
let inset = FOCUS_STROKE / 2.0;
let path = RoundedRect::from_rect(
ring.inset(-inset),
(core::thumb_radius(ring) - inset).max(0.0),
)
.to_path(PATH_TOLERANCE);
scene.stroke_path(origin, &path, FOCUS_STROKE, &Brush::Solid(colors.thumb));
}
}
fn paint_value_indicator(
scene: &mut dyn PaintScene,
origin: Point,
map: &AxisMap,
indicator: &Run,
thumb_primary: f64,
colors: &SliderColors,
) {
let label = indicator.size();
let at = map.value_indicator_origin(thumb_primary);
let box_size = Size::new(
label.width + 2.0 * VALUE_INDICATOR_PAD_X,
label.height + 2.0 * VALUE_INDICATOR_PAD_Y,
);
let box_origin = Point::new(origin.x + at.x, origin.y + at.y);
scene.fill_rounded_rect(
box_origin,
box_size,
VALUE_INDICATOR_RADIUS,
colors.value_indicator,
);
indicator.paint(
Point::new(
box_origin.x + VALUE_INDICATOR_PAD_X,
box_origin.y + VALUE_INDICATOR_PAD_Y,
),
colors.value_indicator_label,
scene,
);
}
fn paint_icon(
scene: &mut dyn PaintScene,
origin: Point,
path: &kurbo::BezPath,
design: f64,
local: Point,
size: f64,
color: Color,
) {
let scale = if design > 0.0 { size / design } else { 1.0 };
let transform = Affine::translate(Vec2::new(local.x, local.y)) * Affine::scale(scale);
scene.fill_path(origin, &(transform * path.clone()), &Brush::Solid(color));
}
#[cfg(test)]
mod tests {
use super::*;
use frust::FrameTime;
use frust::authoring::scene::GlyphRun;
use frust::authoring::{CornerRadii, PointerButton, PointerEvent};
use kurbo::BezPath;
use std::any::Any;
use std::sync::Mutex;
const WIDTH: f64 = 200.0;
const SIZE: Size = Size::new(WIDTH, HANDLE_HEIGHT);
#[derive(Default)]
struct Val {
value: f64,
changes: Vec<f64>,
ends: Vec<f64>,
}
fn view(value: f64) -> SliderView<Val> {
slider::<Val, _>(value, |s: &mut Val, v: f64| {
s.value = v;
s.changes.push(v);
})
.on_change_end(|s: &mut Val, v: f64| s.ends.push(v))
}
fn build(view: &SliderView<Val>) -> SliderWidget {
let mut counter = 0u64;
View::<Val>::build(view, &mut BuildCtx::new(&mut counter))
}
fn widget(value: f64) -> SliderWidget {
build(&view(value))
}
fn ev(phase: PointerPhase, x: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, HANDLE_HEIGHT / 2.0),
button: PointerButton::Primary,
})
}
fn secondary_ev(phase: PointerPhase, x: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, HANDLE_HEIGHT / 2.0),
button: PointerButton::Secondary,
})
}
fn dispatch(w: &mut SliderWidget, state: &mut Val, event: &InputEvent) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, SIZE);
w.event(&mut ctx, event)
}
#[derive(Default)]
struct Recorder {
rrects: Vec<(Point, Size, f64, Color)>,
radii: Vec<(Point, Size, CornerRadii, Color)>,
strokes: Vec<(f64, Color)>,
stroke_paths: Vec<BezPath>,
paths: Vec<Color>,
runs: Vec<(Point, Color)>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
self.rrects.push((o, s, radius, color));
}
fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
self.radii.push((o, s, radii, color));
}
fn stroke_path(&mut self, _o: Point, p: &BezPath, width: f64, brush: &Brush) {
let color = match brush {
Brush::Solid(c) => *c,
_ => Color::TRANSPARENT,
};
self.strokes.push((width, color));
self.stroke_paths.push(p.clone());
}
fn fill_path(&mut self, _o: Point, _p: &BezPath, brush: &Brush) {
if let Brush::Solid(color) = brush {
self.paths.push(*color);
}
}
fn draw_glyph_run(&mut self, run: GlyphRun) {
let color = match run.brush {
Brush::Solid(c) => c,
_ => Color::TRANSPARENT,
};
let t = run.transform.translation();
self.runs.push((Point::new(t.x, t.y), color));
}
}
impl Recorder {
fn thumb(&self) -> (Point, Size, f64, Color) {
*self
.rrects
.iter()
.find(|(_, s, _, _)| s.height == HANDLE_HEIGHT)
.expect("every pass paints a thumb")
}
}
fn layout(w: &mut SliderWidget) -> Size {
let mut tcx = TextContext::new();
let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut ctx, &BoxConstraints::loose(SIZE))
}
fn paint(w: &mut SliderWidget, theme: Option<&Theme>) -> Recorder {
paint_frame(w, theme, SIZE).rec
}
struct Frame {
rec: Recorder,
needs_frame: bool,
paced_only: bool,
}
fn paint_frame(w: &mut SliderWidget, theme: Option<&Theme>, size: Size) -> Frame {
let mut rec = Recorder::default();
let mut ctx = match theme {
Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
None => PaintCtx::new(Point::ZERO, size),
};
w.paint(&mut ctx, &mut rec);
Frame {
needs_frame: ctx.needs_frame(),
paced_only: ctx.needs_frame_paced_only(),
rec,
}
}
#[test]
fn down_move_up_reports_in_order_and_settles_once() {
let mut w = widget(0.0);
let mut state = Val::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 100.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 260.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 260.0));
assert_eq!(state.changes, vec![0.25, 0.5, 1.0], "clamped at the end");
assert_eq!(state.ends, vec![1.0], "settles on the last reported value");
assert_eq!(
w.value, 0.0,
"a controlled slider never writes its own value"
);
}
#[test]
fn a_secondary_press_never_captures_or_reports() {
let mut w = widget(0.0);
let mut state = Val::default();
let result = dispatch(&mut w, &mut state, &secondary_ev(PointerPhase::Down, 50.0));
assert!(matches!(result, EventResult::Ignored));
assert!(!w.drag.dragging);
assert!(state.changes.is_empty());
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 120.0));
assert!(state.changes.is_empty());
}
#[test]
fn a_hover_move_is_ignored_and_requests_nothing() {
let mut w = widget(0.3);
let mut state = Val::default();
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, SIZE);
let result = w.event(&mut ctx, &ev(PointerPhase::Move, 100.0));
assert!(matches!(result, EventResult::Ignored));
assert!(!ctx.needs_redraw(), "a hover must not request a redraw");
assert!(state.changes.is_empty());
}
#[test]
fn cancel_disarms_without_reporting_or_settling() {
let mut w = widget(0.0);
let mut state = Val::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
assert!(w.drag.dragging);
dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 100.0));
assert!(!w.drag.dragging && !w.drag.pressed);
assert!(state.ends.is_empty(), "a Cancel never reaches app state");
let before = state.changes.len();
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 40.0));
assert_eq!(state.changes.len(), before);
}
#[test]
fn disabled_ignores_every_pointer_phase() {
let mut w = build(&view(0.0).enabled(false));
let mut state = Val::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
assert!(!w.drag.dragging);
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 120.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 120.0));
assert!(state.changes.is_empty() && state.ends.is_empty());
}
#[test]
fn disabling_mid_drag_disarms_the_machine() {
let mut w = widget(0.0);
let mut state = Val::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
assert!(w.drag.dragging);
let mut counter = 0u64;
let prev = view(0.0);
let next = view(0.0).enabled(false);
View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert!(!w.drag.dragging && !w.drag.pressed);
let before = state.changes.len();
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 160.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 160.0));
assert_eq!(
state.changes.len(),
before,
"a captured drag stops reporting"
);
assert!(state.ends.is_empty());
}
#[test]
fn a_discrete_drag_reports_only_when_it_crosses_a_step() {
let mut w = build(&view(0.0).range(0.0, 10.0).divisions(10));
let mut state = Val::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 103.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 121.0));
assert_eq!(state.changes, vec![5.0, 6.0]);
}
#[test]
fn a_drag_deduplicates_against_its_own_last_report_until_a_rebuild_confirms_one() {
let mut w = widget(0.0);
let mut state = Val::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
assert_eq!(w.last_reported, Some(0.5));
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 100.0));
assert_eq!(state.changes, vec![0.5], "the repeat is dropped");
let mut counter = 0u64;
let prev = view(0.0);
let next = view(0.5);
View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert_eq!(w.value, 0.5);
assert_eq!(w.last_reported, None, "the confirmed value takes over");
}
#[test]
fn rebuild_adopts_value_and_bounds_without_self_mutation() {
let mut counter = 0u64;
let prev = view(0.2);
let mut w = build(&prev);
assert_eq!(w.value, 0.2);
let next = view(0.8).range(0.0, 10.0).divisions(5);
let flags = View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert_eq!(w.value, 0.8);
assert_eq!(w.spec.max, 10.0);
assert_eq!(w.ticks.len(), 6, "divisions + 1 marks");
assert!(flags.needs_paint());
assert!(
flags.needs_layout(),
"the indicator label changed, so it must be re-shaped"
);
}
#[test]
fn layout_fills_the_width_and_takes_the_handle_height() {
let mut w = widget(0.5);
assert_eq!(layout(&mut w), SIZE);
let mut tcx = TextContext::new();
let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let unbounded = w.layout(
&mut ctx,
&BoxConstraints::new(Size::ZERO, Size::new(f64::INFINITY, f64::INFINITY)),
);
assert_eq!(unbounded.width, DEFAULT_EXTENT);
}
#[test]
fn the_track_paints_two_segments_with_asymmetric_radii_and_a_stop_dot() {
let mut w = widget(0.5);
layout(&mut w);
let rec = paint(&mut w, None);
assert_eq!(rec.radii.len(), 2, "inactive remainder, then active span");
let (inactive_origin, inactive_size, inactive_radii, inactive_color) = rec.radii[0];
let (active_origin, active_size, active_radii, active_color) = rec.radii[1];
assert_eq!(active_color, PRIMARY);
assert_eq!(inactive_color, SECONDARY_CONTAINER);
assert_eq!(active_origin, Point::new(0.0, 14.0), "vertically centred");
assert_eq!(active_size, Size::new(92.0, TRACK_HEIGHT));
assert_eq!(inactive_origin.x, 108.0);
assert_eq!(inactive_size.width, 92.0);
assert_eq!(active_radii.top_left, TRACK_CORNER_RADIUS);
assert_eq!(active_radii.top_right, core::TRACK_INSIDE_CORNER);
assert_eq!(inactive_radii.top_left, core::TRACK_INSIDE_CORNER);
assert_eq!(inactive_radii.top_right, TRACK_CORNER_RADIUS);
assert_eq!(rec.rrects.len(), 2, "stop dot, then thumb");
let (dot_origin, dot_size, dot_radius, dot_color) = rec.rrects[0];
assert_eq!(
dot_size,
Size::new(STOP_INDICATOR_SIZE, STOP_INDICATOR_SIZE)
);
assert_eq!(dot_radius, STOP_INDICATOR_SIZE / 2.0);
assert_eq!(dot_origin, Point::new(190.0, 20.0), "centred at 192");
assert_eq!(dot_color, PRIMARY, "a stop takes the active-track ink");
let (thumb_origin, thumb_size, thumb_radius, thumb_color) = rec.rrects[1];
assert_eq!(thumb_size, Size::new(HANDLE_WIDTH, HANDLE_HEIGHT));
assert_eq!(thumb_origin, Point::new(98.0, 0.0));
assert_eq!(thumb_radius, HANDLE_HEIGHT / 2.0);
assert_eq!(thumb_color, PRIMARY);
}
#[test]
fn a_centered_track_paints_three_segments() {
let mut w = build(¢ered_slider::<Val, _>(0.75, |_s, _v| {}));
layout(&mut w);
let rec = paint(&mut w, None);
assert_eq!(rec.radii.len(), 3);
assert_eq!(rec.radii[2].3, PRIMARY, "the active span paints last");
assert_eq!(rec.radii[2].0.x, 100.0, "growing from the midpoint");
}
#[test]
fn themed_paint_resolves_the_slider_roles() {
let theme = crate::baseline();
let scheme = theme.scheme();
let mut w = widget(0.5);
layout(&mut w);
let rec = paint(&mut w, Some(&theme));
assert_eq!(rec.radii[0].3, scheme.secondary_container);
assert_eq!(rec.radii[1].3, scheme.primary);
assert_eq!(rec.rrects[1].3, scheme.primary, "thumb");
}
#[test]
fn a_disabled_slider_dims_active_and_inactive_by_their_own_opacities() {
let theme = crate::baseline();
let scheme = theme.scheme();
let mut w = build(&view(0.5).enabled(false));
layout(&mut w);
let rec = paint(&mut w, Some(&theme));
assert_eq!(
rec.radii[0].3,
scheme.on_surface.multiply_alpha(DISABLED_INACTIVE_OPACITY)
);
assert_eq!(
rec.radii[1].3,
scheme.on_surface.multiply_alpha(DISABLED_ACTIVE_OPACITY)
);
}
#[test]
fn discrete_ticks_paint_alongside_the_stops() {
let mut w = build(&view(0.5).divisions(4));
layout(&mut w);
let rec = paint(&mut w, None);
assert_eq!(rec.rrects.len(), 4);
assert_eq!(rec.rrects[1].0.x, 52.0, "tick at 0.25, centred on 54");
assert_eq!(
rec.rrects[1].3, SECONDARY_CONTAINER,
"a tick on the active track takes the inactive-track ink"
);
assert_eq!(rec.rrects[2].3, PRIMARY, "and vice versa past the thumb");
}
#[test]
fn the_pressed_thumb_compresses_over_the_morph_and_shows_the_value_indicator() {
let mut w = widget(0.5);
layout(&mut w);
let mut state = Val::default();
let rec = paint(&mut w, None);
assert_eq!(rec.rrects.len(), 2, "stop dot + thumb only");
assert_eq!(rec.rrects[1].1.width, HANDLE_WIDTH);
assert!(rec.runs.is_empty(), "no indicator label at rest");
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
let rec = paint(&mut w, None);
assert!(w.press_anim.is_animating(), "a 100ms ease-out started");
assert_eq!(rec.thumb().1.width, HANDLE_WIDTH);
let halo = rec
.rrects
.iter()
.find(|(_, s, _, _)| s.width == HANDLE_WIDTH + core::FOCUS_INFLATE)
.expect("a dragged handle paints its halo");
assert!(
(halo.3.components[3] - crate::interaction::DRAGGED_OPACITY).abs() < 1e-6,
"the halo takes the dragged precedence opacity"
);
assert_eq!(rec.runs.len(), 1, "the value indicator's label");
w.press_anim.advance(secs(0.05));
let mid =
HANDLE_WIDTH + (PRESSED_HANDLE_WIDTH - HANDLE_WIDTH) * w.press_anim.value_clamped();
assert!(mid < HANDLE_WIDTH && mid > PRESSED_HANDLE_WIDTH, "{mid}");
w.press_anim.advance(secs(0.2));
assert!(!w.press_anim.is_animating(), "settled inside 100ms");
let rec = paint(&mut w, None);
assert_eq!(rec.thumb().1.width, PRESSED_HANDLE_WIDTH);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 100.0));
let rec = paint(&mut w, None);
assert!(rec.runs.is_empty(), "the indicator hides on release");
assert!(w.press_anim.is_animating(), "and the morph reverses");
}
#[test]
fn the_focus_ring_strokes_only_while_the_control_holds_focus() {
let mut w = widget(0.5);
layout(&mut w);
assert!(paint(&mut w, None).strokes.is_empty());
let ring = core::focus_ring_rect(core::thumb_rect(
100.0,
HANDLE_HEIGHT / 2.0,
HANDLE_WIDTH,
HANDLE_HEIGHT,
));
assert_eq!(ring.width(), HANDLE_WIDTH + core::FOCUS_INFLATE);
assert_eq!(ring.height(), HANDLE_HEIGHT + core::FOCUS_INFLATE);
}
#[test]
fn a_relocating_icon_replaces_the_stop_dots_and_docks_beside_the_thumb() {
let mut w = build(&view(0.9).icon(crate::icons::CHECK));
layout(&mut w);
let rec = paint(&mut w, None);
assert_eq!(rec.rrects.len(), 1, "the thumb only — no stop dots");
assert_eq!(rec.paths.len(), 1, "the icon paints as a path");
assert!(w.dock_target, "a thumb at 180 is inside the dock limit");
assert!(w.dock_anim.is_animating(), "the dock spring started");
let mut counter = 0u64;
let prev = view(0.9).icon(crate::icons::CHECK);
let next = view(0.1).icon(crate::icons::CHECK);
View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
paint(&mut w, None);
assert!(!w.dock_target);
}
#[test]
fn inset_track_icons_paint_per_segment() {
let icons = SliderTrackIcons {
active_start: Some(crate::icons::CHECK),
inactive_end: Some(crate::icons::CLOSE),
..SliderTrackIcons::new()
};
let mut w = build(&view(0.5).track_icons(icons));
layout(&mut w);
let rec = paint(&mut w, None);
assert_eq!(rec.paths.len(), 2, "one per filled slot");
assert_eq!(rec.paths[0], ON_PRIMARY, "over the active track");
assert_eq!(rec.paths[1], ON_SURFACE_VARIANT, "over the inactive track");
}
const V_SIZE: Size = Size::new(HANDLE_HEIGHT, WIDTH);
fn v_view(value: f64) -> SliderView<Val> {
vertical_slider::<Val, _>(value, |s: &mut Val, v: f64| {
s.value = v;
s.changes.push(v);
})
}
fn v_layout(w: &mut SliderWidget) -> Size {
let mut tcx = TextContext::new();
let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut ctx, &BoxConstraints::loose(V_SIZE))
}
fn v_ev(phase: PointerPhase, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(HANDLE_HEIGHT / 2.0, y),
button: PointerButton::Primary,
})
}
fn v_dispatch(w: &mut SliderWidget, state: &mut Val, event: &InputEvent) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, V_SIZE);
w.event(&mut ctx, event)
}
#[test]
fn a_vertical_slider_fills_the_height_and_takes_the_handle_width_across_it() {
let mut w = build(&v_view(0.5));
assert_eq!(v_layout(&mut w), V_SIZE);
let mut tcx = TextContext::new();
let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let unbounded = w.layout(
&mut ctx,
&BoxConstraints::new(Size::ZERO, Size::new(f64::INFINITY, f64::INFINITY)),
);
assert_eq!(
unbounded.height, DEFAULT_EXTENT,
"the primary axis is y now"
);
assert_eq!(unbounded.width, HANDLE_HEIGHT);
}
#[test]
fn a_vertical_slider_maps_value_to_y_bottom_up() {
let mut w = build(&v_view(0.25));
v_layout(&mut w);
let rec = paint_frame(&mut w, None, V_SIZE).rec;
assert_eq!(rec.radii.len(), 2);
let (inactive_origin, inactive_size, inactive_radii, _) = rec.radii[0];
let (active_origin, active_size, active_radii, active_color) = rec.radii[1];
assert_eq!(active_color, PRIMARY);
assert_eq!(
active_origin,
Point::new(14.0, 158.0),
"42 tall, at the foot"
);
assert_eq!(active_size, Size::new(TRACK_HEIGHT, 42.0));
assert_eq!(inactive_origin, Point::new(14.0, 0.0));
assert_eq!(inactive_size, Size::new(TRACK_HEIGHT, 142.0));
assert_eq!(active_radii.bottom_left, TRACK_CORNER_RADIUS);
assert_eq!(active_radii.top_left, core::TRACK_INSIDE_CORNER);
assert_eq!(inactive_radii.top_left, TRACK_CORNER_RADIUS);
assert_eq!(inactive_radii.bottom_left, core::TRACK_INSIDE_CORNER);
let thumb = rec
.rrects
.iter()
.find(|(_, s, _, _)| s.width == HANDLE_HEIGHT)
.expect("a vertical thumb spans the cross axis");
assert_eq!(thumb.0, Point::new(0.0, 148.0), "centred on y = 150");
assert_eq!(thumb.1, Size::new(HANDLE_HEIGHT, HANDLE_WIDTH));
let mut state = Val::default();
v_dispatch(&mut w, &mut state, &v_ev(PointerPhase::Down, 50.0));
assert_eq!(state.changes, vec![0.75]);
v_dispatch(&mut w, &mut state, &v_ev(PointerPhase::Move, 0.0));
assert_eq!(state.changes.last(), Some(&1.0), "the top edge is the max");
}
#[test]
fn top_to_bottom_puts_the_minimum_at_the_top_instead() {
let mut w = build(&v_view(0.25).top_to_bottom(true));
v_layout(&mut w);
let rec = paint_frame(&mut w, None, V_SIZE).rec;
let (active_origin, active_size, _, _) = rec.radii[1];
assert_eq!(active_origin, Point::new(14.0, 0.0), "grown from the top");
assert_eq!(active_size.height, 42.0);
let mut state = Val::default();
v_dispatch(&mut w, &mut state, &v_ev(PointerPhase::Down, 100.0));
assert_eq!(state.changes, vec![0.5], "y now runs with the value");
v_dispatch(&mut w, &mut state, &v_ev(PointerPhase::Move, 0.0));
assert_eq!(state.changes.last(), Some(&0.0), "the top edge is the min");
}
#[test]
fn a_vertical_centered_slider_grows_from_the_midpoint() {
let mut w = build(&vertical_centered_slider::<Val, _>(0.75, |_s, _v| {}));
v_layout(&mut w);
let rec = paint_frame(&mut w, None, V_SIZE).rec;
assert_eq!(
rec.radii.len(),
3,
"both inactive ends plus the active span"
);
let (active_origin, active_size, _, _) = rec.radii[2];
assert_eq!(active_origin, Point::new(14.0, 58.0));
assert_eq!(active_size.height, 42.0);
assert_eq!(active_origin.y + active_size.height, 100.0, "the midpoint");
}
fn path_points(path: &BezPath) -> Vec<Point> {
path.elements()
.iter()
.map(|el| match el {
kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => *p,
other => panic!("a sampled wave is moves and lines only: {other:?}"),
})
.collect()
}
#[test]
fn a_wavy_slider_strokes_its_active_span_instead_of_filling_it() {
let mut w = build(&wavy_slider::<Val, _>(0.5, |_s, _v| {}));
layout(&mut w);
let frame = paint_frame(&mut w, None, SIZE);
assert_eq!(
frame.rec.radii.len(),
1,
"only the inactive remainder fills"
);
assert_eq!(frame.rec.strokes.len(), 1, "the active span is a wave");
assert_eq!(
frame.rec.strokes[0],
(TRACK_HEIGHT, PRIMARY),
"stroked at the track's own thickness, in the active ink"
);
let points = path_points(&frame.rec.stroke_paths[0]);
assert_eq!(points[0].x, TRACK_HEIGHT / 2.0);
assert_eq!(points.last().unwrap().x, 92.0 - TRACK_HEIGHT / 2.0);
assert!(
points
.iter()
.any(|p| (p.y - HANDLE_HEIGHT / 2.0).abs() > 1.0),
"and it actually leaves the centreline: {points:?}"
);
assert!(frame.paced_only, "a traveling wave is a cosmetic loop");
}
#[test]
fn the_wave_moves_as_its_phase_advances_and_freezes_under_reduce_motion() {
let mut w = build(&wavy_slider::<Val, _>(0.5, |_s, _v| {}));
layout(&mut w);
let first = path_points(&paint(&mut w, None).stroke_paths[0]);
w.wave_clock.step(secs(0.05));
let second = path_points(&paint(&mut w, None).stroke_paths[0]);
assert_eq!(first.len(), second.len(), "the same span, re-sampled");
assert!(
first.iter().zip(second.iter()).all(|(a, b)| a.x == b.x),
"the span itself does not move, only the wave on it"
);
let travel = first
.iter()
.zip(second.iter())
.map(|(a, b)| (a.y - b.y).abs())
.fold(0.0_f64, f64::max);
assert!(
travel > 0.1,
"the wave must travel: {:?} vs {:?}",
&first[..3],
&second[..3]
);
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let before = w.wave_clock.seconds();
let frame = paint_frame(&mut w, Some(&theme), SIZE);
assert_eq!(frame.rec.strokes.len(), 1, "the frozen wave is still drawn");
assert_eq!(w.wave_clock.seconds(), before, "and its phase stands still");
assert!(!frame.needs_frame, "a frozen loop requests no frames");
}
#[test]
fn the_amplitude_ramp_flattens_a_nearly_full_track_unless_overridden() {
let mut w = build(&wavy_slider::<Val, _>(0.98, |_s, _v| {}));
layout(&mut w);
let flat = path_points(&paint(&mut w, None).stroke_paths[0]);
assert!(flat.iter().all(|p| p.y == HANDLE_HEIGHT / 2.0), "{flat:?}");
let mut w = build(&wavy_slider::<Val, _>(0.98, |_s, _v| {}).amplitude(1.0));
layout(&mut w);
let waved = path_points(&paint(&mut w, None).stroke_paths[0]);
assert!(waved.iter().any(|p| p.y != HANDLE_HEIGHT / 2.0));
let mut w = build(
&wavy_slider::<Val, _>(0.5, |_s, _v| {})
.amplitude(1.0)
.amplitude_for_progress(|_| 0.0),
);
layout(&mut w);
let curved = path_points(&paint(&mut w, None).stroke_paths[0]);
assert!(
curved.iter().all(|p| p.y == HANDLE_HEIGHT / 2.0),
"{curved:?}"
);
}
#[test]
fn a_wavy_centered_slider_waves_only_its_active_span() {
let mut w = build(&wavy_centered_slider::<Val, _>(0.75, |_s, _v| {}));
layout(&mut w);
let rec = paint(&mut w, None);
assert_eq!(rec.radii.len(), 2, "both inactive spans stay flat rects");
assert_eq!(rec.strokes.len(), 1, "the centred active span waves");
let points = path_points(&rec.stroke_paths[0]);
assert_eq!(points[0].x, 100.0 + TRACK_HEIGHT / 2.0, "from the midpoint");
}
#[test]
fn a_flat_slider_never_asks_for_a_frame_it_does_not_need() {
let mut w = widget(0.5);
layout(&mut w);
let frame = paint_frame(&mut w, None, SIZE);
assert!(frame.rec.strokes.is_empty());
assert!(!frame.needs_frame, "a resting flat slider is static");
}
#[test]
fn semantics_reports_the_value_and_its_bounds() {
fn logic(_state: &mut Val) -> SliderView<Val> {
slider::<Val, _>(4.0, |_s, _v| {}).range(0.0, 10.0)
}
let mut root: frust_core::RenderRoot<Val, SliderView<Val>> = frust_core::RenderRoot::new();
let mut state = Val::default();
let mut tcx = TextContext::new();
root.rebuild(&mut logic, &mut state);
root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Slider)
.expect("a slider contributes a Role::Slider node");
assert_eq!(node.numeric_value(), Some(4.0));
assert_eq!(node.min_numeric_value(), Some(0.0));
assert_eq!(node.max_numeric_value(), Some(10.0));
}
static RECORDED: Mutex<Vec<HapticSignal>> = Mutex::new(Vec::new());
fn recording_sink(signal: HapticSignal) {
RECORDED.lock().unwrap().push(signal);
}
fn secs(s: f64) -> FrameTime {
FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
}
fn paint_at(w: &mut SliderWidget, now: FrameTime) -> usize {
let fired = w.haptics.take(now);
if let Some(signal) = fired {
(w.haptic_sink)(signal);
}
usize::from(fired.is_some())
}
#[test]
fn haptics_are_opt_in_then_paced_per_division_or_per_60ms() {
RECORDED.lock().unwrap().clear();
let mut w = build(&view(0.0).haptic_sink(recording_sink));
let mut state = Val::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 120.0));
assert!(
!w.haptics.has_pending(),
"no haptic queued at HapticSignal::None"
);
assert!(RECORDED.lock().unwrap().is_empty());
let mut w = build(
&view(0.0)
.haptic(HapticSignal::SliderTick)
.haptic_sink(recording_sink),
);
let mut state = Val::default();
let mut fired = 0;
for frame in 0..13 {
dispatch(
&mut w,
&mut state,
&ev(
if frame == 0 {
PointerPhase::Down
} else {
PointerPhase::Move
},
10.0 + f64::from(frame) * 10.0,
),
);
fired += paint_at(&mut w, secs(f64::from(frame) * 0.016));
}
assert_eq!(
fired, 4,
"192ms of drag: the first tick plus one per 60ms boundary"
);
assert_eq!(state.changes.len(), 13, "every frame still reports a value");
assert_eq!(
*RECORDED.lock().unwrap(),
vec![HapticSignal::SliderTick; 4],
"and each fired tick reached the sink"
);
RECORDED.lock().unwrap().clear();
let mut w = build(
&view(0.0)
.range(0.0, 10.0)
.divisions(10)
.haptic(HapticSignal::Medium)
.haptic_sink(recording_sink),
);
let mut state = Val::default();
let mut fired = 0;
for frame in 0..5 {
dispatch(
&mut w,
&mut state,
&ev(
if frame == 0 {
PointerPhase::Down
} else {
PointerPhase::Move
},
20.0 * f64::from(frame + 1),
),
);
fired += paint_at(&mut w, secs(f64::from(frame) * 0.001));
}
assert_eq!(fired, 5, "one tick per crossed division, unthrottled");
assert_eq!(*RECORDED.lock().unwrap(), vec![HapticSignal::Medium; 5]);
}
}