- skills/README.md: provenance + index - SKILL.md: merged design-engineering doctrine, Makepad-native (animator, script_mod!) - EASING.md: CSS cubic-bezier -> Ease.Bezier translation verified against dev@b41e740 - RECIPES.md: press scale 0.96, toast enter/exit, sheet settle w/ velocity handoff, stagger, reduced motion - PORTS-AUDIT.md: Before/After audit of rider/koboyo/insurance with file:line refs
266 lines
9.1 KiB
Markdown
266 lines
9.1 KiB
Markdown
# Recipes — the doctrine as `script_mod!` + Rust
|
||
|
||
Copy-paste starting points, written against the Makepad 2.0 dev branch (`b41e740`) API:
|
||
`AnimatorImpl` (`animator_play`, `animator_cut`, `animator_toggle`, `animator_handle_event`,
|
||
`animator_in_state`), `AnimatorState`, `Play`, `Ease`, `snap()`. Widget structs need
|
||
`#[derive(Script, ScriptHook, Widget, Animator)]` and `#[apply_default] animator: Animator`
|
||
(see `reference/AGENTS.md`). In `handle_event`, always pump the animator:
|
||
|
||
```rust
|
||
if self.animator_handle_event(cx, event).must_redraw() {
|
||
self.draw_bg.redraw(cx);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 1. Press feedback — 0.96 shader scale, snap in / eased out
|
||
|
||
The asymmetric contract: press feedback is instant (`snap`), release relaxes in 0.15s.
|
||
Scale happens in SDF space — no layout churn.
|
||
|
||
```rust
|
||
// DSL — inside the widget definition
|
||
draw_bg +: {
|
||
down: instance(0.0)
|
||
hover: instance(0.0)
|
||
color: uniform(#ffffff)
|
||
color_hover: uniform(#f4f3fa)
|
||
radius: uniform(12.0)
|
||
|
||
pixel: fn() {
|
||
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
|
||
// scale(0.96) around the center while held
|
||
let s = mix(1.0, 0.96, self.down)
|
||
let c = self.rect_size * 0.5
|
||
sdf.box(
|
||
c.x - c.x * s, c.y - c.y * s,
|
||
self.rect_size.x * s, self.rect_size.y * s,
|
||
self.radius * s
|
||
)
|
||
sdf.fill(self.color.mix(self.color_hover, self.hover))
|
||
return sdf.result
|
||
}
|
||
}
|
||
|
||
animator: Animator{
|
||
hover: {
|
||
default: @off
|
||
off: AnimatorState{
|
||
ease: OutQuad
|
||
from: {all: Forward {duration: 0.1}}
|
||
apply: {draw_bg: {hover: 0.0, down: 0.0}}
|
||
}
|
||
on: AnimatorState{
|
||
ease: OutQuad
|
||
from: {all: Forward {duration: 0.08}}
|
||
apply: {draw_bg: {hover: 1.0, down: 0.0}}
|
||
}
|
||
down: AnimatorState{
|
||
ease: OutQuad
|
||
from: {all: Forward {duration: 0.15}} // release path: eased
|
||
apply: {draw_bg: {down: snap(1.0), hover: 1.0}} // press path: instant
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```rust
|
||
// Rust — feedback on down, COMMIT ON UP-IF-OVER (tap-cancel works)
|
||
match event.hits(cx, self.draw_bg.area()) {
|
||
Hit::FingerHoverIn(_) => self.animator_play(cx, ids!(hover.on)),
|
||
Hit::FingerHoverOut(_) => self.animator_play(cx, ids!(hover.off)),
|
||
Hit::FingerDown(_) => self.animator_play(cx, ids!(hover.down)),
|
||
Hit::FingerUp(fe) => {
|
||
if fe.is_over {
|
||
self.animator_play(cx, if fe.device.has_hovers() {ids!(hover.on)} else {ids!(hover.off)});
|
||
cx.widget_action(uid, &scope.path, ButtonAction::Clicked); // commit here
|
||
} else {
|
||
self.animator_play(cx, ids!(hover.off)); // dragged away = cancel
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Icon / state swap — opacity + scale + blur-substitute
|
||
|
||
Jakub's exact values: scale 0.25↔1, opacity 0↔1 (blur has no cheap SDF analog; widen the
|
||
edge feather with a `soft` instance instead). Cross-fade both glyphs in one shader, driven
|
||
by a single `t`:
|
||
|
||
```rust
|
||
draw_bg +: {
|
||
t: instance(0.0) // 0 = icon A, 1 = icon B
|
||
pixel: fn() {
|
||
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
|
||
let sa = mix(1.0, 0.25, self.t) // A shrinks out
|
||
let sb = mix(0.25, 1.0, self.t) // B grows in
|
||
// ...draw glyph A with scale sa, alpha (1.0 - self.t)
|
||
// ...draw glyph B with scale sb, alpha self.t
|
||
return sdf.result
|
||
}
|
||
}
|
||
animator: Animator{
|
||
active: {
|
||
default: @off
|
||
off: AnimatorState{ ease: Ease.Bezier {cp0: 0.2, cp1: 0.0, cp2: 0.0, cp3: 1.0}
|
||
from: {all: Forward {duration: 0.2}} apply: {draw_bg: {t: 0.0}} }
|
||
on: AnimatorState{ ease: Ease.Bezier {cp0: 0.2, cp1: 0.0, cp2: 0.0, cp3: 1.0}
|
||
from: {all: Forward {duration: 0.2}} apply: {draw_bg: {t: 1.0}} }
|
||
}
|
||
}
|
||
```
|
||
|
||
Boot rule: `animator_toggle(cx, state, Animate::No, ids!(active.on), ids!(active.off))` when
|
||
first syncing UI to model — animate only on *changes* (`Animate::Yes`), never on startup.
|
||
|
||
---
|
||
|
||
## 3. Toast — enter/exit instead of `set_visible`
|
||
|
||
Toasts are occasional → standard animation. Enter: rise + fade, strong ease-out, 0.3s.
|
||
Exit: same direction back, subtler and faster, 0.2s. Interruptible: a new toast retargets
|
||
the same track mid-flight (that's free with the animator).
|
||
|
||
```rust
|
||
// DSL: drive offset + alpha as instances; shift in the vertex/pixel stage
|
||
toast_view := RoundedView{
|
||
draw_bg +: {
|
||
slide: instance(0.0) // 0 = hidden below, 1 = resting
|
||
// use slide to offset the drawn rect / alpha = slide
|
||
}
|
||
animator: Animator{
|
||
open: {
|
||
default: @off
|
||
off: AnimatorState{ // exit: faster, same path down
|
||
ease: OutQuad
|
||
from: {all: Forward {duration: 0.2}}
|
||
apply: {draw_bg: {slide: 0.0}}
|
||
}
|
||
on: AnimatorState{ // enter: strong ease-out rise
|
||
ease: Ease.Bezier {cp0: 0.23, cp1: 1.0, cp2: 0.32, cp3: 1.0}
|
||
from: {all: Forward {duration: 0.3}}
|
||
apply: {draw_bg: {slide: 1.0}}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```rust
|
||
// Rust — show: make visible, then play (never just set_visible)
|
||
self.ui.view(cx, ids!(toast_view)).set_visible(cx, true);
|
||
toast.animator_play(cx, ids!(open.on));
|
||
self.toast_timer = Some(cx.start_timeout(TOAST_SECS));
|
||
// on timer: play open.off; flip set_visible(false) only after the exit finishes
|
||
// (a short second timeout at 0.2s, or check animator_in_state on the next frame).
|
||
```
|
||
|
||
If the toast body must physically move (layout, not shader), animating its container
|
||
`margin` through the animator is acceptable — toasts are rare enough that relayout is fine.
|
||
|
||
---
|
||
|
||
## 4. Sheet release — momentum projection + velocity handoff
|
||
|
||
Replace "decide mode → snap chrome" with: project where the flick was going, choose the
|
||
nearest snap state, then settle from the *current* position at the *release* velocity using
|
||
a `NextFrame` decay loop (the animator can't take an initial velocity; this can).
|
||
|
||
```rust
|
||
const DECEL_RATE: f64 = 0.998; // 0.99 for a snappier feel
|
||
fn project(v: f64) -> f64 { (v / 1000.0) * DECEL_RATE / (1.0 - DECEL_RATE) }
|
||
|
||
// FingerUp:
|
||
let projected = drag.pos + project(drag.velocity);
|
||
let target = self.flow.nearest_snap(projected); // choose target from projection
|
||
self.settle = Some(Settle {
|
||
from: drag.pos, v0: drag.velocity, target,
|
||
start: event_time,
|
||
});
|
||
self.next_frame = cx.new_next_frame();
|
||
|
||
// NextFrame handler: exponential settle carrying v0 (no-overshoot spring analog)
|
||
if let Some(s) = &self.settle {
|
||
let t = time - s.start;
|
||
let lambda = 12.0; // stiffness/period; raise = snappier
|
||
let d = s.from - s.target;
|
||
let x = (d + (s.v0 + lambda * d) * t) * (-lambda * t).exp();
|
||
let pos = s.target + x;
|
||
self.apply_sheet_pos(cx, pos); // write the instance / walk value
|
||
if x.abs() < 0.5 && (s.v0 * (-lambda * t).exp()).abs() < 10.0 {
|
||
self.settle = None; // settled
|
||
self.flow.set_sheet(s.target_mode);
|
||
} else { self.next_frame = cx.new_next_frame(); }
|
||
}
|
||
|
||
// FingerDown during settle: grab it — kill the settle, resume 1:1 drag from `pos`.
|
||
```
|
||
|
||
For overscroll bounce, use the stock `rubber_band_bounce(x0, v0, t, touch)` from
|
||
`widgets/src/scroll_motion.rs`; while dragging past a bound apply
|
||
`over * dim * 0.55 / (dim + 0.55 * over.abs())`.
|
||
|
||
---
|
||
|
||
## 5. Staggered entrance (rare screens only)
|
||
|
||
Per-item delay of 0.03–0.08s. Simplest robust form: one repeating timer stepping an index,
|
||
playing each row's `enter.on`:
|
||
|
||
```rust
|
||
// on screen-enter (first time only):
|
||
self.stagger_idx = 0;
|
||
self.stagger_timer = Some(cx.start_interval(0.05));
|
||
// on timer tick:
|
||
if let Some(row) = rows.get(self.stagger_idx) {
|
||
row.animator_play(cx, ids!(enter.on));
|
||
self.stagger_idx += 1;
|
||
} else { cx.stop_timer(self.stagger_timer.take().unwrap()); }
|
||
```
|
||
|
||
Rows rest at `opacity 0` + `scale 0.97` (never 0) and enter with the strong ease-out at 0.3s.
|
||
Never replay on back-navigation; never stagger a list the user reopens tens of times a day.
|
||
|
||
---
|
||
|
||
## 6. Reduced motion switch
|
||
|
||
```rust
|
||
// app-level flag (theme/settings)
|
||
pub reduce_motion: bool,
|
||
|
||
fn play_or_cut(&self, cx: &mut Cx, w: &mut impl AnimatorImpl, st: &[LiveId; 2]) {
|
||
if self.reduce_motion { w.animator_cut(cx, st) } // instant, state still lands
|
||
else { w.animator_play(cx, st) }
|
||
}
|
||
```
|
||
|
||
Keep opacity/color feedback under reduced motion; drop slides, scales, overshoot, staggers.
|
||
|
||
---
|
||
|
||
## 7. What NOT to do (verbatim slop patterns)
|
||
|
||
```rust
|
||
// ✗ state change with no transition where one belongs
|
||
self.ui.view(cx, ids!(panel)).set_visible(cx, true);
|
||
|
||
// ✗ commit on press — breaks tap-cancel
|
||
if ui.view(cx, &[id]).finger_down(actions).is_some() { do_the_action(); }
|
||
|
||
// ✗ enter from nothing
|
||
apply: {draw_bg: {scale: 0.0}} // start at 0.95–0.97
|
||
|
||
// ✗ ease-in on anything user-facing
|
||
ease: InQuad // OutQuad/OutQuart/Bezier
|
||
|
||
// ✗ symmetric press/release, or animated press
|
||
// press must be snap(1.0); release 0.1–0.16s eased
|
||
|
||
// ✗ hardcoded per-widget brand hex
|
||
draw_bg +: { color: #7b5cf6 } // hoist to a shared mod/theme token
|
||
```
|