Timeline: the expression field grows while typing and drags taller (#371)

The inline expression field took its height from the saved
expression, so lines typed into it ran over the rows below until the
expression was committed.

Its height now comes from the text being typed (up to 8 lines, as
before), and its bottom edge drags it to any height in whole lines, as
in After Effects; the dragged height is kept per property in the
Timeline state (`expr_lines`).

Tests: the_expression_field_grows_while_editing_and_drags_taller
(ui_inline_edit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-09 22:36:13 +07:00
parent 6fadae2818
commit 0171eb1e2e
4 changed files with 95 additions and 17 deletions

View File

@ -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<Row>, comp: &Comp, closed: &std::collections::BTreeSet<u64>) -> Vec<Row> {
/// 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<Row>, comp: &Comp, tl: &crate::state::TimelineState, ctx: &egui::Context) -> Vec<Row> {
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<Row>, 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::<String>(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::<String>(egui::Id::new(("expr-buf", uid)))).map(|buf| {
let into = ctx.data(|d| d.get_temp::<String>(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::<usize>(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();

View File

@ -413,6 +413,10 @@ pub struct TimelineState {
/// Properties whose inline expression editor is collapsed.
#[serde(default)]
pub expr_closed: BTreeSet<u64>,
/// 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<u64, usize>,
/// 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,

View File

@ -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));

View File

@ -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 |