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}