1use crate::motion::{animations_enabled, MaterialSpringSpec};
8use crate::progress_indicator::rounded_geometry::{
9 loading_polygons, path_bounds, Morph, Polygon, V,
10};
11use ferroui_base::animation::{IGlobalClock, TimeSpan};
12use ferroui_base::input::InputElementImpl;
13use ferroui_base::interactivity::InteractiveImpl;
14use ferroui_base::layout::LayoutableImpl;
15use ferroui_base::media::{BoxShadows, DrawingContext, IBrush, StreamGeometry};
16use ferroui_base::platform::IGeometryContext;
17use ferroui_base::reactive::{AnonymousObserver, IDisposable};
18use ferroui_base::{
19 ferro_class, ferro_class_info, ferro_impl_classes, ferro_properties, instantiate,
20 DirectProperty, FerroLocator, FerroObjectImpl, FerroObjectImplExt, FerroProperty,
21 FerroPropertyChangedEventArgs, LocatorExtensions, Point, Rect, Ref, Size, StyledElementImpl,
22 StyledProperty, Visual, VisualImpl, VisualImplExt, VisualTreeAttachmentEventArgs,
23};
24use ferroui_controls::automation::peers::{AutomationPeer, ProgressBarAutomationPeer};
25use ferroui_controls::primitives::{RangeBase, TemplatedControlImpl};
26use ferroui_controls::ControlImpl;
27use std::cell::{Cell, RefCell};
28use std::f64::consts::PI;
29use std::rc::Rc;
30use std::sync::OnceLock;
31
32#[repr(C)]
36pub struct MaterialLoadingIndicator {
37 base: RangeBase,
38 percentage: Cell<f64>,
39 clock_subscription: RefCell<Option<Rc<dyn IDisposable>>>,
40 visibility_subscription: RefCell<Option<Rc<dyn IDisposable>>>,
41 elapsed: Cell<f64>,
42 epoch: Cell<Option<f64>>,
43}
44ferro_class!(MaterialLoadingIndicator: RangeBase);
45ferro_class_info!(MaterialLoadingIndicator {
46 new: MaterialLoadingIndicator::new
47});
48ferro_impl_classes!(MaterialLoadingIndicator: StyledElementImpl, InteractiveImpl, InputElementImpl, TemplatedControlImpl);
49impl ControlImpl for MaterialLoadingIndicator {
50 fn on_create_automation_peer(this: &Self) -> Ref<AutomationPeer> {
51 ProgressBarAutomationPeer::new(this).upcast()
52 }
53}
54impl FerroObjectImpl for MaterialLoadingIndicator {
55 fn on_property_changed(this: &Self, change: &FerroPropertyChangedEventArgs<'_>) {
56 Self::parent_on_property_changed(this, change);
57 this.invalidate_visual();
58 let property = change.property();
59 if property == RangeBase::value_property().as_property()
60 || property == RangeBase::minimum_property().as_property()
61 || property == RangeBase::maximum_property().as_property()
62 {
63 let range = this.maximum() - this.minimum();
64 let percentage = if range > 0.0 {
65 ((this.value() - this.minimum()) / range * 100.0).clamp(0.0, 100.0)
66 } else {
67 0.0
68 };
69 this.set_and_raise_cell(Self::percentage_property(), &this.percentage, percentage);
70 }
71 if property == Self::is_indeterminate_property().as_property()
72 || property == Self::reduced_motion_property().as_property()
73 {
74 this.update_clock();
75 }
76 }
77}
78impl LayoutableImpl for MaterialLoadingIndicator {
79 fn measure_override(_this: &Self, available: Size) -> Size {
80 Size::new(
81 48.0_f64.min(available.width),
82 48.0_f64.min(available.height),
83 )
84 }
85}
86impl VisualImpl for MaterialLoadingIndicator {
87 fn bypass_flow_direction_policies(_this: &Self) -> bool {
88 true
89 }
90 fn on_attached_to_visual_tree(this: &Self, e: &VisualTreeAttachmentEventArgs) {
91 Self::parent_on_attached_to_visual_tree(this, e);
92 let weak = this.to_ref().downgrade();
93 *this.visibility_subscription.borrow_mut() =
94 Some(this.is_effectively_visible_changed(move || {
95 if let Some(this) = weak.upgrade() {
96 this.update_clock();
97 }
98 }));
99 this.update_clock();
100 }
101 fn on_detached_from_visual_tree(this: &Self, e: &VisualTreeAttachmentEventArgs) {
102 this.stop_clock();
103 if let Some(subscription) = this.visibility_subscription.borrow_mut().take() {
104 subscription.dispose();
105 }
106 Self::parent_on_detached_from_visual_tree(this, e);
107 }
108 fn render(this: &Self, dc: &mut DrawingContext) {
109 let size = this.bounds().size();
110 let side = size.width.min(size.height);
111 if !side.is_finite() || side <= 0.0 {
112 return;
113 }
114 let container = this.container_brush();
115 dc.draw_rectangle(
116 container.as_ref(),
117 None,
118 Rect::new(0.0, 0.0, size.width, size.height),
119 side / 2.0,
120 side / 2.0,
121 &BoxShadows::default(),
122 );
123 let active = this.active_brush();
124 if active.is_none() {
125 return;
126 }
127 let shapes = loading_shapes();
128 let (curves, scale, rotation) = if this.is_indeterminate() {
129 let time = if !animations_enabled(this.reduced_motion()) {
130 0.0
131 } else {
132 this.elapsed.get()
133 };
134 let frame = loading_indeterminate_frame(time);
135 (
136 shapes.sequence[frame.0].cubics(frame.1),
137 shapes.indeterminate_scale,
138 frame.2,
139 )
140 } else {
141 let progress = (this.percentage() / 100.0).clamp(0.0, 1.0);
142 (
143 shapes.determinate.cubics(progress),
144 shapes.determinate_scale,
145 -progress * PI,
146 )
147 };
148 let scale = side * scale * 38.0 / 48.0;
149 let scaled: Vec<_> = curves
150 .into_iter()
151 .map(|c| c.transformed(|p| p * scale))
152 .collect();
153 let bounds = path_bounds(&scaled);
154 let path_center = V::new((bounds[0] + bounds[2]) / 2.0, (bounds[1] + bounds[3]) / 2.0);
155 let point = |p: V| {
156 let p =
157 (p - path_center).rotate(rotation) + V::new(size.width / 2.0, size.height / 2.0);
158 Point::new(p.x, p.y)
159 };
160 let geometry = StreamGeometry::new();
161 let mut path = geometry.open();
162 path.begin_figure(point(scaled[0].0[0]), true);
163 for cubic in &scaled {
164 path.cubic_bezier_to(
165 point(cubic.0[1]),
166 point(cubic.0[2]),
167 point(cubic.0[3]),
168 true,
169 );
170 }
171 path.end_figure(true);
172 path.dispose();
173 dc.draw_geometry(active.as_ref(), None, &geometry.upcast());
174 }
175}
176ferro_properties! {impl MaterialLoadingIndicator {
177 pub fn percentage_property()->DirectProperty<MaterialLoadingIndicator,f64>{
178 FerroProperty::register_direct::<MaterialLoadingIndicator,_>("Percentage",|o|o.percentage(),Some(|o,value|o.set_percentage(value)),0.0)
179 }
180 pub fn is_indeterminate_property()->StyledProperty<bool>{FerroProperty::register::<MaterialLoadingIndicator,_>("IsIndeterminate",true)}
181 pub fn reduced_motion_property()->StyledProperty<bool>{FerroProperty::register::<MaterialLoadingIndicator,_>("ReducedMotion",false)}
182 pub fn active_brush_property()->StyledProperty<Option<Rc<dyn IBrush>>>{FerroProperty::register::<MaterialLoadingIndicator,_>("ActiveBrush",None)}
183 pub fn container_brush_property()->StyledProperty<Option<Rc<dyn IBrush>>>{FerroProperty::register::<MaterialLoadingIndicator,_>("ContainerBrush",None)}
184}}
185impl MaterialLoadingIndicator {
186 fn static_constructor() {
187 Visual::affects_render::<MaterialLoadingIndicator>(&[
188 Self::active_brush_property().as_property(),
189 Self::container_brush_property().as_property(),
190 ]);
191 }
192 pub fn construct() -> Self {
193 Self {
194 base: RangeBase::construct(),
195 percentage: Cell::new(0.0),
196 clock_subscription: RefCell::new(None),
197 visibility_subscription: RefCell::new(None),
198 elapsed: Cell::new(0.0),
199 epoch: Cell::new(None),
200 }
201 }
202 pub fn new() -> Ref<Self> {
203 let this = instantiate(Self::construct());
204 this.set_is_hit_test_visible(false);
205 this
206 }
207 pub fn percentage(&self) -> f64 {
208 self.percentage.get()
209 }
210 pub fn set_percentage(&self, value: f64) {
211 let fraction = if value.is_nan() {
212 0.0
213 } else {
214 (value / 100.0).clamp(0.0, 1.0)
215 };
216 self.set_range_value(self.minimum() + (self.maximum() - self.minimum()) * fraction);
217 }
218 pub fn is_indeterminate(&self) -> bool {
219 self.get_value(Self::is_indeterminate_property())
220 }
221 pub fn set_is_indeterminate(&self, value: bool) {
222 self.set_value(Self::is_indeterminate_property(), value);
223 }
224 pub fn reduced_motion(&self) -> bool {
225 self.get_value(Self::reduced_motion_property())
226 }
227 pub fn set_reduced_motion(&self, value: bool) {
228 self.set_value(Self::reduced_motion_property(), value);
229 }
230 pub fn active_brush(&self) -> Option<Rc<dyn IBrush>> {
231 self.get_value(Self::active_brush_property())
232 }
233 pub fn set_active_brush(&self, value: Option<Rc<dyn IBrush>>) {
234 self.set_value(Self::active_brush_property(), value);
235 }
236 pub fn container_brush(&self) -> Option<Rc<dyn IBrush>> {
237 self.get_value(Self::container_brush_property())
238 }
239 pub fn set_container_brush(&self, value: Option<Rc<dyn IBrush>>) {
240 self.set_value(Self::container_brush_property(), value);
241 }
242 fn update_clock(&self) {
243 if !self.is_attached_to_visual_tree()
244 || !self.is_effectively_visible()
245 || !self.is_indeterminate()
246 || !animations_enabled(self.reduced_motion())
247 {
248 self.stop_clock();
249 return;
250 }
251 if self.clock_subscription.borrow().is_some() {
252 return;
253 }
254 let Some(clock) = FerroLocator::current().get_service::<dyn IGlobalClock>() else {
255 return;
256 };
257 let weak = self.to_ref().downgrade();
258 self.epoch.set(None);
259 *self.clock_subscription.borrow_mut() = Some(clock.subscribe(Rc::new(
260 AnonymousObserver::new(move |time: TimeSpan| {
261 if let Some(this) = weak.upgrade() {
262 if !animations_enabled(this.reduced_motion()) {
263 this.stop_clock();
264 this.invalidate_visual();
265 return;
266 }
267 let time = time.total_seconds();
268 if !time.is_finite() {
269 return;
270 }
271 let epoch = this.epoch.get().unwrap_or_else(|| {
272 let epoch = time - this.elapsed.get();
273 this.epoch.set(Some(epoch));
274 epoch
275 });
276 this.elapsed.set((time - epoch).max(0.0));
277 this.invalidate_visual();
278 }
279 }),
280 )));
281 }
282 fn stop_clock(&self) {
283 if let Some(subscription) = self.clock_subscription.borrow_mut().take() {
284 subscription.dispose();
285 }
286 self.epoch.set(None);
287 }
288}
289impl Drop for MaterialLoadingIndicator {
290 fn drop(&mut self) {
291 if let Some(subscription) = self.clock_subscription.get_mut().take() {
292 subscription.dispose();
293 }
294 if let Some(subscription) = self.visibility_subscription.get_mut().take() {
295 subscription.dispose();
296 }
297 }
298}
299struct LoadingShapes {
300 sequence: Vec<Morph>,
301 determinate: Morph,
302 indeterminate_scale: f64,
303 determinate_scale: f64,
304}
305fn loading_shapes() -> &'static LoadingShapes {
306 static SHAPES: OnceLock<LoadingShapes> = OnceLock::new();
307 SHAPES.get_or_init(|| {
308 let polygons = loading_polygons();
309 let indeterminate_scale = polygons
310 .iter()
311 .fold(1.0_f64, |scale, p| scale.min(p.rotation_safe_scale()));
312 let circle = Polygon::circle(10)
313 .normalized()
314 .transformed(|p| p.rotate(PI / 10.0));
315 let determinate_scale = 1.0_f64
316 .min(circle.rotation_safe_scale())
317 .min(polygons[0].rotation_safe_scale());
318 let sequence = (0..polygons.len())
319 .map(|i| {
320 Morph::new(
321 &polygons[i].normalized(),
322 &polygons[(i + 1) % polygons.len()].normalized(),
323 )
324 })
325 .collect();
326 LoadingShapes {
327 sequence,
328 determinate: Morph::new(&circle.normalized(), &polygons[0].normalized()),
329 indeterminate_scale,
330 determinate_scale,
331 }
332 })
333}
334pub fn loading_indeterminate_frame(seconds: f64) -> (usize, f64, f64) {
339 let time = if seconds.is_finite() {
340 seconds.max(0.0)
341 } else {
342 0.0
343 };
344 let iteration = (time / 0.65).floor();
345 let local = time.rem_euclid(0.65);
346 let progress = if local >= 0.297 {
347 1.0
348 } else {
349 MaterialSpringSpec::new(0.6, 200.0)
350 .sample(local, 0.0, 1.0, 0.0)
351 .value
352 };
353 let rotation =
354 (90.0 * (iteration + 1.0) + progress * 90.0 + time.rem_euclid(4.666) / 4.666 * 360.0)
355 .to_radians();
356 ((iteration as usize) % 7, progress, rotation)
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 #[test]
363 fn loading_sequence_uses_native_shapes_and_timing() {
364 assert_eq!(7, loading_shapes().sequence.len());
365 assert_eq!((0, 0.0, PI / 2.0), loading_indeterminate_frame(0.0));
366 assert_eq!(1, loading_indeterminate_frame(0.65).0);
367 assert_eq!(6, loading_indeterminate_frame(4.0).0);
368 assert!(
369 loading_indeterminate_frame(0.28).1 > 1.0,
370 "native spring must preserve expressive overshoot"
371 );
372 assert_eq!(1.0, loading_indeterminate_frame(0.6).1);
373 }
374}