makepad-ports/skills/makepad-ui-craft/RECIPES.md
andodeki e09f6bbb51 Add makepad-ui-craft skill pack: Emil Kowalski + Jakub Krehel doctrine translated to Makepad 2.0
- 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
2026-09-01 18:36:57 +00:00

266 lines
9.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.030.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.950.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.10.16s eased
// ✗ hardcoded per-widget brand hex
draw_bg +: { color: #7b5cf6 } // hoist to a shared mod/theme token
```