- 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
9.1 KiB
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:
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.
// 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 — 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:
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).
// 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 — 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).
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:
// 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
// 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)
// ✗ 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