Skip to main content

solium_effects/
lib.rs

1#![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))]
2//! Solium's vertex deformations: the shapes a rectangle cannot hold.
3//!
4//! A genie, a fold, a curl, a page turn. Each is a **named function with
5//! parameters** saying where a point of a window's unit square is drawn, and
6//! how finely the window has to be cut for the result to read as a curve
7//! rather than as a fan of flat pieces. Everything else -- capture,
8//! projection, damage, blending between two of them -- is the same code for
9//! all of them and lives in the compositor.
10//!
11//! ## Why this is a crate and not a shader
12//!
13//! Two reasons, both hard, both in
14//! `docs/design/2026-09-12-panes-and-effects-design.md`:
15//!
16//! * The damage tracker needs the deformed bounding box **before** anything is
17//!   drawn, and a shader cannot tell it one. A wrong damage rect is a corrupt
18//!   screen; a wrong fragment shader is only a wrong picture.
19//! * An effect you can only judge by launching a compositor is an effect
20//!   nobody tunes. Everything here is arithmetic on `f64`, so the same code
21//!   that bends real windows is driven by the unit tests below and by the
22//!   preview page in `preview/` with live sliders.
23//!
24//! ## What it deliberately does not know
25//!
26//! What a pane is. The two rectangles a deformation morphs between arrive
27//! already resolved: this crate is handed numbers, and the compositor is what
28//! turns "the dock icon" into a rectangle, once per frame. See `Deform::place`.
29//!
30//! ```
31//! use solium_effects::{Axis, Deform, Rect};
32//!
33//! let window = Rect::new(0.0, 0.0, 800.0, 600.0);
34//! let icon = Rect::new(400.0, 1000.0, 64.0, 32.0);
35//! let genie = Deform::Genie { progress: 1.0, spread: 1.0, axis: Axis::Down };
36//!
37//! // All the way in: every corner of the window is inside the icon.
38//! let (x, y) = genie.place(window, icon, 0.0, 0.0);
39//! assert!((x - icon.x).abs() < 1e-9 && (y - icon.y).abs() < 1e-9);
40//! ```
41
42pub mod ffi;
43pub mod fragment;
44
45/// A rectangle, in whatever coordinates the caller is using.
46///
47/// This crate's own rather than the compositor's, because the compositor's is
48/// Smithay's and this crate has no dependencies. The conversion happens at one
49/// seam, in `present.rs`.
50#[derive(Clone, Copy, Debug, Default, PartialEq)]
51pub struct Rect {
52    pub x: f64,
53    pub y: f64,
54    pub w: f64,
55    pub h: f64,
56}
57
58impl Rect {
59    #[must_use]
60    pub const fn new(x: f64, y: f64, w: f64, h: f64) -> Self {
61        Self { x, y, w, h }
62    }
63
64    /// The point at `(u, v)` of this rectangle's unit square. `(0, 0)` is its
65    /// top left corner.
66    #[must_use]
67    pub fn at(self, u: f64, v: f64) -> (f64, f64) {
68        (self.x + u * self.w, self.y + v * self.h)
69    }
70}
71
72/// Interpolate between two values.
73///
74/// `solium_animation::lerp` is the same three characters, and this is not a
75/// dependency on it: an effects crate that imports the animation crate is one
76/// step from an effects crate that imports the compositor, and the empty
77/// `[dependencies]` in `Cargo.toml` is the thing keeping both testable.
78fn lerp(from: f64, to: f64, progress: f64) -> f64 {
79    from + (to - from) * progress
80}
81
82/// One name, in the form a lookup compares.
83fn tidy(name: &str) -> String {
84    name.trim().to_ascii_lowercase().replace(['_', '-'], "")
85}
86
87/// Where an effect's parameters come from.
88///
89/// A trait rather than a map, because the same parameters arrive from a Lua
90/// table in the compositor, from a slider in the preview, and from a literal
91/// in a test -- and this crate may not depend on any of the three. Every
92/// parameter is optional and every effect has a default for each of its own,
93/// which is what makes `{ effect = "genie" }` on its own a legal thing to
94/// write.
95///
96/// What this cannot do is complain. A parameter that is missing, misspelled or
97/// the wrong type is indistinguishable here from one nobody wrote, and there
98/// is no logger to say so -- so both give the default. The check that catches
99/// it is `script::shipped`, which reads the names out of this crate and fails
100/// the build when the shipped Lua asks for one that does not exist.
101pub trait Params {
102    /// A number, if the caller has one under that name.
103    fn number(&self, key: &str) -> Option<f64>;
104
105    /// A word: an enumerated choice, such as which way a genie is pulled.
106    fn word(&self, key: &str) -> Option<String>;
107}
108
109/// No parameters at all: every effect at its own defaults.
110impl Params for () {
111    fn number(&self, _key: &str) -> Option<f64> {
112        None
113    }
114    fn word(&self, _key: &str) -> Option<String> {
115        None
116    }
117}
118
119/// Which way an effect sweeps across the window.
120///
121/// The axis a genie was hardcoded to before this crate existed, which is why a
122/// dock down the side of the screen was inexpressible: `(1.0 - v)` is *down*
123/// and nothing else. Named for the direction the window is pulled, so the
124/// edge nearest the dock is the edge that leads.
125#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
126pub enum Axis {
127    /// Pulled downwards: the bottom edge leads. A dock along the bottom, and
128    /// the default because that is the dock everybody has seen.
129    #[default]
130    Down,
131    /// Pulled upwards: the top edge leads. A bar along the top.
132    Up,
133    /// Pulled to the left: the left edge leads. A dock down the left side.
134    Left,
135    /// Pulled to the right: the right edge leads.
136    Right,
137}
138
139impl Axis {
140    /// How far through the sweep the point at `(u, v)` is.
141    ///
142    /// 0 leads and 1 follows, so a point at phase 0 has finished its journey
143    /// before a point at phase 1 has started one. That lag is the whole
144    /// difference between a genie and a shrink.
145    #[must_use]
146    pub fn phase(self, u: f64, v: f64) -> f64 {
147        match self {
148            Self::Down => 1.0 - v,
149            Self::Up => v,
150            Self::Left => u,
151            Self::Right => 1.0 - u,
152        }
153    }
154
155    /// Whether the sweep runs across the window rather than down it.
156    ///
157    /// What [`Deform::segments`] turns the grid by. A window cut into 48 rows
158    /// and 8 columns has 48 steps of phase for a dock at the bottom and eight
159    /// for one at the side -- which is not a subtler genie, it is a flipbook.
160    #[must_use]
161    pub fn horizontal(self) -> bool {
162        matches!(self, Self::Left | Self::Right)
163    }
164
165    /// Every axis, for tools that want to show them all and for the check that
166    /// the shipped Lua only names ones that exist.
167    #[must_use]
168    pub fn all() -> [(&'static str, Self); 4] {
169        [
170            ("down", Self::Down),
171            ("up", Self::Up),
172            ("left", Self::Left),
173            ("right", Self::Right),
174        ]
175    }
176
177    /// Look an axis up by the name a script or a settings file would use.
178    #[must_use]
179    pub fn from_name(name: &str) -> Option<Self> {
180        let wanted = tidy(name);
181        Self::all()
182            .into_iter()
183            .find(|(known, _)| tidy(known) == wanted)
184            .map(|(_, axis)| axis)
185    }
186
187    /// What to call this axis.
188    #[must_use]
189    pub fn name(self) -> &'static str {
190        match self {
191            Self::Down => "down",
192            Self::Up => "up",
193            Self::Left => "left",
194            Self::Right => "right",
195        }
196    }
197}
198
199/// A deformation a rectangle cannot express.
200///
201/// A small enum rather than a callback: a deform has to be blended between two
202/// frames, compared for equality, and named by a script, and a closure does
203/// none of those.
204#[derive(Clone, Copy, Debug, PartialEq)]
205pub enum Deform {
206    /// Pulled from one rectangle into another like a sheet through a
207    /// letterbox: the minimise, and the open that undoes it.
208    ///
209    /// `progress` 0 draws the window where it is, 1 has all of it inside the
210    /// far rectangle. The rows nearest that rectangle go first, and that lag
211    /// is the whole effect: it bends the sheet instead of shrinking it.
212    /// `spread` is how much of the window is in motion at once -- 0 pulls it
213    /// in rigidly, larger values draw the tail out behind it. `axis` is which
214    /// edge leads.
215    Genie {
216        progress: f32,
217        spread: f32,
218        axis: Axis,
219    },
220}
221
222impl Deform {
223    /// Where the point at `(u, v)` of a window's unit square is drawn.
224    ///
225    /// A morph between two rectangles, and **both of them are arguments**.
226    /// `from` is where the window is being drawn and `to` is what it is being
227    /// pulled into -- a dock icon, a tab, another window. Neither is stored:
228    /// the compositor resolves `to` from an identity once per frame, because a
229    /// rectangle snapshotted when the script ran aims at where the dock icon
230    /// was half a second ago.
231    ///
232    /// Which of the two is the window is the caller's choice, so there is no
233    /// separate effect for the way back: swap them, or animate `progress`
234    /// down instead of up, and the same function opens a window out of an icon
235    /// that it minimised into one.
236    #[must_use]
237    pub fn place(self, from: Rect, to: Rect, u: f64, v: f64) -> (f64, f64) {
238        match self {
239            Self::Genie {
240                progress,
241                spread,
242                axis,
243            } => {
244                let spread = f64::from(spread).max(0.0);
245                // Each step across the axis runs its own copy of the
246                // animation, the ones furthest from the target starting last.
247                // Smoothstepped per step, so the sheet arrives without a
248                // crease.
249                let step = (f64::from(progress) * (1.0 + spread) - axis.phase(u, v) * spread)
250                    .clamp(0.0, 1.0);
251                let eased = step * step * (3.0 - 2.0 * step);
252                let (from_x, from_y) = from.at(u, v);
253                let (to_x, to_y) = to.at(u, v);
254                (lerp(from_x, to_x, eased), lerp(from_y, to_y, eased))
255            }
256        }
257    }
258
259    /// Columns and rows the mesh needs to look like a curve rather than a fan
260    /// of flat pieces. Columns subdivide `u`, rows subdivide `v`.
261    ///
262    /// The grid follows the axis, which is the whole reason the axis is a
263    /// parameter and not two effects. Across the sweep the taper is linear, so
264    /// a handful of cells only matters when a matrix is in play too; along it
265    /// is where the bend lives and where every cell is one more step of phase.
266    #[must_use]
267    pub fn segments(self) -> (u32, u32) {
268        /// Cells along the sweep: enough that the bend has no visible facets.
269        const ALONG: u32 = 48;
270        /// Cells across it.
271        const ACROSS: u32 = 8;
272
273        match self {
274            Self::Genie { axis, .. } => {
275                if axis.horizontal() {
276                    (ALONG, ACROSS)
277                } else {
278                    (ACROSS, ALONG)
279                }
280            }
281        }
282    }
283
284    /// The same deform, doing nothing.
285    #[must_use]
286    pub fn at_rest(self) -> Self {
287        match self {
288            Self::Genie { spread, axis, .. } => Self::Genie {
289                progress: 0.0,
290                spread,
291                axis,
292            },
293        }
294    }
295
296    /// Whether this deform draws the window exactly where it is: progress 0,
297    /// or below it, which clamps to the same picture.
298    /// `tests::at_rest_a_deform_draws_the_window_exactly_where_it_is` shows the
299    /// picture; `tests::a_genie_at_progress_zero_or_below_is_at_rest` the rule.
300    #[must_use]
301    pub fn is_at_rest(self) -> bool {
302        match self {
303            Self::Genie { progress, .. } => progress <= 0.0,
304        }
305    }
306
307    /// Blend two deforms, either of which may be absent.
308    ///
309    /// Absent means "not deformed", which for a genie is progress 0 -- so a
310    /// script animating into one does not have to name the starting state, and
311    /// clearing one animates back out of it.
312    #[must_use]
313    pub fn blend(from: Option<Self>, to: Option<Self>, progress: f64) -> Option<Self> {
314        match (from, to) {
315            (None, None) => None,
316            (Some(one), None) => Some(one.mix(one.at_rest(), progress)),
317            (None, Some(other)) => Some(other.at_rest().mix(other, progress)),
318            (Some(one), Some(other)) => Some(one.mix(other, progress)),
319        }
320    }
321
322    /// Blend towards another deform of the same kind.
323    ///
324    /// Between different kinds there is no meaningful halfway, so the
325    /// destination wins outright; a script wanting a hand-off animates one out
326    /// and the next one in. The axis is the same: there is no rectangle
327    /// half way between *down* and *left*, and a grid cannot be turned a
328    /// quarter of the way round.
329    #[must_use]
330    pub fn mix(self, other: Self, progress: f64) -> Self {
331        match (self, other) {
332            (
333                Self::Genie {
334                    progress: from_progress,
335                    spread: from_spread,
336                    ..
337                },
338                Self::Genie {
339                    progress: to_progress,
340                    spread: to_spread,
341                    axis,
342                },
343            ) => Self::Genie {
344                progress: number(lerp(
345                    f64::from(from_progress),
346                    f64::from(to_progress),
347                    progress,
348                )),
349                spread: number(lerp(f64::from(from_spread), f64::from(to_spread), progress)),
350                axis,
351            },
352        }
353    }
354
355    /// Every effect, at its defaults, for tools that want to show them all.
356    ///
357    /// This is the whole vocabulary [`Self::from_name`] knows, so it is also
358    /// what a script may write -- and what the shipped `lua/*.lua` is checked
359    /// against by `script::shipped`. An effect added here is available to
360    /// every script and to the preview the moment it compiles, with nothing to
361    /// add in `script.rs`; that is the point of the crate.
362    #[must_use]
363    pub fn all() -> [(&'static str, Self); 1] {
364        [(
365            "genie",
366            Self::Genie {
367                // All the way in, because that is what one animates *towards*.
368                progress: 1.0,
369                spread: 1.0,
370                axis: Axis::Down,
371            },
372        )]
373    }
374
375    /// Look an effect up by the name a script used, and read its parameters.
376    ///
377    /// Mirrors `Curve::from_name`, including the part that matters: the names
378    /// come from the engine rather than from a list kept in `script.rs`.
379    #[must_use]
380    pub fn from_name(name: &str, params: &dyn Params) -> Option<Self> {
381        let wanted = tidy(name);
382        Self::all()
383            .into_iter()
384            .find(|(known, _)| tidy(known) == wanted)
385            .map(|(_, effect)| effect.with(params))
386    }
387
388    /// What to call this effect.
389    #[must_use]
390    pub fn name(self) -> &'static str {
391        match self {
392            Self::Genie { .. } => "genie",
393        }
394    }
395
396    /// This effect with whatever the caller named read into it. Anything it
397    /// does not name keeps the default it already has.
398    #[must_use]
399    fn with(self, params: &dyn Params) -> Self {
400        match self {
401            Self::Genie {
402                progress,
403                spread,
404                axis,
405            } => Self::Genie {
406                progress: params.number("progress").map_or(progress, number),
407                spread: params.number("spread").map_or(spread, number),
408                axis: params
409                    .word("axis")
410                    .and_then(|word| Axis::from_name(&word))
411                    .unwrap_or(axis),
412            },
413        }
414    }
415}
416
417/// One parameter, narrowed to what a vertex function needs.
418///
419/// `f32` throughout because these are counts of nothing -- a fraction, a
420/// spread -- and a deform is compared for equality every frame.
421#[expect(
422    clippy::cast_possible_truncation,
423    reason = "an effect parameter is a small float either way"
424)]
425fn number(value: f64) -> f32 {
426    value as f32
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    const WINDOW: Rect = Rect::new(100.0, 50.0, 800.0, 600.0);
434    const ICON: Rect = Rect::new(600.0, 1000.0, 64.0, 32.0);
435
436    fn genie(progress: f32, axis: Axis) -> Deform {
437        Deform::Genie {
438            progress,
439            spread: 1.0,
440            axis,
441        }
442    }
443
444    /// The four corners of the unit square at some progress.
445    fn corners(deform: Deform, from: Rect, to: Rect) -> [(f64, f64); 4] {
446        [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)].map(|(u, v)| deform.place(from, to, u, v))
447    }
448
449    #[test]
450    fn at_rest_a_deform_draws_the_window_exactly_where_it_is() {
451        // The property the whole cheap path rests on: progress 0 is not
452        // "nearly the window", it is the window, to the last bit.
453        for axis in Axis::all().map(|(_, axis)| axis) {
454            for step in 0..=10 {
455                let (u, v) = (f64::from(step) / 10.0, f64::from(step % 7) / 7.0);
456                assert_eq!(genie(0.0, axis).place(WINDOW, ICON, u, v), WINDOW.at(u, v));
457            }
458        }
459    }
460
461    /// A genie at progress 0, or below it (which clamps to the same picture),
462    /// is at rest; any progress past 0 is not.
463    #[test]
464    fn a_genie_at_progress_zero_or_below_is_at_rest() {
465        let genie = |progress| Deform::Genie {
466            progress,
467            spread: 1.4,
468            axis: Axis::Down,
469        };
470        assert!(genie(0.0).is_at_rest());
471        assert!(genie(-0.5).is_at_rest());
472        assert!(!genie(0.01).is_at_rest());
473        assert!(genie(1.0).at_rest().is_at_rest());
474    }
475
476    #[test]
477    fn all_the_way_in_every_corner_is_inside_the_target() {
478        for axis in Axis::all().map(|(_, axis)| axis) {
479            for corner in corners(genie(1.0, axis), WINDOW, ICON) {
480                assert!(
481                    corner.0 >= ICON.x - 1e-9
482                        && corner.0 <= ICON.x + ICON.w + 1e-9
483                        && corner.1 >= ICON.y - 1e-9
484                        && corner.1 <= ICON.y + ICON.h + 1e-9,
485                    "{} left {corner:?} outside {ICON:?}",
486                    axis.name()
487                );
488            }
489        }
490    }
491
492    /// **The lag is what makes it a genie rather than a shrink.**
493    ///
494    /// Half way through, the edge nearest the target has travelled further
495    /// than the edge furthest from it. Without that the window is a rectangle
496    /// changing size, which is a different animation with the same endpoints
497    /// -- and the endpoints are all the two tests above look at.
498    #[test]
499    fn the_leading_edge_goes_first() {
500        let travelled = |deform: Deform, u: f64, v: f64| {
501            let (x, y) = deform.place(WINDOW, ICON, u, v);
502            let (rest_x, rest_y) = WINDOW.at(u, v);
503            (x - rest_x).hypot(y - rest_y)
504        };
505
506        for (axis, (lead_u, lead_v), (trail_u, trail_v)) in [
507            (Axis::Down, (0.5, 1.0), (0.5, 0.0)),
508            (Axis::Up, (0.5, 0.0), (0.5, 1.0)),
509            (Axis::Left, (0.0, 0.5), (1.0, 0.5)),
510            (Axis::Right, (1.0, 0.5), (0.0, 0.5)),
511        ] {
512            let deform = genie(0.5, axis);
513            let (lead, trail) = (
514                travelled(deform, lead_u, lead_v),
515                travelled(deform, trail_u, trail_v),
516            );
517            assert!(
518                lead > trail + 1.0,
519                "{}: the leading edge moved {lead:.1} and the trailing one {trail:.1}, \
520                 which is a shrink and not a genie",
521                axis.name()
522            );
523        }
524    }
525
526    /// **No part of the sheet overtakes the part ahead of it.**
527    ///
528    /// The sheet *stretches* -- the neck is the whole look -- so neighbouring
529    /// rows do not stay a fixed distance apart and a test asserting they do is
530    /// asserting a shrink. What must hold instead is the ordering: a row
531    /// closer to the target is never less far along than one behind it, at any
532    /// progress and at any spread. Break that and the sheet folds through
533    /// itself, which draws as a crease that flickers rather than as anything a
534    /// reader would call a bug in a genie.
535    ///
536    /// Measured against a target the same size as the window, so every point
537    /// has the same distance to cover and how far it has gone is comparable
538    /// between two of them. Against a shrinking target it is not: a row near
539    /// the top has further to travel than a row near the bottom, whichever is
540    /// further along.
541    #[test]
542    fn no_part_of_the_sheet_overtakes_the_one_ahead_of_it() {
543        let shifted = Rect::new(WINDOW.x, WINDOW.y + 900.0, WINDOW.w, WINDOW.h);
544        let travelled = |deform: Deform, u: f64, v: f64| {
545            let (x, y) = deform.place(WINDOW, shifted, u, v);
546            let (rest_x, rest_y) = WINDOW.at(u, v);
547            (x - rest_x).hypot(y - rest_y)
548        };
549
550        for spread in [0.0, 0.5, 1.4, 4.0] {
551            for step in 0_u8..=10 {
552                let progress = f32::from(step) / 10.0;
553                let deform = Deform::Genie {
554                    progress,
555                    spread,
556                    axis: Axis::Down,
557                };
558                let (columns, rows) = deform.segments();
559                // Down the phase axis, from the leading edge backwards.
560                let mut ahead = f64::MAX;
561                for row in (0..=rows).rev() {
562                    let v = f64::from(row) / f64::from(rows);
563                    for column in 0..=columns {
564                        let behind = travelled(deform, f64::from(column) / f64::from(columns), v);
565                        assert!(
566                            behind <= ahead + 1e-9,
567                            "spread {spread} at {progress}: row {row} has gone {behind:.3} \
568                             and the row ahead of it only {ahead:.3}"
569                        );
570                    }
571                    ahead = travelled(deform, 0.0, v);
572                }
573            }
574        }
575
576        // And the instrument: at a spread this large the rows really are at
577        // different points, or the assertion above is comparing a rigid slab
578        // with itself and holds for the wrong reason.
579        let wide = Deform::Genie {
580            progress: 0.5,
581            spread: 4.0,
582            axis: Axis::Down,
583        };
584        assert!(
585            travelled(wide, 0.5, 1.0) > travelled(wide, 0.5, 0.0) + 1.0,
586            "the sheet is not spread out at all"
587        );
588    }
589
590    /// **The grid follows the axis.**
591    ///
592    /// The reason the axis could not simply be a parameter over a fixed grid:
593    /// the fine subdivision has to be the one the phase varies along. Eight
594    /// steps of phase is a flipbook, whichever way the dock faces.
595    #[test]
596    fn the_fine_subdivision_is_the_one_the_phase_runs_along() {
597        for (_, axis) in Axis::all() {
598            let (columns, rows) = genie(0.5, axis).segments();
599            let (along, across) = if axis.horizontal() {
600                (columns, rows)
601            } else {
602                (rows, columns)
603            };
604            assert!(
605                along >= across * 4,
606                "{}: {along} cells along the sweep and {across} across it",
607                axis.name()
608            );
609            // And the phase really does vary along that one and not the other.
610            let (a, b) = if axis.horizontal() {
611                (axis.phase(0.0, 0.5), axis.phase(1.0, 0.5))
612            } else {
613                (axis.phase(0.5, 0.0), axis.phase(0.5, 1.0))
614            };
615            assert!((a - b).abs() > 0.9, "{}: phase barely moved", axis.name());
616        }
617    }
618
619    /// **Opening out of an icon is the same effect as minimising into one.**
620    ///
621    /// Which is why there is no `Ungenie`: the direction is which rectangle is
622    /// handed in as the window, and the compositor's `from` is whichever one
623    /// it is drawing.
624    #[test]
625    fn the_morph_runs_both_ways() {
626        let deform = genie(1.0, Axis::Down);
627        for corner in corners(deform, ICON, WINDOW) {
628            assert!(
629                corner.0 >= WINDOW.x - 1e-9 && corner.0 <= WINDOW.x + WINDOW.w + 1e-9,
630                "{corner:?} is not inside {WINDOW:?}"
631            );
632        }
633    }
634
635    /// **A target that is where the window already is deforms nothing.**
636    ///
637    /// The compositor's failure mode when an anchor cannot be resolved -- the
638    /// pane it named has closed -- is to aim the deform at the window's own
639    /// rectangle. It has to come out as the undeformed window rather than as
640    /// anything interesting.
641    #[test]
642    fn aiming_at_itself_is_a_no_op() {
643        for progress in [0.0, 0.25, 0.5, 1.0] {
644            for (u, v) in [(0.0, 0.0), (0.5, 0.5), (1.0, 1.0), (0.25, 0.75)] {
645                assert_eq!(
646                    genie(progress, Axis::Down).place(WINDOW, WINDOW, u, v),
647                    WINDOW.at(u, v)
648                );
649            }
650        }
651    }
652
653    #[test]
654    fn blending_from_nothing_starts_at_rest_and_lands_on_the_deform() {
655        let target = genie(1.0, Axis::Down);
656        assert_eq!(Deform::blend(None, None, 0.5), None);
657        assert_eq!(
658            Deform::blend(None, Some(target), 0.0),
659            Some(target.at_rest())
660        );
661        assert_eq!(Deform::blend(None, Some(target), 1.0), Some(target));
662        // And clearing one animates back out of it rather than snapping.
663        assert_eq!(
664            Deform::blend(Some(target), None, 1.0),
665            Some(target.at_rest())
666        );
667        let halfway = Deform::blend(None, Some(target), 0.5);
668        assert_eq!(halfway, Some(genie(0.5, Axis::Down)));
669    }
670
671    /// **An axis does not blend; the destination's wins.**
672    ///
673    /// There is no grid a quarter of the way between 48 rows and 48 columns,
674    /// so a halfway axis would be a lie the mesh could not draw.
675    #[test]
676    fn the_destination_axis_wins_outright() {
677        let down = genie(1.0, Axis::Down);
678        let left = genie(1.0, Axis::Left);
679        for progress in [0.0, 0.5, 1.0] {
680            assert_eq!(
681                down.mix(left, progress),
682                genie(1.0, Axis::Left),
683                "at {progress} the axis was not the destination's"
684            );
685        }
686    }
687
688    #[test]
689    fn effects_are_found_by_the_names_scripts_use() {
690        assert_eq!(
691            Deform::from_name("genie", &()),
692            Some(Deform::Genie {
693                progress: 1.0,
694                spread: 1.0,
695                axis: Axis::Down,
696            })
697        );
698        assert_eq!(
699            Deform::from_name("GENIE", &()),
700            Deform::from_name("genie", &())
701        );
702        assert_eq!(Deform::from_name("nonsense", &()), None);
703    }
704
705    /// Every name in [`Deform::all`] is one a script can write, and every
706    /// effect answers to the name it is listed under.
707    ///
708    /// The round trip rather than either half, for the reason
709    /// `solium_animation`'s equivalent gives: the halves are two lists that
710    /// have to agree and there is nothing but this holding them together.
711    #[test]
712    fn every_name_resolves_and_every_effect_names_itself() {
713        for (name, effect) in Deform::all() {
714            assert_eq!(
715                Deform::from_name(name, &()),
716                Some(effect),
717                "{name} is unreadable"
718            );
719            assert_eq!(
720                effect.name(),
721                name,
722                "{name} does not answer to its own name"
723            );
724        }
725        for (name, axis) in Axis::all() {
726            assert_eq!(Axis::from_name(name), Some(axis), "{name} is unreadable");
727            assert_eq!(axis.name(), name, "{name} does not answer to its own name");
728        }
729    }
730
731    /// Parameters are read through the caller's own table, and anything it
732    /// does not carry keeps the effect's default.
733    #[test]
734    fn parameters_are_read_and_the_rest_default() {
735        struct Given;
736        impl Params for Given {
737            fn number(&self, key: &str) -> Option<f64> {
738                (key == "spread").then_some(2.5)
739            }
740            fn word(&self, key: &str) -> Option<String> {
741                (key == "axis").then(|| "LEFT".to_owned())
742            }
743        }
744
745        assert_eq!(
746            Deform::from_name("genie", &Given),
747            Some(Deform::Genie {
748                // Untouched: nothing was given for it.
749                progress: 1.0,
750                spread: 2.5,
751                axis: Axis::Left,
752            })
753        );
754
755        // A word that names no axis is indistinguishable from one nobody
756        // wrote, and both keep the default. `script::shipped` is what catches
757        // it in the Lua that ships.
758        struct Nonsense;
759        impl Params for Nonsense {
760            fn number(&self, _key: &str) -> Option<f64> {
761                None
762            }
763            fn word(&self, _key: &str) -> Option<String> {
764                Some("sideways".to_owned())
765            }
766        }
767        assert_eq!(
768            Deform::from_name("genie", &Nonsense),
769            Deform::from_name("genie", &())
770        );
771    }
772}