use std::rc::Rc;
use frust_core::accesskit::Role;
use frust_core::{
BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
PaintScene, PointerPhase, SemanticsCtx, View, Widget,
};
use frust_theme::{DesignLanguage, GlassMaterial, GlassScale, Theme};
use kurbo::{Point, Size};
use peniko::{Brush, Color, Gradient};
use crate::authoring::presses;
const HEIGHT: f64 = 24.0;
const DEFAULT_WIDTH: f64 = 200.0;
const TRACK_H: f64 = 4.0;
const THUMB: f64 = 18.0;
const CUPERTINO_THUMB: f64 = 28.0;
const CUPERTINO_THUMB_FILL: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
const SPECULAR_PEAK_SCALE: f32 = 1.8;
const TRACK: Color = Color::from_rgb8(0xD1, 0xD5, 0xDB);
const FILL: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
const THUMB_FILL: Color = Color::from_rgb8(0x1D, 0x4E, 0xD8);
fn resolve_colors(theme: Option<&Theme>) -> (Color, Color, Color) {
match theme {
Some(theme) => {
let scheme = theme.scheme();
(
scheme.surface_container_highest,
scheme.primary,
scheme.primary,
)
}
None => (TRACK, FILL, THUMB_FILL),
}
}
fn is_cupertino(theme: Option<&Theme>) -> bool {
theme.map(|t| t.design_language) == Some(DesignLanguage::Cupertino)
}
fn resolve_control_glass(theme: Option<&Theme>) -> GlassMaterial {
theme
.map(|t| t.glass.control.clone())
.unwrap_or_else(|| GlassScale::opaque_material().control)
}
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
type OnChange<State> = Rc<dyn Fn(&mut State, f64)>;
pub struct SliderView<State: 'static> {
value: f64,
on_change: OnChange<State>,
}
pub fn slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
value: f64,
on_change: F,
) -> SliderView<State> {
SliderView {
value: value.clamp(0.0, 1.0),
on_change: Rc::new(on_change),
}
}
#[allow(non_snake_case)]
pub fn Slider<State: 'static, F: Fn(&mut State, f64) + 'static>(
value: f64,
on_change: F,
) -> SliderView<State> {
slider(value, on_change)
}
pub struct SliderWidget {
value: f64,
captured: bool,
on_change: crate::authoring::ErasedArgCallback<f64>,
}
fn value_from_x(x: f64, width: f64) -> f64 {
if width <= 0.0 {
0.0
} else {
(x / width).clamp(0.0, 1.0)
}
}
impl<State: 'static> View<State> for SliderView<State> {
type Element = SliderWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> SliderWidget {
SliderWidget {
value: self.value,
captured: false,
on_change: crate::authoring::erase_callback_arg(&self.on_change),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut SliderWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.on_change = crate::authoring::erase_callback_arg(&self.on_change);
if prev.value != self.value {
element.value = self.value;
ChangeFlags::PAINT
} else {
ChangeFlags::NONE
}
}
}
impl Widget for SliderWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let width = if bc.max().width.is_finite() {
bc.max().width
} else {
DEFAULT_WIDTH
};
bc.constrain(Size::new(width, HEIGHT))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let (track, fill, thumb_fill) = resolve_colors(theme);
let o = ctx.origin();
let w = ctx.size().width;
let mid_y = o.y + ctx.size().height / 2.0;
scene.fill_rounded_rect(
Point::new(o.x, mid_y - TRACK_H / 2.0),
Size::new(w, TRACK_H),
TRACK_H / 2.0,
track,
);
let thumb_x = o.x + self.value.clamp(0.0, 1.0) * w;
scene.fill_rounded_rect(
Point::new(o.x, mid_y - TRACK_H / 2.0),
Size::new((thumb_x - o.x).max(0.0), TRACK_H),
TRACK_H / 2.0,
fill,
);
if is_cupertino(theme) {
let diam = CUPERTINO_THUMB;
let thumb_origin = Point::new(thumb_x - diam / 2.0, mid_y - diam / 2.0);
let thumb_size = Size::new(diam, diam);
let glass_control = resolve_control_glass(theme);
scene.draw_shadow(
Point::new(
thumb_origin.x,
thumb_origin.y + glass_control.shadow.y_offset,
),
thumb_size,
diam / 2.0,
glass_control.shadow.blur_std_dev,
with_alpha(Color::BLACK, glass_control.shadow.color_alpha),
);
scene.fill_rounded_rect(thumb_origin, thumb_size, diam / 2.0, CUPERTINO_THUMB_FILL);
let highlight_center = Point::new(thumb_x - diam * 0.18, mid_y - diam * 0.28);
let peak_alpha = (glass_control.hairline_alpha * SPECULAR_PEAK_SCALE).min(1.0);
let highlight = Gradient::new_radial(highlight_center, (diam * 0.65) as f32)
.with_stops([
(0.0f32, with_alpha(CUPERTINO_THUMB_FILL, peak_alpha)),
(1.0f32, with_alpha(CUPERTINO_THUMB_FILL, 0.0)),
]);
scene.fill_rounded_rect_brush(
thumb_origin,
thumb_size,
diam / 2.0,
&Brush::Gradient(highlight),
);
} else {
scene.fill_rounded_rect(
Point::new(thumb_x - THUMB / 2.0, mid_y - THUMB / 2.0),
Size::new(THUMB, THUMB),
THUMB / 2.0,
thumb_fill,
);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return EventResult::Ignored;
}
self.captured = true;
ctx.capture_pointer();
let v = value_from_x(p.position.x, ctx.size().width);
(self.on_change)(ctx, v);
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Move => {
if !self.captured {
return EventResult::Ignored;
}
let v = value_from_x(p.position.x, ctx.size().width);
(self.on_change)(ctx, v);
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Up | PointerPhase::Cancel => {
if !self.captured {
return EventResult::Ignored;
}
self.captured = 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(0.0);
node.set_max_numeric_value(1.0);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::any::Any;
#[derive(Default)]
struct Val {
value: f64,
changes: u32,
}
fn widget(value: f64) -> SliderWidget {
let view = slider::<Val, _>(value, |s: &mut Val, v: f64| {
s.value = v;
s.changes += 1;
});
let mut counter = 0u64;
View::<Val>::build(&view, &mut BuildCtx::new(&mut counter))
}
fn ev(phase: PointerPhase, x: f64) -> InputEvent {
InputEvent::Pointer(frust_core::PointerEvent {
phase,
position: Point::new(x, 12.0),
button: frust_core::PointerButton::Primary,
})
}
fn secondary_ev(phase: PointerPhase, x: f64) -> InputEvent {
InputEvent::Pointer(frust_core::PointerEvent {
phase,
position: Point::new(x, 12.0),
button: frust_core::PointerButton::Secondary,
})
}
fn dispatch(w: &mut SliderWidget, state: &mut Val, event: &InputEvent) {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(200.0, HEIGHT));
w.event(&mut ctx, event);
}
#[test]
fn a_secondary_press_never_captures_or_reports_a_value() {
let mut w = widget(0.0);
let mut state = Val::default();
dispatch(&mut w, &mut state, &secondary_ev(PointerPhase::Down, 50.0));
assert!(!w.captured, "no capture opened");
assert_eq!(state.changes, 0, "and no value reported");
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 120.0));
assert_eq!(state.changes, 0);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
assert!(w.captured);
assert_eq!(state.value, 0.25);
}
#[test]
fn value_math_maps_position_to_fraction() {
assert_eq!(value_from_x(0.0, 200.0), 0.0);
assert_eq!(value_from_x(100.0, 200.0), 0.5);
assert_eq!(value_from_x(200.0, 200.0), 1.0);
assert_eq!(value_from_x(-40.0, 200.0), 0.0);
assert_eq!(value_from_x(9000.0, 200.0), 1.0);
assert_eq!(value_from_x(10.0, 0.0), 0.0);
}
#[test]
fn down_reports_value_at_position() {
let mut w = widget(0.0);
let mut state = Val::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
assert_eq!(state.value, 0.25);
assert_eq!(state.changes, 1);
}
#[test]
fn drag_reports_continuously_and_clamps() {
let mut w = widget(0.0);
let mut state = Val::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 300.0)); assert_eq!(state.value, 1.0);
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, -20.0)); assert_eq!(state.value, 0.0);
assert_eq!(state.changes, 3);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 0.0));
assert_eq!(state.changes, 3);
}
#[test]
fn hover_move_without_down_is_ignored_noop() {
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::new(200.0, HEIGHT));
let result = w.event(&mut ctx, &ev(PointerPhase::Move, 100.0));
assert!(matches!(result, EventResult::Ignored));
assert!(!ctx.needs_redraw(), "hover must not request a redraw");
assert_eq!(state.changes, 0, "hover must not report a value");
}
#[test]
fn cancel_clears_captured_state() {
let mut w = widget(0.0);
let mut state = Val::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 100.0));
assert!(w.captured);
dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 100.0));
assert!(!w.captured, "Cancel disarms the drag");
let before = state.changes;
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 40.0));
assert_eq!(state.changes, before, "hover after Cancel does not report");
}
#[derive(Default)]
struct TrackRecorder {
rrects: Vec<Color>,
shadows: Vec<(Point, Size, f64, f64, Color)>,
brushes: Vec<(Point, Size, f64, Brush)>,
}
impl PaintScene for TrackRecorder {
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, _r: f64, color: Color) {
self.rrects.push(color);
}
fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
self.shadows.push((o, s, radius, std_dev, color));
}
fn fill_rounded_rect_brush(&mut self, o: Point, s: Size, radius: f64, brush: &Brush) {
self.brushes.push((o, s, radius, brush.clone()));
}
}
fn paint_colors(value: f64, theme: Option<&Theme>) -> Vec<Color> {
let mut w = widget(value);
let mut rec = TrackRecorder::default();
let mut ctx = match theme {
Some(t) => PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)).with_theme(t),
None => PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)),
};
w.paint(&mut ctx, &mut rec);
rec.rrects
}
#[test]
fn unthemed_paint_uses_fallback_constants() {
assert_eq!(paint_colors(0.5, None), vec![TRACK, FILL, THUMB_FILL]);
}
#[test]
fn themed_paint_resolves_the_scheme_s_slider_roles() {
let theme = Theme::neutral();
let scheme = theme.scheme();
assert_eq!(
paint_colors(0.5, Some(&theme)),
vec![
scheme.surface_container_highest,
scheme.primary,
scheme.primary
],
);
}
#[test]
fn non_cupertino_and_unthemed_paint_have_no_reflective_treatment() {
let mut unthemed = widget(0.5);
let mut rec = TrackRecorder::default();
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT));
unthemed.paint(&mut ctx, &mut rec);
assert_eq!(rec.rrects.len(), 3, "track + fill + thumb, nothing else");
assert!(rec.shadows.is_empty());
assert!(rec.brushes.is_empty());
let theme = Theme::neutral();
assert_ne!(
theme.design_language,
DesignLanguage::Cupertino,
"the default path under test must not be the Cupertino one"
);
let mut themed = widget(0.5);
let mut rec = TrackRecorder::default();
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)).with_theme(&theme);
themed.paint(&mut ctx, &mut rec);
assert_eq!(rec.rrects.len(), 3);
assert!(rec.shadows.is_empty());
assert!(rec.brushes.is_empty());
}
fn cupertino_theme() -> Theme {
Theme::builder(Theme::neutral())
.design_language(DesignLanguage::Cupertino)
.map_glass(|mut g| {
g.control = GlassMaterial {
blur_radius_intent: 15.0,
fills_light: Vec::new(),
fills_dark: Vec::new(),
hairline_alpha: 0.3,
shadow: frust_theme::ShadowSpec {
y_offset: 2.0,
blur_std_dev: 4.0,
color_alpha: 0.12,
},
};
g
})
.build()
}
#[test]
fn cupertino_themed_paint_uses_the_larger_reflective_knob() {
let theme = cupertino_theme();
assert_eq!(theme.design_language, DesignLanguage::Cupertino);
let mut w = widget(0.5);
let mut rec = TrackRecorder::default();
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(200.0, HEIGHT)).with_theme(&theme);
w.paint(&mut ctx, &mut rec);
assert_eq!(rec.rrects.len(), 3);
assert_eq!(rec.rrects[2], CUPERTINO_THUMB_FILL);
assert_eq!(rec.shadows.len(), 1, "one drop shadow under the knob");
let expected = theme.glass.control.shadow;
assert_eq!(
rec.shadows[0].2,
CUPERTINO_THUMB / 2.0,
"circular, knob radius"
);
assert_eq!(rec.shadows[0].3, expected.blur_std_dev);
assert_eq!(rec.brushes.len(), 1, "one specular gradient highlight");
assert_eq!(
rec.brushes[0].2,
CUPERTINO_THUMB / 2.0,
"circular, knob radius"
);
match &rec.brushes[0].3 {
Brush::Gradient(g) => assert_eq!(g.stops.len(), 2, "peak + fade-to-transparent"),
_ => panic!("expected a gradient brush for the specular highlight"),
}
}
#[test]
fn rebuild_adopts_new_value_without_self_mutation() {
let mut counter = 0u64;
let prev = slider::<Val, _>(0.2, |_s, _v| {});
let mut w = View::<Val>::build(&prev, &mut BuildCtx::new(&mut counter));
assert_eq!(w.value, 0.2);
let next = slider::<Val, _>(0.8, |_s, _v| {});
View::<Val>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert_eq!(w.value, 0.8);
}
}