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

9.1 KiB
Raw Permalink Blame History

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.030.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.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