makepad-ports/plans/004-koboyo-chrome.md
andodeki 3cfeaa289c Add implementation plans for makepad-ui-craft (Emil's improve-animations plan format)
plans/README.md: index, execution order 000->005, executor ground rules (test gate, DSL landmines)
000: shared craft crate (easing/duration/color tokens, Pressable, CraftToast, motion math + units)
001: HIGH press contract - feedback on down, commit on up-if-over, 0.96 scale (all 3 crates)
002: MEDIUM toast enter/exit, interruptible, reduced-motion aware (all 3 crates)
003: MEDIUM insurance sheet momentum projection + velocity-handoff settle, rubber-band
004: MEDIUM koboyo drawer slide, origin-aware popovers, animated camera fit
005: LOW polish - color tokens, concentric radii, real icons, tabular figures
2026-09-01 18:50:05 +00:00

5.4 KiB
Raw Permalink Blame History

004 — Koboyo chrome: drawer slide, origin-aware popovers, animated camera-fit

  • Status: TODO
  • Commit: d50006e
  • Severity: MEDIUM
  • Category: Physicality & origin / missed opportunities
  • Estimated scope: koboyo/src/app.rs (~120 lines), koboyo/src/model.rs (+~40 lines), koboyo/src/canvas_view.rs (camera tween step), tests

Problem

// koboyo/src/app.rs:608 — current: the 112-tool drawer pops
fn set_drawer(&mut self, cx: &mut Cx, open: bool) {
    self.drawer_open = open;
    self.ui.view(cx, ids!(drawer)).set_visible(cx, open);
    if open {
        self.ui.text_input(cx, ids!(search_input)).set_text(cx, "");
        self.rebuild_drawer(cx);
        let area = self.ui.text_input(cx, ids!(search_input)).area();
        cx.set_key_focus(area);   // focus lands before the surface visually exists
    }
}
// koboyo/src/app.rs:601 — current: five popover panels pop from nothing
self.ui.view(cx, ids!(fly_shapes)).set_visible(cx, false);
self.ui.view(cx, ids!(fly_connect)).set_visible(cx, false);
self.ui.view(cx, ids!(menu_panel)).set_visible(cx, false);
self.ui.view(cx, ids!(exp_panel)).set_visible(cx, false);
self.ui.view(cx, ids!(view_panel)).set_visible(cx, false);
// koboyo/src/model.rs:407 and :722 — current: button-triggered camera jumps snap
pub fn reset_zoom(&mut self) {  }
pub fn zoom_to_fit(&mut self, viewport: (f64, f64)) -> bool {  }

Wheel/pinch zoom and pan are 1:1 and must stay instant; but Reset/Fit are occasional, button-triggered spatial jumps — snapping teleports the user's mental map.

Target

  1. Drawer (drawer at app.rs:268): slide + fade via a slide instance, enter Forward {duration: 0.25} ease: mod.craft.ease_sheet (Bezier {cp0: 0.32, cp1: 0.72, cp2: 0.0, cp3: 1.0}), exit Forward {duration: 0.18} OutQuad, same edge both directions. set_key_focus moves to after the enter starts (same frame is fine — after animator_play, not before set_visible).
  2. Popovers (fly_shapes, fly_connect, menu_panel, exp_panel, view_panel): enter 0.18s, scale 0.97→1.0 + fade 0→1, ease: mod.craft.ease_out_strong, SDF scale anchored at the edge nearest the triggering rail button (origin-aware: anchor point in the shader is the rail-side edge midpoint, not rect_size*0.5). Exit 0.12s fade OutQuad. Never start from scale 0.
  3. Camera fit/reset: tween the camera (pan + zoom together) from current to target over 0.25s with Ease.ExpDecay {d1: 0.82, d2: 0.97} evaluated in Rust (Ease::map(t)), stepped by NextFrame in canvas_view.rs. Interpolate zoom geometrically (z = z0·(z1/z0)^k) and pan linearly in content space so the motion doesn't swim. Any gesture (pointer down, wheel) cancels the tween instantly and takes over 1:1. Under reduce_motion: jump (current behavior).
  4. Rail buttons already gain minimal press feedback in plan 001 — no double-animation here.

Repo conventions to follow

  • Camera math lives in model.rs (zoom_at_keeps_anchor_stationary test style); the tween targets should be computed by the existing fit_to_bbox/reset_zoom into a pending target rather than mutating live camera state directly.
  • canvas_view.rs already owns gesture routing + redraw; put the NextFrame step there, following its existing pan/zoom apply path.
  • Dynamic widget lookups with &[id] (README bug 1); custom-widget borrows via .widget(cx, …) (bug 5).

Steps

  1. model.rs: add CameraTween {from: Cam, to: Cam, start: f64} + pub fn camera_tween_eval(tw, t) -> Cam (geometric zoom, content-space pan) + units: endpoint equality at t≥1, anchor stability for pure-zoom tweens, cancel semantics.
  2. canvas_view.rs: route Fit/Reset through the tween; NextFrame step; cancel on any Hit::FingerDown/scroll; keep instant path under reduce_motion.
  3. app.rs: drawer animator track + focus reorder; popover animator tracks with per-panel anchor; replace the five set_visible lines with animator_play + deferred hide (0.120.18s cleanup timer, as in the toast pattern).
  4. UI tests: drawer open → search input focused and a row clickable after ≤ 0.3s; zoom-to-fit with content → camera equals fit target after ≤ 0.5s (assert model camera, not pixels); popover open/close leaves no invisible-but-hit-testable panel (README bug 6/8 — center-clickability of what's underneath after close).

Boundaries

  • Do NOT smooth wheel/pinch zoom, pan, draw, lasso, or eraser — direct manipulation stays 1:1.
  • Do NOT change drawer contents, search, recents, or the 112-tool registry.
  • Do NOT animate the canvas dot-grid shader.
  • If popover anchoring requires layout info not available at draw time, anchor to the rail-side edge center and report — don't invent trigger-position plumbing.

Verification

  • Mechanical: cargo test -p makepad-example-koboyo --lib --test integration (29+14 + new), MAKEPAD=headless … --test ui (8 + new; ~183 s budget — keep new UI tests ≤ 2).
  • Feel check: open the drawer — it slides from its edge, search is focused, typing works immediately; open a rail popover — it grows out of the rail, not from screen center; press Fit — the canvas glides to frame the content and a mid-glide drag grabs it instantly.
  • Done when: grep -n "set_visible" koboyo/src/app.rs shows no drawer/popover pops (toast handled by plan 002), and the matrix is green.