diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b25c8d9d..ebcc3e08 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -93,14 +93,26 @@ fn stripes(p: &egui::Painter, r: Rect, label: Color32) { /// Row height (expression editors grow with their text). fn row_height(row: &Row, rh: f32) -> f32 { match row.kind { - RowKind::Expr { lines, .. } => rh * lines.clamp(1, 8) as f32 + 6.0, + RowKind::Expr { lines, .. } => rh * lines as f32 + 6.0, RowKind::Waveform { .. } => rh * 3.0, _ => rh, } } -/// Insert an expression editor row after every property that has an expression. -fn with_expr_rows(rows: Vec, comp: &Comp, closed: &std::collections::BTreeSet) -> Vec { +/// An inline expression editor grows with its text up to this many lines (After Effects)... +const EXPR_AUTO_LINES: usize = 8; +/// ...and its bottom edge drags it up to this many. +const EXPR_MAX_LINES: usize = 100; + +/// The text of the expression being edited (while its editor has the keyboard). +fn expr_buf_id(prop: u64) -> egui::Id { + egui::Id::new(("expr-buf", prop)) +} + +/// Insert an expression editor row after every property that has an expression: as many lines +/// as its text (the text being edited, so it grows while typing), up to 8, or the height its +/// bottom edge was dragged to. +fn with_expr_rows(rows: Vec, comp: &Comp, tl: &crate::state::TimelineState, ctx: &egui::Context) -> Vec { let mut out = Vec::with_capacity(rows.len()); // Rows come grouped by layer: look each layer up once (a linear search per property row // made twirling open many layers of a big comp quadratic). @@ -108,15 +120,17 @@ fn with_expr_rows(rows: Vec, comp: &Comp, closed: &std::collections::BTreeS for r in rows { let mut extra = None; if let RowKind::Prop { uid } = r.kind - && !closed.contains(&uid) + && !tl.expr_closed.contains(&uid) { if cur.is_none_or(|l| l.id != r.layer) { cur = comp.layer(r.layer); } - extra = cur.and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| Row { - layer: r.layer, - depth: r.depth, - kind: RowKind::Expr { uid, lines: e.text.lines().count().max(1) }, + extra = cur.and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| { + let lines = tl.expr_lines.get(&uid).copied().unwrap_or_else(|| { + let edited = ctx.data(|d| d.get_temp::(expr_buf_id(uid))); + edited.as_deref().unwrap_or(&e.text).split('\n').count().min(EXPR_AUTO_LINES) + }); + Row { layer: r.layer, depth: r.depth, kind: RowKind::Expr { uid, lines: lines.clamp(1, EXPR_MAX_LINES) } } }); } out.push(r); @@ -1542,7 +1556,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = cw.end; // ---- rows. - let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline.expr_closed); + let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline, &ctx); let rh = t.row_h; let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum(); let max_scroll = (total_h - rows_rect.height() + rh).max(0.0); @@ -2108,6 +2122,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { RowKind::Expr { uid, lines } => { let Some(prop) = layer.props.find(*uid) else { continue }; let Some(ex) = prop.expr.clone() else { continue }; + let (buf_id, edit_id) = (expr_buf_id(*uid), egui::Id::new(("expr-edit", uid))); lp.rect_filled(left, 0.0, t.row_alt); let indent = cw.name + 6.0 + 14.0 * row.depth as f32; lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Expression:"), Tokens::ui(11.5), t.text_dim); @@ -2138,9 +2153,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Pressed while the expression is being edited (its text is buffered while // the editor has the focus): the reference will go in at the cursor, // replacing the selection (#284). - let into = ctx.data(|d| d.get_temp::(egui::Id::new(("expr-buf", uid)))).map(|buf| { + let into = ctx.data(|d| d.get_temp::(buf_id)).map(|buf| { let n = buf.chars().count(); - let sel = egui::text_edit::TextEditState::load(&ctx, egui::Id::new(("expr-edit", uid))) + let sel = egui::text_edit::TextEditState::load(&ctx, edit_id) .and_then(|st| st.cursor.char_range()) .map_or([n, n], |r| [r.primary.index.0.min(r.secondary.index.0).min(n), r.primary.index.0.max(r.secondary.index.0).min(n)]); (buf, sel) @@ -2187,8 +2202,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } }); if let Some(text) = picked { - let edit_id = egui::Id::new(("expr-edit", uid)); - let buf_id = egui::Id::new(("expr-buf", uid)); let cur: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); let at = egui::text_edit::TextEditState::load(&ctx, edit_id) .and_then(|st| st.cursor.char_range()) @@ -2205,8 +2218,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_clip_rect(right_clip); gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, Color32::from_rgb(0x1a, 0x1a, 0x1a)); let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0)); - let buf_id = egui::Id::new(("expr-buf", uid)); - let edit_id = egui::Id::new(("expr-edit", uid)); let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); if let Some(after) = ctx.data_mut(|d| d.remove_temp::(expr_refocus_id(*uid))) { // A pick whip just put a reference in: editing goes on in the new text, @@ -2219,8 +2230,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Settings ▸ Scripting & Expressions ▸ Expressions Editor. let sp = app.session.prefs.scripting.clone(); - let resp = super::expr_editor::editor(ui, edit_id, &mut buf, er, &sp, if ex.enabled { expr_col } else { t.text_dim }, (*lines).clamp(1, 8), &t); + let resp = super::expr_editor::editor(ui, edit_id, &mut buf, er, &sp, if ex.enabled { expr_col } else { t.text_dim }, *lines, &t); app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name); + // Its bottom edge drags to set its height in whole lines, as in After Effects. + let edge = Rect::from_min_max(pos2(er.min.x, r.max.y - 4.0), pos2(er.max.x, r.max.y)); + let eresp = ui.interact(edge, egui::Id::new(("expr-edge", uid)), Sense::drag()); + app.auto.add(&format!("timeline.prop.{uid}.expressionHeight"), edge, "Expression field height"); + if eresp.hovered() || eresp.dragged() { + ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + if let Some(py) = eresp.interact_pointer_pos().filter(|_| eresp.dragged()).map(|p| p.y) { + let n = ((py - r.min.y - 6.0) / rh).round().clamp(1.0, EXPR_MAX_LINES as f32); + app.ui.timeline.expr_lines.insert(*uid, n as usize); + } let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl)); if commit { resp.surrender_focus(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d7a7edcd..a7069c2b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -413,6 +413,10 @@ pub struct TimelineState { /// Properties whose inline expression editor is collapsed. #[serde(default)] pub expr_closed: BTreeSet, + /// Inline expression editors given a height (in lines) by dragging their bottom edge; + /// the others grow with their text, up to 8 lines. + #[serde(default)] + pub expr_lines: BTreeMap, /// Scale and Mask Feather properties whose chain link (Constrain Proportions, on by default) /// was turned off. #[serde(default)] @@ -462,6 +466,7 @@ impl Default for TimelineState { graph_reference: false, graph_transform_box: true, expr_closed: BTreeSet::new(), + expr_lines: BTreeMap::new(), unlinked: BTreeSet::new(), columns: default_tl_columns(), source_name: false, diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index d15b05ec..2562f64e 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -211,6 +211,57 @@ fn the_pick_whip_scrolls_the_timeline_at_its_edges() { h.run_steps(3); } +/// #371: the inline expression field grows with the text being typed (it used to keep the +/// saved text's height until committed), and its bottom edge drags to any height in lines. +#[test] +fn the_expression_field_grows_while_editing_and_drags_taller() { + let (mut h, _) = harness(); + let (layer, opacity, transform) = { + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": "Plate", "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(layer); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + let id = format!("timeline.prop.{opacity}.expression"); + let height = |h: &Harness<'_, EffectcraftApp>| h.state().auto.find(&id).unwrap().rect[3]; + let line = h.state().tokens.row_h; + let one = height(&h); + let p = center(&h, &id); + click(&mut h, p, 1); + key(&mut h, Key::End); + for more in ["+ 1", "+ 2"] { + key(&mut h, Key::Enter); + type_text(&mut h, more); + } + h.run_steps(2); + assert!((height(&h) - one - 2.0 * line).abs() < 0.5, "three lines while editing: {} from {one}", height(&h)); + // Its bottom edge: dragged down 4 lines, then up past the top (one line). + let edge_id = format!("timeline.prop.{opacity}.expressionHeight"); + let drag_edge = |h: &mut Harness<'_, EffectcraftApp>, dy: f32| { + let edge = center(h, &edge_id); + let to = edge + egui::vec2(0.0, dy); + h.input_mut().events.push(Event::PointerMoved(edge)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(edge + (to - edge) * (k as f32 / 6.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + drag_edge(&mut h, 4.0 * line); + assert_eq!(h.state().ui.timeline.expr_lines.get(&opacity), Some(&7)); + assert!((height(&h) - one - 6.0 * line).abs() < 0.5, "{}", height(&h)); + drag_edge(&mut h, -20.0 * line); + assert_eq!(h.state().ui.timeline.expr_lines.get(&opacity), Some(&1)); +} + /// 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 f8a75dca..9f3ee66d 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: the expression pick whip scrolls the Timeline when held at its top or bottom, #362; dropped on one of a property's values it picks that dimension (`position[0]`), #363; 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; dropped on one of a property's values it picks that dimension (`position[0]`), #363; the inline expression field grows while typing and its bottom edge drags it taller or shorter, #371; 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 |