Krolyxon
Commit eed1fb9

Branches

Refs →

budctlcratesbudctl-guisrcmotion.rs

446 lines · 14.0 KB · Rust

1//! Motion, in Nothing's way: short, eased out, never bouncing, and fading rather than sliding.
2//!
3//! Small moves run inside their widget: a hover, a switch, a ring's dots. The widget starts its
4//! tween when a redraw shows that what it should show has changed, asks for the frames the tween
5//! needs, and then lets the window rest, so only its own patch is painted again. Fades of whole
6//! parts (a page, a dialog) run in the app, which draws the view again on each frame of one.
7//!
8//! With motion off (`gui.motion` in the config, and in the tests), everything is at once.
9
10use iced::advanced::layout::{self, Layout};
11use iced::advanced::widget::{Operation, Tree, Widget, tree};
12use iced::advanced::{Clipboard, Shell, mouse, overlay, renderer};
13use iced::time::{Duration, Instant};
14use iced::{Border, Color, Element, Event, Length, Rectangle, Renderer, Size, Theme, Vector, window};
15
16use crate::Message;
17
18/// A hover, in and out.
19pub const HOVER: Duration = Duration::from_millis(150);
20/// A change of state: a switch, the chosen segment, the keyboard cursor.
21pub const STATE: Duration = Duration::from_millis(200);
22/// A page fading in.
23pub const PAGE: Duration = Duration::from_millis(240);
24/// A dialog fading in.
25pub const OPEN: Duration = Duration::from_millis(200);
26/// A dialog fading out.
27pub const CLOSE: Duration = Duration::from_millis(150);
28/// A battery ring lighting its dots, one after the other.
29pub const SWEEP: Duration = Duration::from_millis(600);
30/// How long a blinking dot stays lit, and dark.
31pub const BLINK: Duration = Duration::from_millis(500);
32/// One step of the scanning dots.
33pub const SCAN: Duration = Duration::from_millis(90);
34
35/// The ease-out curve `cubic-bezier(0.25, 0.1, 0.25, 1)`: quick at first, settling gently. `t`
36/// is the share of the time gone, from 0 to 1; the result, the share of the way gone.
37pub fn ease(t: f32) -> f32 {
38 // A cubic Bézier from (0, 0) to (1, 1) with control points (0.25, 0.1) and (0.25, 1),
39 // along one axis.
40 fn bezier(s: f32, a: f32, b: f32) -> f32 {
41 let r = 1.0 - s;
42 3.0 * r * r * s * a + 3.0 * r * s * s * b + s * s * s
43 }
44 if t <= 0.0 {
45 return 0.0;
46 }
47 if t >= 1.0 {
48 return 1.0;
49 }
50 // x grows with s, so halving finds the s where x is t.
51 let (mut low, mut high) = (0.0_f32, 1.0_f32);
52 for _ in 0..24 {
53 let mid = (low + high) / 2.0;
54 if bezier(mid, 0.25, 0.25) < t {
55 low = mid;
56 } else {
57 high = mid;
58 }
59 }
60 bezier((low + high) / 2.0, 0.1, 1.0)
61}
62
63/// How far a fade that began at `since` and lasts `length` is at `now`, eased: 0 to 1.
64pub fn progress(since: Instant, length: Duration, now: Instant) -> f32 {
65 let gone = now.saturating_duration_since(since).as_secs_f32();
66 ease(gone / length.as_secs_f32().max(f32::EPSILON))
67}
68
69/// Whether a fade that began at `since` and lasts `length` is over at `now`.
70pub fn over(since: Instant, length: Duration, now: Instant) -> bool {
71 now.saturating_duration_since(since) >= length
72}
73
74/// What a tween can move: numbers, colors, paints.
75pub trait Mix: Copy + PartialEq {
76 /// `self` moved `t` (0 to 1) of the way to `to`.
77 fn mix(self, to: Self, t: f32) -> Self;
78}
79
80impl Mix for f32 {
81 fn mix(self, to: f32, t: f32) -> f32 {
82 self + (to - self) * t
83 }
84}
85
86impl Mix for Color {
87 /// Transparent takes the other color's hue, so a fill fades in as itself, not from black.
88 fn mix(self, to: Color, t: f32) -> Color {
89 let from = if self.a == 0.0 { Color { a: 0.0, ..to } } else { self };
90 let to = if to.a == 0.0 { Color { a: 0.0, ..from } } else { to };
91 Color {
92 r: from.r.mix(to.r, t),
93 g: from.g.mix(to.g, t),
94 b: from.b.mix(to.b, t),
95 a: from.a.mix(to.a, t),
96 }
97 }
98}
99
100/// A value on its way to another, eased.
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct Tween<T> {
103 from: T,
104 to: T,
105 /// When it set off; `None` once it is there, or when it went at once.
106 start: Option<Instant>,
107 length: Duration,
108}
109
110impl<T: Mix> Tween<T> {
111 /// A value that stays where it is.
112 pub fn at_rest(value: T) -> Self {
113 Tween {
114 from: value,
115 to: value,
116 start: None,
117 length: Duration::ZERO,
118 }
119 }
120
121 /// Sets off for `to` from wherever it is now, taking `length`; at once without `motion`.
122 /// Aiming where it already goes changes nothing.
123 pub fn aim(&mut self, to: T, now: Instant, length: Duration, motion: bool) {
124 if to == self.to {
125 return;
126 }
127 self.from = self.at(now);
128 self.to = to;
129 self.start = motion.then_some(now);
130 self.length = length;
131 }
132
133 /// Where it is at `now`.
134 pub fn at(&self, now: Instant) -> T {
135 match self.start {
136 Some(start) if !over(start, self.length, now) => self.from.mix(self.to, progress(start, self.length, now)),
137 _ => self.to,
138 }
139 }
140
141 /// Where it is going.
142 pub fn target(&self) -> T {
143 self.to
144 }
145
146 /// Whether it is still on its way at `now`.
147 pub fn running(&self, now: Instant) -> bool {
148 self.start.is_some_and(|start| !over(start, self.length, now))
149 }
150}
151
152/// When a redraw is under way, its time.
153pub fn redraw(event: &Event) -> Option<Instant> {
154 match event {
155 Event::Window(window::Event::RedrawRequested(now)) => Some(*now),
156 _ => None,
157 }
158}
159
160/// A surface's paint: its fill, and its 2 px frame (the keyboard cursor). Transparent is none.
161#[derive(Debug, Clone, Copy, PartialEq)]
162pub struct Paint {
163 pub fill: Color,
164 pub frame: Color,
165}
166
167impl Paint {
168 pub const NONE: Paint = Paint {
169 fill: Color::TRANSPARENT,
170 frame: Color::TRANSPARENT,
171 };
172
173 /// Just a fill.
174 pub fn fill(color: Color) -> Paint {
175 Paint {
176 fill: color,
177 frame: Color::TRANSPARENT,
178 }
179 }
180
181 /// `self`, framed in `color` when `shown`.
182 pub fn framed(self, color: Color, shown: bool) -> Paint {
183 Paint {
184 frame: if shown { color } else { Color::TRANSPARENT },
185 ..self
186 }
187 }
188}
189
190impl Mix for Paint {
191 fn mix(self, to: Paint, t: f32) -> Paint {
192 Paint {
193 fill: self.fill.mix(to.fill, t),
194 frame: self.frame.mix(to.frame, t),
195 }
196 }
197}
198
199/// `content` on a surface painted `rest`, or `hover` while the mouse is over it (when
200/// `interactive`). A change of paint fades: the hover in [`HOVER`], anything else (the chosen
201/// segment, the keyboard cursor) in [`STATE`].
202///
203/// The content keeps its own clicks and keys; give a button in it no background or border.
204pub fn eased<'a>(
205 content: impl Into<Element<'a, Message>>,
206 rest: Paint,
207 hover: Paint,
208 radius: f32,
209 interactive: bool,
210 motion: bool,
211) -> Element<'a, Message> {
212 Element::new(Eased {
213 content: content.into(),
214 rest,
215 hover,
216 radius,
217 interactive,
218 motion,
219 })
220}
221
222/// `content` on a surface of one paint, which fades when it changes.
223pub fn surface<'a>(
224 content: impl Into<Element<'a, Message>>,
225 paint: Paint,
226 radius: f32,
227 motion: bool,
228) -> Element<'a, Message> {
229 eased(content, paint, paint, radius, false, motion)
230}
231
232struct Eased<'a> {
233 content: Element<'a, Message>,
234 rest: Paint,
235 hover: Paint,
236 radius: f32,
237 interactive: bool,
238 motion: bool,
239}
240
241/// What an [`Eased`] surface shows, kept between views.
242struct Shown {
243 hovered: bool,
244 paint: Tween<Paint>,
245 /// The time of the last redraw: the paint is drawn as it is then.
246 now: Option<Instant>,
247}
248
249impl Widget<Message, Theme, Renderer> for Eased<'_> {
250 fn tag(&self) -> tree::Tag {
251 tree::Tag::of::<Shown>()
252 }
253
254 fn state(&self) -> tree::State {
255 tree::State::new(Shown {
256 hovered: false,
257 paint: Tween::at_rest(self.rest),
258 now: None,
259 })
260 }
261
262 fn children(&self) -> Vec<Tree> {
263 vec![Tree::new(&self.content)]
264 }
265
266 fn diff(&self, tree: &mut Tree) {
267 tree.diff_children(std::slice::from_ref(&self.content));
268 }
269
270 fn size(&self) -> Size<Length> {
271 self.content.as_widget().size()
272 }
273
274 fn size_hint(&self) -> Size<Length> {
275 self.content.as_widget().size_hint()
276 }
277
278 fn layout(&mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits) -> layout::Node {
279 match tree.children.first_mut() {
280 Some(child) => self.content.as_widget_mut().layout(child, renderer, limits),
281 None => layout::Node::new(Size::ZERO),
282 }
283 }
284
285 fn update(
286 &mut self,
287 tree: &mut Tree,
288 event: &Event,
289 layout: Layout<'_>,
290 cursor: mouse::Cursor,
291 renderer: &Renderer,
292 clipboard: &mut dyn Clipboard,
293 shell: &mut Shell<'_, Message>,
294 viewport: &Rectangle,
295 ) {
296 if let Some(child) = tree.children.first_mut() {
297 self.content
298 .as_widget_mut()
299 .update(child, event, layout, cursor, renderer, clipboard, shell, viewport);
300 }
301 let shown = tree.state.downcast_mut::<Shown>();
302 // A fade starts now, not at the last redraw: that may be long gone, and the fade with it.
303 let now = match redraw(event) {
304 Some(now) => {
305 shown.now = Some(now);
306 now
307 }
308 None => Instant::now(),
309 };
310 let hovered = self.interactive && cursor.is_over(layout.bounds());
311 let length = if hovered == shown.hovered { STATE } else { HOVER };
312 shown.hovered = hovered;
313 let target = if hovered { self.hover } else { self.rest };
314 if target != shown.paint.target() || shown.paint.running(now) {
315 shown.paint.aim(target, now, length, self.motion);
316 shell.request_redraw();
317 }
318 }
319
320 fn draw(
321 &self,
322 tree: &Tree,
323 renderer: &mut Renderer,
324 theme: &Theme,
325 style: &renderer::Style,
326 layout: Layout<'_>,
327 cursor: mouse::Cursor,
328 viewport: &Rectangle,
329 ) {
330 use iced::advanced::Renderer as _;
331
332 let shown = tree.state.downcast_ref::<Shown>();
333 let paint = shown.now.map_or(shown.paint.target(), |now| shown.paint.at(now));
334 if paint.fill.a > 0.0 || paint.frame.a > 0.0 {
335 renderer.fill_quad(
336 renderer::Quad {
337 bounds: layout.bounds(),
338 border: Border {
339 color: paint.frame,
340 width: if paint.frame.a > 0.0 { 2.0 } else { 0.0 },
341 radius: self.radius.into(),
342 },
343 ..renderer::Quad::default()
344 },
345 paint.fill,
346 );
347 }
348 if let Some(child) = tree.children.first() {
349 self.content
350 .as_widget()
351 .draw(child, renderer, theme, style, layout, cursor, viewport);
352 }
353 }
354
355 fn operate(&mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn Operation) {
356 if let Some(child) = tree.children.first_mut() {
357 self.content.as_widget_mut().operate(child, layout, renderer, operation);
358 }
359 }
360
361 fn mouse_interaction(
362 &self,
363 tree: &Tree,
364 layout: Layout<'_>,
365 cursor: mouse::Cursor,
366 viewport: &Rectangle,
367 renderer: &Renderer,
368 ) -> mouse::Interaction {
369 tree.children.first().map_or_else(mouse::Interaction::default, |child| {
370 self.content
371 .as_widget()
372 .mouse_interaction(child, layout, cursor, viewport, renderer)
373 })
374 }
375
376 fn overlay<'b>(
377 &'b mut self,
378 tree: &'b mut Tree,
379 layout: Layout<'b>,
380 renderer: &Renderer,
381 viewport: &Rectangle,
382 translation: Vector,
383 ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
384 let child = tree.children.first_mut()?;
385 self.content
386 .as_widget_mut()
387 .overlay(child, layout, renderer, viewport, translation)
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn the_curve_eases_out() {
397 assert!(ease(0.0).abs() < 1e-4);
398 assert!((ease(1.0) - 1.0).abs() < 1e-4);
399 // Out of range is held at the ends.
400 assert!(ease(-1.0).abs() < 1e-4);
401 assert!((ease(2.0) - 1.0).abs() < 1e-4);
402 // Most of the way is gone by half the time: it settles gently.
403 assert!(ease(0.5) > 0.75);
404 let mut last = 0.0;
405 for step in 1..=100 {
406 let now = ease(step as f32 / 100.0);
407 assert!(now >= last, "never goes back, so never bounces");
408 assert!(now <= 1.0 + 1e-4, "never overshoots");
409 last = now;
410 }
411 }
412
413 #[test]
414 fn tweens_go_where_they_are_aimed() {
415 let start = Instant::now();
416 let mut tween = Tween::at_rest(0.0_f32);
417 assert!(!tween.running(start));
418 tween.aim(10.0, start, STATE, true);
419 assert!(tween.running(start));
420 assert_eq!(tween.at(start), 0.0);
421 let halfway = tween.at(start + STATE / 2);
422 assert!(halfway > 5.0 && halfway < 10.0, "{halfway}");
423 assert_eq!(tween.at(start + STATE), 10.0);
424 assert!(!tween.running(start + STATE));
425
426 // Aimed elsewhere on the way, it sets off from where it is.
427 tween.aim(0.0, start + STATE / 2, STATE, true);
428 assert!((tween.at(start + STATE / 2) - halfway).abs() < 1e-3);
429
430 // Without motion, it is there at once.
431 let mut still = Tween::at_rest(0.0_f32);
432 still.aim(1.0, start, STATE, false);
433 assert!(!still.running(start));
434 assert_eq!(still.at(start), 1.0);
435 }
436
437 #[test]
438 fn colors_fade_in_as_themselves() {
439 let red = Color::from_rgb(1.0, 0.0, 0.0);
440 let half = Color::TRANSPARENT.mix(red, 0.5);
441 assert_eq!(half, Color { a: 0.5, ..red });
442 let half = red.mix(Color::TRANSPARENT, 0.5);
443 assert_eq!(half, Color { a: 0.5, ..red });
444 assert_eq!(red.mix(Color::WHITE, 1.0), Color::WHITE);
445 }
446}