From 639342a5e7d55f6cfd8ef65be398ccbdbbf64642 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 22:35:15 +0700 Subject: [PATCH] Timeline: the pick whip scrolls the layers at their edges (#362) A pick whip dragged to the top or bottom of the Timeline's layers left them where they were, so a property out of view couldn't be picked. While a pick whip (expression or parent) is held within a row of the top or bottom edge of the layers, or past it, the layers scroll that way, faster the deeper, as in After Effects. Tests: the_pick_whip_scrolls_the_timeline_at_its_edges (ui_inline_edit). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 27 +++++++++++++++- crates/ui-egui/tests/ui_inline_edit.rs | 45 ++++++++++++++++++++++++++ docs/parity.md | 2 +- 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 042718fb..7b1ccb69 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -132,6 +132,20 @@ fn pick_whip_id() -> egui::Id { egui::Id::new("tl-pickwhip") } +/// Autoscroll speed (points per second, up negative) of a drag held at height `y` within +/// `zone` of the top or bottom edge of `rows`, or past it: faster the deeper. +fn edge_scroll_speed(rows: Rect, y: f32, zone: f32) -> f32 { + let zone = zone.min(rows.height() / 2.0).max(1.0); + let depth = if y < rows.min.y + zone { + y - (rows.min.y + zone) + } else if y > rows.max.y - zone { + y - (rows.max.y - zone) + } else { + return 0.0; + }; + (depth / zone).clamp(-3.0, 3.0) * 400.0 +} + /// The text of an expression being edited when its pick whip was pressed, and the selection in /// it (characters): the pick whip's reference goes in there. type WhipInto = (String, [usize; 2]); @@ -1552,7 +1566,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.min(max_scroll); + // A pick whip held near the top or bottom of the layers (or past it) scrolls them, as in + // After Effects. + if ctx.data(|d| d.get_temp::(pick_whip_id())).is_some() + && let Some(ptr) = ctx.input(|i| i.pointer.latest_pos()).filter(|p| rect.x_range().contains(p.x)) + { + let speed = edge_scroll_speed(rows_rect, ptr.y, rh); + if speed != 0.0 { + app.ui.timeline.scroll_y += speed * ctx.input(|i| i.stable_dt).min(0.1); + ctx.request_repaint(); + } + } + app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.clamp(0.0, max_scroll); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None }; diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index a8702ad1..a5e35479 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -128,6 +128,51 @@ fn the_expression_pick_whip_inserts_at_the_cursor_while_editing() { assert_eq!(expr(&h), "transform.rotation"); } +/// #362: an expression pick whip held near the bottom or top of the Timeline's layers scrolls +/// them (After Effects), so a property out of view can be picked. +#[test] +fn the_pick_whip_scrolls_the_timeline_at_its_edges() { + let (mut h, _) = harness(); + let (top, opacity, transform) = { + let s = &mut h.state_mut().session; + for i in 0..40 { + s.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap(); + } + let l = s.execute("layer.newSolid", json!({"name": "Top", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap(); + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone(); + (l, tr.get("opacity").unwrap().uid, tr.uid) + }; + h.state_mut().ui.timeline.open_layers.insert(top); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + let scroll = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.scroll_y; + let rows = h.state().auto.find("timeline.scroll").unwrap().rect; + let from = center(&h, &format!("timeline.prop.{opacity}.pickWhip")); + let (bottom, upper) = (pos2(from.x, rows[1] + rows[3] - 2.0), pos2(from.x, rows[1] + 2.0)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (bottom - from) * (k as f32 / 8.0))); + h.step(); + } + h.run_steps(20); + let down = scroll(&h); + assert!(down > 50.0, "held at the bottom edge the layers scroll up: {down}"); + h.input_mut().events.push(Event::PointerMoved(upper)); + h.run_steps(10); + assert!(scroll(&h) < down, "and at the top edge back down: {} after {down}", scroll(&h)); + h.input_mut().events.push(Event::PointerMoved(pos2(from.x, rows[1] + rows[3] / 2.0))); + h.run_steps(2); + let still = scroll(&h); + h.run_steps(10); + assert_eq!(scroll(&h), still, "away from the edges it stays"); + h.input_mut().events.push(Event::PointerButton { pos: upper, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + /// A click at `p` with `modifiers` held. fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) { h.input_mut().events.push(Event::PointerMoved(p)); diff --git a/docs/parity.md b/docs/parity.md index 4ebdb4b4..499066f0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -166,7 +166,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: concave / self-intersecting `onDraw` fills, real images in `drawImage`, image controls and icon buttons landed in M13.11; ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) | | Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) | | Compositions | ≈ 81% | 3.3 | Mocha-style planar tracks for templates; nested comp markers on the precomp layer bar (Preserve frame rate / resolution when nested and nested comps showing nothing past their end landed in M4.9–M4.10; Pre-compose keeping the comp's settings and New Comp from Selection's dialog in M3.12; CMP-7: Essential Graphics mirrored and linked properties landed in M13.11; Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | -| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (G2: an angle's revolutions scrub and take typing in Effect Controls, the Timeline and Properties, #93; puppet pin recording with Record Options landed in M13.1; pin selection, rotate/scale handles, nulls for pins and Follow-Through in M13.14–M13.16), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | +| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (G2: the expression pick whip scrolls the Timeline when held at its top or bottom, #362; an angle's revolutions scrub and take typing in Effect Controls, the Timeline and Properties, #93; puppet pin recording with Record Options landed in M13.1; pin selection, rotate/scale handles, nulls for pins and Follow-Through in M13.14–M13.16), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | | Text | ≈ 96% | 0.3 | no extruded strokes (G2: the font menus list every installed family with its own styles — before, only a project asking for a font read the system fonts — and `text.fonts` / MCP `list_fonts` list them for agents; M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 98% | 0.1 | No shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (M13.30: Render Queue, analyses and Content-Aware Fill render on each job worker's own WebGPU device in passes, particles and Advanced 3D read back under keys, layer buffers in the browser's disk cache; browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) | | 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 |