Skip to main content

ferroui_themes_material/
selection_mark.rs

1/*
2 * Copyright 2024 The Android Open Source Project
3 * Copyright 2026 FerroUI contributors
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
7 * Unless required by applicable law or agreed to in writing, software distributed
8 * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
9 * CONDITIONS OF ANY KIND, either express or implied. See the License for the
10 * specific language governing permissions and limitations under the License.
11 */
12//! Path-length check revelation and check/dash gravitation from AndroidX
13//! Checkbox.kt, e4d933c98df7baa7ea2b83eea6e086915961e0ff. The 18dp geometry is
14//! the latest Material3 styling branch (isCheckboxStylingFixEnabled=true).
15
16use crate::motion::{
17    animations_enabled, MaterialMotionRole, MaterialMotionScheme, MaterialSpringState,
18};
19use ferroui_base::animation::{IGlobalClock, TimeSpan};
20use ferroui_base::input::InputElementImpl;
21use ferroui_base::interactivity::InteractiveImpl;
22use ferroui_base::layout::LayoutableImpl;
23use ferroui_base::media::immutable::ImmutablePen;
24use ferroui_base::media::{
25    BrushExtensions, DrawingContext, IBrush, IPen, PenLineCap, PenLineJoin, StreamGeometry,
26};
27use ferroui_base::platform::IGeometryContext;
28use ferroui_base::reactive::{AnonymousObserver, IDisposable};
29use ferroui_base::{
30    ferro_class, ferro_class_info, ferro_impl_classes, ferro_properties, instantiate, FerroLocator,
31    FerroObjectImpl, FerroObjectImplExt, FerroProperty, FerroPropertyChangedEventArgs,
32    LocatorExtensions, Point, Ref, Size, StyledElementImpl, StyledProperty, Visual, VisualImpl,
33    VisualImplExt, VisualTreeAttachmentEventArgs,
34};
35use ferroui_controls::{Control, ControlImpl};
36use std::cell::{Cell, RefCell};
37use std::rc::Rc;
38
39#[repr(C)]
40pub struct MaterialSelectionMark {
41    base: Control,
42    motion: RefCell<MarkMotion>,
43    clock_subscription: RefCell<Option<Rc<dyn IDisposable>>>,
44    visibility_subscription: RefCell<Option<Rc<dyn IDisposable>>>,
45    last_tick: Cell<Option<f64>>,
46}
47ferro_class!(MaterialSelectionMark:Control);
48ferro_class_info!(MaterialSelectionMark {
49    new: MaterialSelectionMark::new
50});
51ferro_impl_classes!(MaterialSelectionMark:StyledElementImpl,InteractiveImpl,InputElementImpl,ControlImpl);
52impl FerroObjectImpl for MaterialSelectionMark {
53    fn on_property_changed(this: &Self, change: &FerroPropertyChangedEventArgs<'_>) {
54        Self::parent_on_property_changed(this, change);
55        let property = change.property();
56        if property == Self::is_checked_property().as_property()
57            || property == Self::reduced_motion_property().as_property()
58            || property == Self::motion_scheme_property().as_property()
59        {
60            this.retarget();
61        }
62        this.invalidate_visual();
63    }
64}
65impl LayoutableImpl for MaterialSelectionMark {
66    fn measure_override(_this: &Self, available: Size) -> Size {
67        Size::new(
68            18.0_f64.min(available.width),
69            18.0_f64.min(available.height),
70        )
71    }
72}
73impl VisualImpl for MaterialSelectionMark {
74    fn bypass_flow_direction_policies(_this: &Self) -> bool {
75        true
76    }
77    fn on_attached_to_visual_tree(this: &Self, event: &VisualTreeAttachmentEventArgs) {
78        Self::parent_on_attached_to_visual_tree(this, event);
79        let weak = this.to_ref().downgrade();
80        *this.visibility_subscription.borrow_mut() =
81            Some(this.is_effectively_visible_changed(move || {
82                if let Some(this) = weak.upgrade() {
83                    this.update_clock();
84                }
85            }));
86        this.retarget();
87    }
88    fn on_detached_from_visual_tree(this: &Self, event: &VisualTreeAttachmentEventArgs) {
89        this.stop_clock();
90        if let Some(subscription) = this.visibility_subscription.borrow_mut().take() {
91            subscription.dispose();
92        }
93        Self::parent_on_detached_from_visual_tree(this, event);
94    }
95    fn render(this: &Self, dc: &mut DrawingContext) {
96        let size = this.bounds().size();
97        let width = size.width.min(size.height);
98        let brush = this.fill();
99        if !width.is_finite() || width <= 0.0 || brush.is_none() {
100            return;
101        }
102        let motion = this.motion.borrow();
103        let (fraction, gravity) = if animations_enabled(this.reduced_motion()) {
104            (motion.draw.current().value, motion.gravity.current().value)
105        } else {
106            mark_targets(this.is_checked())
107        };
108        let points = mark_path(fraction, gravity, width);
109        if points.len() < 2 {
110            return;
111        }
112        let offset = Point::new((size.width - width) / 2.0, (size.height - width) / 2.0);
113        let point = |p: Point| Point::new(p.x + offset.x, p.y + offset.y);
114        let geometry = StreamGeometry::new();
115        let mut path = geometry.open();
116        path.begin_figure(point(points[0]), false);
117        for p in points.iter().skip(1) {
118            path.line_to(point(*p), true);
119        }
120        path.end_figure(false);
121        path.dispose();
122        let pen: Rc<dyn IPen> = Rc::new(ImmutablePen::new(
123            brush.as_ref().map(BrushExtensions::to_immutable),
124            2.0,
125            None,
126            PenLineCap::Square,
127            PenLineJoin::Miter,
128            4.0,
129        ));
130        dc.draw_geometry(None, Some(&pen), &geometry.upcast());
131    }
132}
133ferro_properties! {impl MaterialSelectionMark {
134    pub fn is_checked_property()->StyledProperty<Option<bool>>{FerroProperty::register::<MaterialSelectionMark,_>("IsChecked",Some(false))}
135    pub fn fill_property()->StyledProperty<Option<Rc<dyn IBrush>>>{FerroProperty::register::<MaterialSelectionMark,_>("Fill",None)}
136    pub fn reduced_motion_property()->StyledProperty<bool>{FerroProperty::register::<MaterialSelectionMark,_>("ReducedMotion",false)}
137    pub fn motion_scheme_property()->StyledProperty<MaterialMotionScheme>{FerroProperty::register::<MaterialSelectionMark,_>("MotionScheme",MaterialMotionScheme::Expressive)}
138}}
139impl MaterialSelectionMark {
140    fn static_constructor() {
141        Visual::affects_render::<MaterialSelectionMark>(&[Self::fill_property().as_property()]);
142    }
143    pub fn construct() -> Self {
144        Self {
145            base: Control::construct(),
146            motion: RefCell::new(MarkMotion::new()),
147            clock_subscription: RefCell::new(None),
148            visibility_subscription: RefCell::new(None),
149            last_tick: Cell::new(None),
150        }
151    }
152    pub fn new() -> Ref<Self> {
153        let this = instantiate(Self::construct());
154        this.set_is_hit_test_visible(false);
155        this
156    }
157    pub fn is_checked(&self) -> Option<bool> {
158        self.get_value(Self::is_checked_property())
159    }
160    pub fn set_is_checked(&self, value: Option<bool>) {
161        self.set_value(Self::is_checked_property(), value);
162    }
163    pub fn fill(&self) -> Option<Rc<dyn IBrush>> {
164        self.get_value(Self::fill_property())
165    }
166    pub fn set_fill(&self, value: Option<Rc<dyn IBrush>>) {
167        self.set_value(Self::fill_property(), value);
168    }
169    pub fn reduced_motion(&self) -> bool {
170        self.get_value(Self::reduced_motion_property())
171    }
172    pub fn set_reduced_motion(&self, value: bool) {
173        self.set_value(Self::reduced_motion_property(), value);
174    }
175    pub fn motion_scheme(&self) -> MaterialMotionScheme {
176        self.get_value(Self::motion_scheme_property())
177    }
178    pub fn set_motion_scheme(&self, value: MaterialMotionScheme) {
179        self.set_value(Self::motion_scheme_property(), value);
180    }
181    fn retarget(&self) {
182        let reduced =
183            !animations_enabled(self.reduced_motion()) || !self.is_attached_to_visual_tree();
184        self.motion
185            .borrow_mut()
186            .retarget(self.is_checked(), self.motion_scheme(), reduced);
187        self.update_clock();
188    }
189    fn update_clock(&self) {
190        if !self.is_attached_to_visual_tree()
191            || !self.is_effectively_visible()
192            || !animations_enabled(self.reduced_motion())
193            || !self.motion.borrow().running()
194        {
195            self.stop_clock();
196            return;
197        }
198        if self.clock_subscription.borrow().is_some() {
199            return;
200        }
201        let Some(clock) = FerroLocator::current().get_service::<dyn IGlobalClock>() else {
202            return;
203        };
204        self.last_tick.set(None);
205        let weak = self.to_ref().downgrade();
206        *self.clock_subscription.borrow_mut() = Some(clock.subscribe(Rc::new(
207            AnonymousObserver::new(move |time: TimeSpan| {
208                if let Some(this) = weak.upgrade() {
209                    if !animations_enabled(this.reduced_motion()) {
210                        this.retarget();
211                        this.invalidate_visual();
212                        return;
213                    }
214                    let time = time.total_seconds();
215                    if !time.is_finite() {
216                        return;
217                    }
218                    let delta = this
219                        .last_tick
220                        .replace(Some(time))
221                        .map_or(0.0, |previous| (time - previous).max(0.0));
222                    this.motion.borrow_mut().advance(delta);
223                    this.invalidate_visual();
224                    this.update_clock();
225                }
226            }),
227        )));
228    }
229    fn stop_clock(&self) {
230        if let Some(subscription) = self.clock_subscription.borrow_mut().take() {
231            subscription.dispose();
232        }
233        self.last_tick.set(None);
234    }
235}
236impl Drop for MaterialSelectionMark {
237    fn drop(&mut self) {
238        if let Some(subscription) = self.clock_subscription.get_mut().take() {
239            subscription.dispose();
240        }
241        if let Some(subscription) = self.visibility_subscription.get_mut().take() {
242            subscription.dispose();
243        }
244    }
245}
246fn mark_targets(state: Option<bool>) -> (f64, f64) {
247    match state {
248        Some(false) => (0.0, 0.0),
249        Some(true) => (1.0, 0.0),
250        None => (1.0, 1.0),
251    }
252}
253struct MarkMotion {
254    state: Option<bool>,
255    draw: MaterialSpringState,
256    gravity: MaterialSpringState,
257    off_delay: Option<f64>,
258}
259impl MarkMotion {
260    fn new() -> Self {
261        let spec = MaterialMotionScheme::Expressive.spring(MaterialMotionRole::DefaultSpatial);
262        Self {
263            state: Some(false),
264            draw: MaterialSpringState::new(spec, 0.0),
265            gravity: MaterialSpringState::new(spec, 0.0),
266            off_delay: None,
267        }
268    }
269    fn retarget(&mut self, state: Option<bool>, scheme: MaterialMotionScheme, reduced: bool) {
270        let (draw, gravity) = mark_targets(state);
271        let previous = self.state;
272        let spec = scheme.spring(MaterialMotionRole::DefaultSpatial);
273        self.draw.set_spec(spec);
274        self.gravity.set_spec(spec);
275        self.draw.set_reduced_motion(reduced);
276        self.gravity.set_reduced_motion(reduced);
277        if reduced {
278            self.draw.retarget(draw);
279            self.gravity.retarget(gravity);
280            self.draw.finish();
281            self.gravity.finish();
282            self.off_delay = None;
283        } else if state == Some(false) && previous != Some(false) {
284            self.off_delay = Some(0.1);
285        } else if state != Some(false) {
286            self.off_delay = None;
287            self.draw.retarget(draw);
288            self.gravity.retarget(gravity);
289            if previous == Some(false) {
290                self.gravity.finish();
291            }
292        }
293        self.state = state;
294    }
295    fn advance(&mut self, delta: f64) {
296        if let Some(remaining) = self.off_delay {
297            if remaining - delta <= 1e-9 {
298                self.off_delay = None;
299                self.draw.retarget(0.0);
300                self.gravity.retarget(0.0);
301                self.draw.finish();
302                self.gravity.finish();
303            } else {
304                self.off_delay = Some(remaining - delta);
305            }
306        } else {
307            self.draw.advance(delta);
308            self.gravity.advance(delta);
309            if self.draw.is_at_rest(0.01, 0.625) {
310                self.draw.finish();
311            }
312            if self.gravity.is_at_rest(0.01, 0.625) {
313                self.gravity.finish();
314            }
315        }
316    }
317    fn running(&self) -> bool {
318        self.off_delay.is_some()
319            || !self.draw.is_at_rest(0.01, 0.625)
320            || !self.gravity.is_at_rest(0.01, 0.625)
321    }
322}
323fn mark_path(fraction: f64, gravity: f64, width: f64) -> Vec<Point> {
324    let points = [
325        Point::new(width * 0.25, width * 0.5),
326        Point::new(
327            width * (0.4 + 0.1 * gravity),
328            width * (0.65 - 0.15 * gravity),
329        ),
330        Point::new(width * 0.75, width * (0.3 + 0.2 * gravity)),
331    ];
332    let length = |a: Point, b: Point| (b.x - a.x).hypot(b.y - a.y);
333    let first = length(points[0], points[1]);
334    let second = length(points[1], points[2]);
335    let mut remaining = (first + second) * fraction.clamp(0.0, 1.0);
336    if remaining <= 0.0 {
337        return Vec::new();
338    }
339    let mut output = vec![points[0]];
340    for i in 0..2 {
341        let span = if i == 0 { first } else { second };
342        let t = (remaining / span).clamp(0.0, 1.0);
343        output.push(Point::new(
344            points[i].x + (points[i + 1].x - points[i].x) * t,
345            points[i].y + (points[i + 1].y - points[i].y) * t,
346        ));
347        remaining -= span;
348        if remaining <= 0.0 {
349            break;
350        }
351    }
352    output
353}
354#[cfg(test)]
355mod tests {
356    use super::*;
357    #[test]
358    fn native_check_reveals_by_length_and_gravitates_to_dash() {
359        let check = mark_path(1.0, 0.0, 18.0);
360        assert_eq!(3, check.len());
361        assert_eq!(Point::new(4.5, 9.0), check[0]);
362        assert!((check[1].y - 11.7).abs() < 1e-12);
363        assert_eq!(13.5, check[2].x);
364        assert!((check[2].y - 5.4).abs() < 1e-12);
365        let dash = mark_path(1.0, 1.0, 18.0);
366        assert!(dash.iter().all(|p| (p.y - 9.0).abs() < 1e-12));
367        let half = mark_path(0.5, 1.0, 18.0);
368        assert_eq!(Point::new(9.0, 9.0), *half.last().unwrap());
369    }
370    #[test]
371    fn uncheck_holds_mark_for_native_100ms_then_snaps() {
372        let mut motion = MarkMotion::new();
373        motion.retarget(Some(true), MaterialMotionScheme::Expressive, true);
374        motion.retarget(Some(false), MaterialMotionScheme::Expressive, false);
375        motion.advance(0.099);
376        assert_eq!(1.0, motion.draw.current().value);
377        motion.advance(0.001);
378        assert_eq!(0.0, motion.draw.current().value);
379        assert!(!motion.running());
380    }
381    #[test]
382    fn indeterminate_check_morph_and_reduced_motion_do_not_leave_clock_work() {
383        let mut motion = MarkMotion::new();
384        motion.retarget(Some(true), MaterialMotionScheme::Expressive, true);
385        motion.retarget(None, MaterialMotionScheme::Expressive, false);
386        motion.advance(0.04);
387        assert!(motion.gravity.current().value > 0.0 && motion.gravity.current().value < 1.0);
388        motion.retarget(None, MaterialMotionScheme::Expressive, true);
389        assert_eq!(1.0, motion.gravity.current().value);
390        assert!(!motion.running());
391    }
392}