| 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 | |
| 10 | use iced::advanced::layout::{self, Layout}; |
| 11 | use iced::advanced::widget::{Operation, Tree, Widget, tree}; |
| 12 | use iced::advanced::{Clipboard, Shell, mouse, overlay, renderer}; |
| 13 | use iced::time::{Duration, Instant}; |
| 14 | use iced::{Border, Color, Element, Event, Length, Rectangle, Renderer, Size, Theme, Vector, window}; |
| 15 | |
| 16 | use crate::Message; |
| 17 | |
| 18 | /// A hover, in and out. |
| 19 | pub const HOVER: Duration = Duration::from_millis(150); |
| 20 | /// A change of state: a switch, the chosen segment, the keyboard cursor. |
| 21 | pub const STATE: Duration = Duration::from_millis(200); |
| 22 | /// A page fading in. |
| 23 | pub const PAGE: Duration = Duration::from_millis(240); |
| 24 | /// A dialog fading in. |
| 25 | pub const OPEN: Duration = Duration::from_millis(200); |
| 26 | /// A dialog fading out. |
| 27 | pub const CLOSE: Duration = Duration::from_millis(150); |
| 28 | /// A battery ring lighting its dots, one after the other. |
| 29 | pub const SWEEP: Duration = Duration::from_millis(600); |
| 30 | /// How long a blinking dot stays lit, and dark. |
| 31 | pub const BLINK: Duration = Duration::from_millis(500); |
| 32 | /// One step of the scanning dots. |
| 33 | pub 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. |
| 37 | pub 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. |
| 64 | pub 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`. |
| 70 | pub 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. |
| 75 | pub 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 | |
| 80 | impl Mix for f32 { |
| 81 | fn mix(self, to: f32, t: f32) -> f32 { |
| 82 | self + (to - self) * t |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | impl 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)] |
| 102 | pub 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 | |
| 110 | impl<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. |
| 153 | pub 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)] |
| 162 | pub struct Paint { |
| 163 | pub fill: Color, |
| 164 | pub frame: Color, |
| 165 | } |
| 166 | |
| 167 | impl 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 | |
| 190 | impl 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. |
| 204 | pub 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. |
| 223 | pub 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 | |
| 232 | struct 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. |
| 242 | struct 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 | |
| 249 | impl 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)] |
| 392 | mod 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 | } |