Files
effectcraft/crates/ui-egui/src/panels/timeline.rs
T

6037 lines
274 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Timeline panel.
//!
//! Left: current-time display, search, comp switches, then per layer A/V features (video, audio,
//! solo, lock), label, number, name, switches (shy, collapse, quality, fx, frame blend, motion
//! blur, adjustment, 3D), mode, track matte and parent, with twirl-down property trees (stopwatch,
//! keyframe navigator, scrubbable values). Right: time navigator, ruler with work area and cache
//! bar, layer duration bars, keyframes, the CTI, and the value graph editor.
use effectcraft_engine::color::BlendMode;
use effectcraft_engine::keyframe::{Interp, Value};
use effectcraft_engine::project::{
Comp, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, Project, PropGroup, Property,
};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::time::Tick;
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Vec2, pos2, vec2};
use serde_json::json;
use std::sync::Arc;
use crate::i18n::{tr, tr_args};
use crate::icons::{self, Icon};
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
mod marquee;
#[derive(Clone, Debug, PartialEq)]
enum RowKind {
Layer,
Group {
uid: u64,
name: String,
open: bool,
has_children: bool,
fx: Option<bool>,
/// Eye switch (layer styles).
eye: Option<bool>,
},
Prop {
uid: u64,
},
/// Inline expression editor under a property with an expression.
Expr {
uid: u64,
lines: usize,
},
/// Audio > Waveform of a footage layer with audio (drawn from the item's peak summary).
Waveform {
item: u64,
},
}
/// Synthetic group uid for a layer's Audio > Waveform twirl (never a real property uid).
const WAVE_BIT: u64 = 1 << 60;
/// Synthetic group uid for the Geometry Options row of a 3D text or shape layer in a Classic 3D
/// comp, which only offers "Change Renderer…" (never a real property uid).
const GEOM_BIT: u64 = 1 << 59;
/// Classic 3D comps don't extrude: a 3D text or shape layer's Geometry Options row offers to
/// change the renderer instead, as in After Effects.
fn classic_geometry_row(comp: &Comp, l: &Layer) -> bool {
comp.renderer == effectcraft_engine::project::Renderer::Classic3D && l.is_3d() && matches!(l.source, LayerSource::Text | LayerSource::Shape)
}
/// Background of the selected layer's name cell.
const SELECTED_NAME_BG: Color32 = Color32::from_rgb(0xa6, 0xa6, 0xa6);
/// A layer bar's fill: the label colour muted toward grey (a little brighter when selected).
pub(crate) fn bar_color(label: Color32, selected: bool) -> Color32 {
let k = if selected { 0.62 } else { 0.45 };
let g = 0x3a as f32;
let mix = |c: u8| (c as f32 * k + g * (1.0 - k)).round() as u8;
Color32::from_rgb(mix(label.r()), mix(label.g()), mix(label.b()))
}
/// How far a layer bar's trim zones reach past its ends and into it, in points. After Effects'
/// zones straddle each end, so the pointer needn't land inside the bar.
const TRIM_OUTSIDE: f32 = 4.0;
const TRIM_INSIDE: f32 = 5.0;
/// A middle-drag in the timeline pans it (`hand_tool_pan`, #2854): a middle press here starts no
/// drag, so it can't move a bar or a key. A widget that takes a drag on press was given it this frame,
/// before anything here ran. Handing that drag to an id nothing uses and ending it means no
/// widget sees it start or stop.
fn ignore_middle_press(ui: &egui::Ui, rect: Rect) {
if ui.rect_contains_pointer(rect) && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Middle) && !i.pointer.primary_down()) {
ui.ctx().set_dragged_id(egui::Id::unique("tl-middle-press"));
ui.ctx().stop_dragging();
}
}
/// A layer bar's body (drag to move) and its In/Out trim zones. Short bars keep their middle
/// third as body; a bar too short for that is all trim zones (the Out one on top).
fn trim_zones(bar: Rect) -> (Rect, Rect, Rect) {
let inside = TRIM_INSIDE.min(bar.width() / 3.0).max(0.0);
let in_r = Rect::from_min_max(pos2(bar.min.x - TRIM_OUTSIDE, bar.min.y), pos2(bar.min.x + inside, bar.max.y));
let out_r = Rect::from_min_max(pos2(bar.max.x - inside, bar.min.y), pos2(bar.max.x + TRIM_OUTSIDE, bar.max.y));
(bar.shrink2(vec2(inside, 0.0)), in_r, out_r)
}
/// The trim cursor over a layer bar's ends: the horizontal resize arrow.
fn trim_cursor(ctx: &egui::Context) {
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
/// Darken part `r` of a layer bar and stripe it diagonally in its label colour (where the layer
/// runs past its source). Only the visible part is drawn.
fn stripes(p: &egui::Painter, r: Rect, label: Color32) {
let v = r.intersect(p.clip_rect());
if !v.is_positive() {
return;
}
let p = p.with_clip_rect(v);
p.rect_filled(v, 0.0, Color32::from_black_alpha(90));
let h = v.height();
let mut x = v.min.x - h;
while x < v.max.x {
p.line_segment([pos2(x, v.max.y), pos2(x + h, v.min.y)], Stroke::new(1.0, label.gamma_multiply(0.7)));
x += 6.0;
}
}
/// Row height (expression editors grow with their text).
fn row_height(row: &Row, rh: f32) -> f32 {
match row.kind {
RowKind::Expr { lines, .. } => rh * lines as f32,
RowKind::Waveform { .. } => rh * 3.0,
_ => rh,
}
}
/// 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::unique(("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).
let mut cur: Option<&Layer> = None;
for r in rows {
let mut extra = None;
if let RowKind::Prop { uid } = r.kind
&& !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| {
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);
out.extend(extra);
}
out
}
/// A pick-whip drag in progress: (kind, layer, prop uid, start point). The kinds are the
/// layer's parent pick whip, an expression's, a property's (Parent & Link column) and the layer's
/// track matte pick whip.
type PickWhip = (u8, u64, u64, Pos2);
const WHIP_PARENT: u8 = 0;
const WHIP_EXPR: u8 = 1;
const WHIP_PROP: u8 = 2;
const WHIP_MATTE: u8 = 3;
/// The `egui::Id` under which a pick-whip drag in progress ([`PickWhip`]) is kept during the
/// drag.
fn pick_whip_id() -> egui::Id {
egui::Id::unique("tl-pickwhip")
}
/// The layers a layer pick whip (parent, track matte) links, taken as its drag starts.
fn whip_layers_id() -> egui::Id {
egui::Id::unique("tl-pickwhip-layers")
}
/// The automation id, label and hover hint of a pick whip.
struct WhipText<'a> {
auto: &'a str,
label: &'a str,
hint: &'a str,
}
/// A pick whip at `rect`: dragging it starts a pick whip of `kind` from `layer`'s `prop` (0 for
/// the layer's own), drawn and dropped at the end of [`show`].
fn pick_whip(
app: &mut EffectcraftApp,
ui: &egui::Ui,
p: &egui::Painter,
rect: Rect,
(kind, layer, prop): (u8, u64, u64),
text: WhipText<'_>,
) -> egui::Response {
let WhipText { auto, label, hint } = text;
let resp = ui.interact(rect, egui::Id::unique(("pick-whip", kind, layer, prop)), Sense::drag()).on_hover_text(hint);
icons::paint(p, rect.shrink(1.0), Icon::PickWhip, if resp.hovered() || resp.dragged() { app.tokens.text } else { app.tokens.text_dim });
app.auto.add(auto, rect, label);
if resp.drag_started() {
let state: PickWhip = (kind, layer, prop, rect.center());
let selected = &app.session.state.selected_layers;
let layers = match (kind, app.session.active_comp()) {
(WHIP_MATTE, Some(comp)) => video_targets(comp, LayerId(layer), selected),
_ => edited_layers(LayerId(layer), selected),
};
ui.ctx().data_mut(|d| {
d.insert_temp(pick_whip_id(), state);
d.insert_temp(whip_layers_id(), layers);
});
}
resp
}
/// Properties shown outside the Timeline (Effect Controls) that a pick whip can be dropped on:
/// (row, layer, prop uid, its value fields per dimension), and the pass they were shown in.
type WhipTargets = (u64, Vec<(Rect, u64, u64, Vec<Rect>)>);
/// The `egui::Id` of the drop targets other panels register for a pick-whip drag
/// ([`whip_target`]); [`show`] clears it when the whip is released or dropped.
fn whip_targets_id() -> egui::Id {
egui::Id::unique("tl-pickwhip-targets")
}
/// While a pick whip is being dragged, `layer`'s property `prop` shown at `row` in another panel
/// is a target too; `dims` are its value fields, one per dimension.
pub(crate) fn whip_target(ctx: &egui::Context, row: Rect, layer: u64, prop: u64, dims: Vec<Rect>) {
if ctx.data(|d| d.get_temp::<PickWhip>(pick_whip_id())).is_none() {
return;
}
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| {
let targets = d.get_temp_mut_or_default::<WhipTargets>(whip_targets_id());
if targets.0 != pass {
*targets = (pass, vec![]);
}
targets.1.push((row, layer, prop, dims));
});
}
/// 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
}
/// Autoscroll speed (points per second, left negative) of a ruler scrub held at `x`, past the
/// left (`x0`) or right (`x1`) end of the visible time span: faster the further past.
fn edge_scroll_speed_x(x0: f32, x1: f32, x: f32) -> f32 {
let depth = if x < x0 {
x - x0
} else if x > x1 {
x - x1
} else {
return 0.0;
};
(depth / 40.0).clamp(-3.0, 3.0) * 400.0 + depth.signum() * 100.0
}
/// The timeline's first visible time (`start`, showing `span` seconds of a `duration` comp)
/// paged so the CTI at `cti` is in view: unchanged while it is, else the page that starts at
/// the CTI (as After Effects pages the Timeline during a preview).
fn page_to_cti(start: f64, span: f64, duration: f64, cti: f64) -> f64 {
if !(start.is_finite() && span.is_finite() && cti.is_finite()) || (cti >= start && cti <= start + span) {
return start;
}
cti.min(duration - span).max(0.0)
}
/// The visible span's start (seconds) that puts `cti` in its middle, kept inside the comp.
fn centered_on(span: f64, duration: f64, cti: f64) -> f64 {
if !(span.is_finite() && duration.is_finite() && cti.is_finite()) {
return 0.0;
}
(cti - span / 2.0).min(duration - span).max(0.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]);
/// The `egui::Id` under which the expression text captured by an expression pick whip
/// ([`WhipInto`]) is held for property `prop` until that editor is drawn again.
fn whip_into_id(prop: u64) -> egui::Id {
egui::Id::unique(("tl-pickwhip-into", prop))
}
/// An expression editor to take the keyboard when it is next drawn: its property, the text to
/// select as counts of characters before the end (so it fits the expression as it will be then)
/// and the pass it was asked for in. After a pick whip into the expression being edited, the
/// cursor goes just after the reference; after an Alt-click on a stopwatch adds an expression,
/// its whole text is selected, so typing replaces it.
#[derive(Clone, Copy, Debug)]
struct ExprFocus {
prop: u64,
select: [usize; 2],
pass: u64,
}
/// The `egui::Id` of the pending [`ExprFocus`]: which expression editor takes the keyboard on
/// its next draw, and what to select in it.
fn expr_focus_id() -> egui::Id {
egui::Id::unique("tl-expr-focus")
}
fn focus_expression(ctx: &egui::Context, prop: u64, select: [usize; 2]) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(expr_focus_id(), ExprFocus { prop, select, pass }));
}
#[derive(Clone, Debug, PartialEq)]
struct Row {
layer: LayerId,
depth: usize,
kind: RowKind,
}
const AV_W: f32 = 76.0;
const LABEL_W: f32 = 20.0;
const NUM_W: f32 = 26.0;
const SW: f32 = 19.0;
/// The Timeline's columns in After Effects' order: (id, header label). Right-click a column
/// header to show or hide them; the name column is always shown.
pub const COLUMNS: [(&str, &str); 13] = [
("av", "A/V Features"),
("keys", "Keys"),
("label", "Label"),
("num", "#"),
("name", "Layer Name"),
("comment", "Comment"),
("switches", "Switches"),
("modes", "Modes"),
("parent", "Parent & Link"),
("in", "In"),
("out", "Out"),
("duration", "Duration"),
("stretch", "Stretch"),
];
/// Is a column shown?
pub fn column_visible(tl: &crate::state::TimelineState, id: &str) -> bool {
match id {
"name" => true,
"modes" => tl.show_modes,
_ => tl.columns.contains(id),
}
}
/// Show or hide a column (`modes` is the Switches/Modes toggle, F4).
pub fn set_column(tl: &mut crate::state::TimelineState, id: &str, on: bool) -> Result<(), String> {
match id {
"name" if on => Ok(()),
"name" => Err("the name column can't be hidden".into()),
"modes" => {
tl.show_modes = on;
Ok(())
}
_ if COLUMNS.iter().any(|c| c.0 == id) => {
if on {
tl.columns.insert(id.to_string());
} else {
tl.columns.remove(id);
}
Ok(())
}
_ => Err(format!("unknown column `{id}` (one of: {})", COLUMNS.map(|c| c.0).join(", "))),
}
}
const KEYS_W: f32 = 56.0;
const COMMENT_W: f32 = 120.0;
const TIME_W: f32 = 72.0;
const STRETCH_W: f32 = 60.0;
const PARENT_W: f32 = 116.0;
const MODES_W: f32 = 92.0 + 112.0;
#[derive(Clone, Copy, Default)]
struct Vis {
av: bool,
keys: bool,
label: bool,
num: bool,
comment: bool,
switches: bool,
modes: bool,
parent: bool,
in_: bool,
out: bool,
duration: bool,
stretch: bool,
}
impl Vis {
fn of(tl: &crate::state::TimelineState) -> Vis {
let v = |id| column_visible(tl, id);
Vis {
av: v("av"),
keys: v("keys"),
label: v("label"),
num: v("num"),
comment: v("comment"),
switches: v("switches"),
modes: v("modes"),
parent: v("parent"),
in_: v("in"),
out: v("out"),
duration: v("duration"),
stretch: v("stretch"),
}
}
/// Width of every visible column but the name.
fn fixed(&self) -> f32 {
let w = |on: bool, w: f32| if on { w } else { 0.0 };
w(self.av, AV_W)
+ w(self.keys, KEYS_W)
+ w(self.label, LABEL_W)
+ w(self.num, NUM_W)
+ w(self.comment, COMMENT_W)
+ w(self.switches, SW * 8.0 + 6.0)
+ w(self.modes, MODES_W)
+ w(self.parent, PARENT_W)
+ w(self.in_, TIME_W)
+ w(self.out, TIME_W)
+ w(self.duration, TIME_W)
+ w(self.stretch, STRETCH_W)
}
}
struct Cols {
vis: Vis,
av: f32,
keys: f32,
label: f32,
num: f32,
name: f32,
/// End of the name column.
name_end: f32,
comment: f32,
switches: f32,
mode: f32,
trkmat: f32,
parent: f32,
in_: f32,
out: f32,
duration: f32,
stretch: f32,
end: f32,
}
fn cols(x0: f32, width: f32, vis: Vis) -> Cols {
let name_w = (width - vis.fixed()).max(120.0);
let mut x = x0;
let mut next = |on: bool, w: f32| {
let at = x;
if on {
x += w;
}
at
};
let av = next(vis.av, AV_W);
let keys = next(vis.keys, KEYS_W);
let label = next(vis.label, LABEL_W);
let num = next(vis.num, NUM_W);
let name = next(true, name_w);
let comment = next(vis.comment, COMMENT_W);
let switches = next(vis.switches, SW * 8.0 + 6.0);
let mode = next(vis.modes, 92.0);
let trkmat = next(vis.modes, 112.0);
let parent = next(vis.parent, PARENT_W);
let in_ = next(vis.in_, TIME_W);
let out = next(vis.out, TIME_W);
let duration = next(vis.duration, TIME_W);
let stretch = next(vis.stretch, STRETCH_W);
let end = next(true, 0.0);
Cols { vis, av, keys, label, num, name, name_end: name + name_w, comment, switches, mode, trkmat, parent, in_, out, duration, stretch, end }
}
/// Which column a header x position falls in.
fn column_at(cw: &Cols, x: f32) -> Option<&'static str> {
let v = cw.vis;
let spans = [
("av", v.av, cw.av, cw.av + AV_W),
("keys", v.keys, cw.keys, cw.keys + KEYS_W),
("label", v.label, cw.label, cw.label + LABEL_W),
("num", v.num, cw.num, cw.num + NUM_W),
("name", true, cw.name, cw.name_end),
("comment", v.comment, cw.comment, cw.comment + COMMENT_W),
("switches", v.switches, cw.switches, cw.switches + SW * 8.0 + 6.0),
("modes", v.modes, cw.mode, cw.mode + MODES_W),
("parent", v.parent, cw.parent, cw.parent + PARENT_W),
("in", v.in_, cw.in_, cw.in_ + TIME_W),
("out", v.out, cw.out, cw.out + TIME_W),
("duration", v.duration, cw.duration, cw.duration + TIME_W),
("stretch", v.stretch, cw.stretch, cw.stretch + STRETCH_W),
];
spans.into_iter().find(|(_, on, a, b)| *on && x >= *a && x < *b).map(|s| s.0)
}
/// The column headers that show an icon or a symbol instead of a name, and what hovering one
/// says: (column, key, short name, explanation), in the order the header draws them. Over a layer
/// switch the key is that switch's name, and the row's switch shows the same text
/// ([`switch_tip`]).
pub const HEADER_TIPS: [(&str, &str, &str, &str); 15] = [
("av", "video", "Video", "Video: shows or hides the layer's picture in the composition"),
("av", "audio", "Audio", "Audio: turns the layer's sound on or off"),
("av", "solo", "Solo", "Solo: while any layer is soloed, only the soloed layers render"),
("av", "lock", "Lock", "Lock: keeps the layer from being selected or changed"),
("label", "label", "Label", "Label: the layer's color label; click a layer's swatch to pick another"),
("num", "num", "#", "Layer number: the layer's place in the stack, counted from the top"),
("switches", "shy", "Shy", "Shy: the layer leaves the Timeline while Hide Shy Layers is on"),
(
"switches",
"collapse",
"Collapse Transformations / Continuously Rasterize",
"Collapse Transformations / Continuously Rasterize: a precomposition's layers render straight into this composition; a vector layer is redrawn sharp at every scale",
),
("switches", "quality", "Quality and Sampling", "Quality and Sampling: Draft renders faster; Best renders with subpixel accuracy and smooth sampling"),
("switches", "fx", "Effect", "Effect: turns the layer's effects on or off"),
("switches", "frameBlend", "Frame Blending", "Frame Blending: smooths footage whose speed or frame rate differs from the composition's"),
("switches", "motionBlur", "Motion Blur", "Motion Blur: blurs the layer along its movement while the composition's Motion Blur is on"),
("switches", "adjustment", "Adjustment Layer", "Adjustment Layer: the layer's effects apply to every layer below it"),
("switches", "threeD", "3D Layer", "3D Layer: the layer moves and turns in depth and reacts to cameras and lights"),
(
"modes",
"preserveTransparency",
"Preserve Transparency",
"Preserve Transparency: the layer shows only over the layers below it, and only as opaque as they are",
),
];
/// The hover text of the layer switch (or the header over it) named `key`.
fn switch_tip(key: &str) -> Option<&'static str> {
HEADER_TIPS.iter().find(|t| t.1 == key).map(|t| t.3)
}
/// Where each shown entry of [`HEADER_TIPS`] sits in the column header row `ch`: (column, key,
/// rect), over the icon the header draws there. Hidden columns give none.
fn header_tip_rects(cw: &Cols, ch: Rect) -> Vec<(&'static str, &'static str, Rect)> {
let v = cw.vis;
let mut out = Vec::new();
for (col, key, _, _) in HEADER_TIPS {
// The icon's place among its column's icons.
let i = HEADER_TIPS.iter().filter(|t| t.0 == col).position(|t| t.1 == key).unwrap_or(0) as f32;
let span = match col {
"av" => v.av.then_some((cw.av + 1.0 + 18.0 * i, 18.0)),
"label" => v.label.then_some((cw.label, LABEL_W)),
"num" => v.num.then_some((cw.num, NUM_W)),
"switches" => v.switches.then_some((cw.switches + SW * i, SW)),
// The `T` over the rows' Preserve Transparency boxes.
"modes" => v.modes.then_some((cw.mode + 72.0, 18.0)),
_ => None,
};
if let Some((x, w)) = span {
out.push((col, key, Rect::from_min_max(pos2(x, ch.min.y), pos2(x + w, ch.max.y))));
}
}
out
}
/// Snap a time to the nearest candidate within `tolerance` (all seconds); Shift-dragging the
/// current-time indicator snaps to keyframes, layer in/out points, markers and the work area.
pub fn snap_time(candidates: &[f64], t: f64, tolerance: f64) -> f64 {
candidates.iter().copied().filter(|c| (c - t).abs() <= tolerance).min_by(|a, b| (a - t).abs().total_cmp(&(b - t).abs())).unwrap_or(t)
}
/// Times the current-time indicator snaps to (comp seconds).
fn snap_candidates(comp: &Comp, rows: &[Row]) -> Vec<f64> {
snap_targets(comp, rows, &[], false)
}
/// [`snap_candidates`] without the `edited` layers' In / Out points; when `moving` them, without
/// their markers and keys either (those move along).
fn snap_targets(comp: &Comp, rows: &[Row], edited: &[u64], moving: bool) -> Vec<f64> {
let skip = |l: LayerId, contents: bool| edited.contains(&l.0) && (moving || !contents);
let mut v = vec![0.0, comp.work_area.0.seconds(), comp.work_area.1.seconds(), comp.duration.seconds()];
v.extend(comp.markers.iter().map(|m| m.time.seconds()));
for l in &comp.layers {
if !skip(l.id, false) {
v.push(l.in_point.seconds());
v.push(l.out_point.seconds());
}
if !skip(l.id, true) {
v.extend(l.markers.iter().map(|m| l.comp_time(m.time).seconds()));
}
}
for r in rows {
if let RowKind::Prop { uid } = r.kind
&& !skip(r.layer, true)
&& let Some(l) = comp.layer(r.layer)
&& let Some(p) = l.props.find(uid)
{
v.extend(p.keys.iter().map(|k| l.comp_time(k.time).seconds()));
}
}
v
}
/// Shift-dragging the work area or one of its ends (#252): the correction (seconds) that puts
/// the nearest of the `moving` times on the current time, a key, a layer in / out point or a
/// marker within 8 px (the work area's own ends are no targets).
fn work_area_snap(app: &EffectcraftApp, comp: &Comp, moving: &[f64], pps: f64) -> f64 {
let (a, b) = (comp.work_area.0.seconds(), comp.work_area.1.seconds());
let mut cands = snap_candidates(comp, &build_rows(app, comp));
cands.retain(|c| (c - a).abs() > 1e-9 && (c - b).abs() > 1e-9);
cands.push(app.session.time().seconds());
let tol = 8.0 / pps.max(1e-6);
moving.iter().map(|t| snap_time(&cands, *t, tol) - t).filter(|d| *d != 0.0).min_by(|x, y| x.abs().total_cmp(&y.abs())).unwrap_or(0.0)
}
/// Timeline horizontal mapping.
#[derive(Clone, Copy, Debug)]
pub(crate) struct TMap {
pub x0: f32,
pub start: f64,
pub pps: f64,
}
impl TMap {
pub fn x(&self, secs: f64) -> f32 {
self.x0 + ((secs - self.start) * self.pps) as f32
}
pub fn t(&self, x: f32) -> f64 {
self.start + (x - self.x0) as f64 / self.pps
}
}
fn disk_bar_range(tm: &TMap, clip: Rect, fd: f64, frames: i64) -> std::ops::Range<i64> {
if clip.width() <= 0.0 || clip.height() <= 0.0 || !fd.is_finite() || fd <= 0.0 || !tm.pps.is_finite() || tm.pps <= 0.0 {
return 0..0;
}
let start = tm.t(clip.min.x) / fd;
let end = tm.t(clip.max.x) / fd;
if !start.is_finite() || !end.is_finite() {
return 0..0;
}
let limit = frames.clamp(0, 100_000) as f64;
(start.clamp(0.0, limit).floor() as i64)..(end.clamp(0.0, limit).ceil() as i64)
}
/// A Timeline ruler tick label for comp frame `fno`: seconds:frames (`00:15f`, AE) or, when
/// the project's Time Display Style is Frames, the frame number the current-time display shows
/// (`00015`, counted from Start Numbering Frames At) (#710).
fn ruler_label(fno: i64, fps_i: i64, settings: &effectcraft_engine::project::ProjectSettings) -> String {
if settings.time_display == effectcraft_engine::project::TimeDisplayStyle::Frames {
return format!("{:05}", fno.saturating_add(settings.frame_start));
}
let fps_i = fps_i.max(1);
if fno >= 60 * fps_i * 60 {
format!("{}:{:02}:{:02}f", fno / (fps_i * 60), (fno / fps_i) % 60, fno % fps_i)
} else {
format!("{:02}:{:02}f", fno / fps_i, fno % fps_i)
}
}
/// The `egui::Id` under which [`show`] records this frame's time mapping as `(x0, start, pps)`,
/// i.e. the [`TMap`] fields it lays the ruler and graph out with.
fn tl_map_id() -> egui::Id {
egui::Id::unique("timeline-map")
}
/// The column right of the time graph: the composition marker bin over the layers' scroll bar.
const GUTTER_W: f32 = 18.0;
/// The most a time ruler zooms in (pixels per second).
const MAX_PPS: f64 = 4000.0;
/// One `=` / `-` step (and one click on the footer's zoom buttons).
pub(crate) const ZOOM_STEP: f64 = 1.5;
/// Pixels per second that fit `comp` in a time graph `width` points wide.
pub(crate) fn fit_pps(width: f32, comp: &Comp) -> f64 {
(width - 10.0).max(1.0) as f64 / comp.duration.seconds().max(0.01)
}
/// Zoom the time ruler to `pps` pixels per second, keeping comp time `at_t` at `at_px` pixels
/// from the ruler's start. Zooming out stops at `fit`, where the whole comp shows (and the
/// ruler keeps fitting it).
pub(crate) fn set_zoom(tl: &mut crate::state::TimelineState, comp: &Comp, pps: f64, fit: f64, at_t: f64, at_px: f64) {
let npps = pps.min(MAX_PPS.max(fit)).max(fit);
if npps <= fit {
tl.pps = None;
tl.start = 0.0;
return;
}
tl.start = (at_t - at_px / npps).clamp(0.0, comp.duration.seconds());
tl.pps = Some(npps);
}
/// `;`: from the whole comp, zoom in to frame level around the CTI; otherwise show the whole comp.
pub fn toggle_frame_zoom(app: &mut EffectcraftApp, ctx: &egui::Context) {
let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::unique("timeline-graph-area"))) else { return };
let Some(comp) = app.session.active_comp_arc() else { return };
let fit = fit_pps(w, &comp);
if app.ui.timeline.pps.is_some_and(|p| p > fit) {
set_zoom(&mut app.ui.timeline, &comp, fit, fit, 0.0, 0.0);
return;
}
let cti = app.session.time().seconds();
set_zoom(&mut app.ui.timeline, &comp, MAX_PPS, fit, cti, w as f64 / 2.0);
}
/// `Shift+;`: show the whole comp; from there, go back to the span it was pressed in.
pub fn toggle_fit_zoom(app: &mut EffectcraftApp, ctx: &egui::Context) {
let tl = &mut app.ui.timeline;
if let Some(pps) = tl.pps {
tl.before_fit = Some((tl.start, pps));
tl.pps = None;
tl.start = 0.0;
return;
}
let Some((start, pps)) = tl.before_fit.take() else { return };
let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::unique("timeline-graph-area"))) else { return };
let Some(comp) = app.session.active_comp_arc() else { return };
set_zoom(&mut app.ui.timeline, &comp, pps, fit_pps(w, &comp), start, 0.0);
}
/// Zoom the time ruler around the CTI.
pub fn zoom(app: &mut EffectcraftApp, ctx: &egui::Context, k: f64) {
let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::unique("timeline-graph-area"))) else { return };
let Some(comp) = app.session.active_comp_arc() else { return };
let fit = fit_pps(w, &comp);
let pps = app.ui.timeline.pps.unwrap_or(fit);
let cti = app.session.time().seconds();
let rel = (cti - app.ui.timeline.start) * pps;
set_zoom(&mut app.ui.timeline, &comp, pps * k, fit, cti, rel);
}
/// Screen point of a layer bar (centre, or at a comp time).
pub fn locate(app: &EffectcraftApp, layer: u64, time: Option<f64>) -> Option<(f32, f32)> {
let e = app.auto.find(&format!("timeline.layer.{layer}.bar"))?;
let y = e.rect[1] + e.rect[3] / 2.0;
match time {
Some(t) => {
// The ruler element's label carries "start,pps".
let tm = app.auto.find("timeline.ruler")?;
let (start, pps) = tm.label.split_once(',').and_then(|(a, b)| Some((a.parse::<f64>().ok()?, b.parse::<f64>().ok()?)))?;
Some((tm.rect[0] + 6.0 + ((t - start) * pps) as f32, y))
}
None => Some((e.rect[0] + e.rect[2] / 2.0, y)),
}
}
/// Start renaming the first selected layer (Enter in the Timeline).
pub fn begin_rename(app: &mut EffectcraftApp, ctx: &egui::Context) {
// A selected effect, mask, shape group or text animator renames before its layer (as in
// After Effects, #1061).
let group = app.session.state.selected_props.iter().rev().find_map(|(lid, uid)| {
let g = app.session.active_comp()?.layer(*lid)?.props.find_group(*uid).filter(|g| g.is_instance())?;
Some((lid.0, g.uid, g.name.clone(), matches!(g.kind, GroupKind::Effect { .. })))
});
if let Some((layer, uid, name, is_fx)) = group {
// An effect in a visible Effect Controls panel renames there, the rest in the Timeline.
if is_fx && app.auto.previous.iter().chain(app.auto.elements.iter()).any(|e| e.id == format!("effectControls.effect.{uid}.name")) {
super::effect_controls::start_rename(ctx, uid, &name);
} else {
// Show the group's row first: the field lives in it.
fn ancestors(g: &PropGroup, uid: u64, out: &mut Vec<u64>) -> bool {
for sg in g.groups() {
if sg.uid == uid || ancestors(sg, uid, out) {
out.push(sg.uid);
return true;
}
}
false
}
let mut open = vec![];
if let Some(l) = app.session.active_comp().and_then(|c| c.layer(LayerId(layer))) {
ancestors(&l.props, uid, &mut open);
}
open.retain(|u| *u != uid);
app.ui.timeline.open_layers.insert(layer);
app.ui.timeline.open_groups.extend(open);
ctx.data_mut(|d| {
d.insert_temp(group_rename_id(), (layer, uid, name));
d.insert_temp(rename_focus_id(), true);
});
}
return;
}
if let Some(l) = app.session.state.selected_layers.first().and_then(|id| app.session.active_comp().and_then(|c| c.layer(*id))) {
start_rename(ctx, l.id.0, &l.name);
}
}
/// What double-clicking a layer's name or bar does, as in After Effects: a precomp opens, footage
/// and solids open in the Layer panel (paint happens there), text is edited in the viewer with the
/// whole text selected. `None`: the name renames, the bar does nothing.
fn double_click_action(layer: &Layer) -> Option<(String, serde_json::Value)> {
match &layer.source {
LayerSource::Comp { item } => Some(("comp.open".into(), json!({"comp": item.0}))),
LayerSource::Footage { .. } | LayerSource::Solid { .. } => Some(("layer.openLayer".into(), json!({"layer": layer.id.0}))),
LayerSource::Text => Some(("text.edit".into(), json!({"layer": layer.id.0}))),
_ => None,
}
}
/// Open the rename field on layer `id`; it takes the keyboard focus on its first frame only.
fn start_rename(ctx: &egui::Context, id: u64, name: &str) {
ctx.data_mut(|d| {
d.insert_temp(egui::Id::unique("tl-rename"), (id, name.to_string()));
d.insert_temp(rename_focus_id(), true);
});
}
/// The Timeline's open group rename field: (layer, group uid, text).
fn group_rename_id() -> egui::Id {
egui::Id::unique("tl-group-rename")
}
/// The `egui::Id` of the one-shot flag that makes the open layer rename field take the keyboard
/// on its first frame (see [`start_rename`]).
fn rename_focus_id() -> egui::Id {
egui::Id::unique("tl-rename-focus")
}
fn group_visible(g: &PropGroup, layer: &Layer) -> bool {
match g.match_id.as_str() {
"masks" | "effects" => !g.children.is_empty(),
"materialOptions" | "geometryOptions" => layer.is_3d(),
"audio" => true,
_ => true,
}
}
fn prop_visible(p: &Property, layer: &Layer) -> bool {
if matches!(p.ui, ParamUi::Hidden) {
return false;
}
// Path Options show only the Path popup until a mask path is chosen.
if let Some(po) = layer.props.group("text/pathOptions")
&& p.match_id != "path"
&& po.get(&p.match_id).is_some_and(|q| q.uid == p.uid)
&& po.get("path").is_some_and(|q| q.value.as_enum() == 0 && q.keys.is_empty())
{
return false;
}
if p.three_d_only && !layer.is_3d() {
return false;
}
if p.two_d_only && layer.is_3d() {
return false;
}
// One-node cameras (and lights with auto-orient off) have no Point of Interest.
if p.match_id == "poi" && (layer.is_camera() || layer.is_light()) && layer.auto_orient != effectcraft_engine::project::AutoOrient::TowardsPointOfInterest {
return false;
}
// Separate Dimensions: X/Y/Z Position replace Position.
if p.match_id == "position" && layer.transform().is_some_and(|tr| tr.get("positionX").is_some() && tr.get("position").is_some_and(|q| q.uid == p.uid)) {
return false;
}
true
}
fn reveal_matches(p: &Property, path_matches: &[&str], kind: &str) -> bool {
match kind {
"animated" => p.is_animated() || p.has_expression(),
"expressions" => p.has_expression(),
_ => path_matches.contains(&p.match_id.as_str()),
}
}
/// The name shown for a layer: its name, or its source's name (Source Name column).
fn display_name(app: &EffectcraftApp, l: &Layer) -> String {
if app.ui.timeline.source_name
&& let Some(item) = l.source.item().and_then(|i| app.session.project.item(i))
{
return item.name.clone();
}
l.name.clone()
}
/// The timeline search (After Effects' search field): rows for the properties and groups of a
/// layer whose names contain `q` (lowercase), each with its enclosing groups. A matching group
/// brings its whole contents.
fn search_rows(l: &Layer, q: &str) -> Vec<Row> {
/// The row for a group in search results: depth, its effect's enabled flag, and its eye
/// switch ([`eye_of`]).
fn group_row(l: &Layer, g: &PropGroup, depth: usize, parent: &PropGroup) -> Row {
let fx = matches!(g.kind, GroupKind::Effect { .. }).then_some(g.enabled);
let eye = eye_of(parent, g);
Row { layer: l.id, depth, kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open: true, has_children: !g.children.is_empty(), fx, eye } }
}
/// Every visible child of `g` at `depth`, groups recursed into whole — a matching group
/// brings its contents. A shape group's inner Contents is elided (as in AE): no row, and
/// its items list at the same depth as the group itself.
fn all(l: &Layer, g: &PropGroup, depth: usize, out: &mut Vec<Row>) {
for c in &g.children {
match c {
// A shape group's inner Contents is elided (as in AE): no row, items at the same depth.
Node::Group(sg) if g.match_id == "group" && sg.match_id == "contents" => all(l, sg, depth, out),
Node::Group(sg) => {
out.push(group_row(l, sg, depth, g));
all(l, sg, depth + 1, out);
}
Node::Prop(p) if prop_visible(p, l) => out.push(Row { layer: l.id, depth, kind: RowKind::Prop { uid: p.uid } }),
_ => {}
}
}
}
/// The children of `g` that match `q`: a name-matching group brings its whole contents
/// (`all`); otherwise the search recurses and keeps only subtrees with hits. A shape
/// group's inner Contents is searched through without a row of its own (as in AE).
fn rec(l: &Layer, g: &PropGroup, depth: usize, q: &str, out: &mut Vec<Row>) {
for c in &g.children {
match c {
// A shape group's inner Contents is elided (as in AE): search through it, no row.
Node::Group(sg) if g.match_id == "group" && sg.match_id == "contents" => rec(l, sg, depth, q, out),
Node::Group(sg) if depth > 1 || group_visible(sg, l) => {
// An effect group also matches the name the row draws, so a search finds
// `高斯模糊` in a Chinese interface and `Gaussian Blur` in an English one.
let hit = sg.name.to_lowercase().contains(q)
|| matches!(&sg.kind, GroupKind::Effect { effect } if crate::i18n::effects::matches(effect, &sg.name, q));
if hit {
out.push(group_row(l, sg, depth, g));
all(l, sg, depth + 1, out);
continue;
}
let mut inner = vec![];
rec(l, sg, depth + 1, q, &mut inner);
if !inner.is_empty() {
out.push(group_row(l, sg, depth, g));
out.extend(inner);
}
}
Node::Prop(p) if prop_visible(p, l) && p.name.to_lowercase().contains(q) => {
out.push(Row { layer: l.id, depth, kind: RowKind::Prop { uid: p.uid } });
}
_ => {}
}
}
}
let mut out = vec![];
rec(l, &l.props, 1, q, &mut out);
out
}
/// The property each reveal key shows (P, S, R, T, A, F, L; `animated` = keyframed or with an
/// expression): (group searched, property match ids).
fn reveal_targets(kind: &str) -> Option<(&'static str, &'static [&'static str])> {
Some(match kind {
"position" => ("transform", &["position", "positionX", "positionY", "positionZ"]),
"scale" => ("transform", &["scale"]),
"rotation" => ("transform", &["rotation", "rotationX", "rotationY", "orientation"]),
"opacity" => ("transform", &["opacity"]),
"anchor" => ("transform", &["anchor"]),
"feather" => ("masks", &["feather"]),
"maskPath" => ("masks", &["path"]),
"maskOpacity" => ("masks", &["opacity"]),
"levels" => ("audio", &["levels"]),
"timeRemap" => ("", &["timeRemap"]),
"animated" | "expressions" => ("", &[]),
_ => return None,
})
}
/// The properties the Timeline shows (revealed rows), as `{layer, prop}`: J / K and Select All
/// Keyframes act on these, as in After Effects.
pub(crate) fn visible_props(app: &EffectcraftApp, comp: &Comp) -> Vec<serde_json::Value> {
build_rows(app, comp)
.iter()
.filter_map(|r| match r.kind {
RowKind::Prop { uid } => Some(json!({"layer": r.layer.0, "prop": uid})),
_ => None,
})
.collect()
}
/// Project the property tree into Timeline rows; explicit search takes precedence over collapse and shortcut reveals.
fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
let tl = &app.ui.timeline;
let search = tl.search.trim().to_lowercase();
let mut rows = Vec::new();
for l in &comp.layers {
if comp.hide_shy && l.switches.shy {
continue;
}
if !search.is_empty() {
// Layers whose name or properties match; matching properties are revealed.
let matches = search_rows(l, &search);
if matches.is_empty() && !display_name(app, l).to_lowercase().contains(&search) {
continue;
}
rows.push(Row { layer: l.id, depth: 0, kind: RowKind::Layer });
rows.extend(matches);
continue;
}
rows.push(Row { layer: l.id, depth: 0, kind: RowKind::Layer });
// Essential Graphics ▸ Solo Supported Properties: every property it can expose.
if app.session.state.essential_solo {
let mut v = vec![];
for (i, c) in l.props.children.iter().enumerate() {
if c.match_id() == effectcraft_engine::project::essential::GROUP {
continue;
}
let g = PropGroup { children: vec![l.props.children[i].clone()], ..PropGroup::new(0, "", "") };
collect_props(&g, &mut v, &|p| prop_visible(p, l) && effectcraft_engine::project::essential::control_type(p).is_some());
}
rows.extend(v.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } }));
continue;
}
if !tl.open_layers.contains(&l.id.0) {
continue;
}
if let Some(kinds) = tl.layer_reveal.get(&l.id.0).filter(|k| !k.is_empty()) {
reveal_rows(app, l, kinds, &mut rows);
continue;
}
let classic_geometry = classic_geometry_row(comp, l);
for c in &l.props.children {
match c {
// The Change Renderer row (after Transform) stands in for the extrusion options.
Node::Group(g) if classic_geometry && g.match_id == "geometryOptions" => {}
Node::Group(g) if group_visible(g, l) => {
let open = tl.open_groups.contains(&g.uid);
rows.push(Row {
layer: l.id,
depth: 1,
kind: RowKind::Group {
uid: g.uid,
name: g.name.clone(),
open,
has_children: !g.children.is_empty(),
fx: None,
eye: (g.match_id == effectcraft_engine::project::styles::GROUP).then_some(g.enabled),
},
});
if open {
push_group(&mut rows, l, g, 2, &tl.open_groups);
// Audio > Waveform > Waveform (footage with audio).
if g.match_id == "audio"
&& let Some(item) = super::waveform::audio_item(&app.session.project, l)
{
let wuid = WAVE_BIT | g.uid;
let wopen = tl.open_groups.contains(&wuid);
rows.push(Row {
layer: l.id,
depth: 2,
kind: RowKind::Group { uid: wuid, name: "Waveform".into(), open: wopen, has_children: true, fx: None, eye: None },
});
if wopen {
rows.push(Row { layer: l.id, depth: 3, kind: RowKind::Waveform { item: item.0 } });
}
}
}
if classic_geometry && g.match_id == "transform" {
rows.push(Row {
layer: l.id,
depth: 1,
kind: RowKind::Group {
uid: GEOM_BIT | l.id.0,
name: "Geometry Options".into(),
open: false,
has_children: false,
fx: None,
eye: None,
},
});
}
}
Node::Prop(p) if prop_visible(p, l) => rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid: p.uid } }),
_ => {}
}
}
}
rows
}
/// Rows of a twirled-open layer under the reveal shortcuts (one, or several added with Shift):
/// masks, effects, the revealed properties in property-tree order, the Reveal Properties
/// selection, then the waveform.
fn reveal_rows(app: &EffectcraftApp, l: &Layer, kinds: &[String], rows: &mut Vec<Row>) {
let tl = &app.ui.timeline;
let has = |k: &str| kinds.iter().any(|r| r == k);
// Groups shown with their whole contents: the revealed properties inside aren't listed again.
let mut shown_groups: Vec<u64> = vec![];
let mut group_rows = |rows: &mut Vec<Row>, g: &PropGroup, fx: bool| {
for g in g.groups() {
shown_groups.push(g.uid);
rows.push(Row {
layer: l.id,
depth: 1,
kind: RowKind::Group {
uid: g.uid,
name: g.name.clone(),
open: tl.open_groups.contains(&g.uid),
has_children: true,
fx: fx.then_some(g.enabled),
eye: None,
},
});
if tl.open_groups.contains(&g.uid) {
push_group(rows, l, g, 2, &tl.open_groups);
}
}
};
if has("masks")
&& let Some(m) = l.masks()
{
group_rows(rows, m, false);
}
if has("effects")
&& let Some(fx) = l.effects()
{
group_rows(rows, fx, true);
}
// PP: paint, Roto Brush and Puppet effects; FF: effects whose plug-in is missing.
for (k, keep) in [
("paint", &(|id: &str| matches!(id, "ec.paint.paint" | "ec.matte.rotobrush" | "ec.distort.puppet")) as &dyn Fn(&str) -> bool),
("missingEffects", &|id: &str| effectcraft_engine::effects::find(id).is_none()),
] {
if has(k)
&& let Some(fx) = l.effects()
{
let mut only = fx.clone();
only.children.retain(|c| matches!(c, Node::Group(g) if matches!(&g.kind, GroupKind::Effect { effect } if keep(effect))));
group_rows(rows, &only, true);
}
}
// AA: Material Options (3D layers).
if has("material")
&& let Some(m) = l.props.sub("materialOptions")
&& l.is_3d()
{
let mut only = PropGroup::new(0, "", "");
only.children.push(Node::Group(m.clone()));
group_rows(rows, &only, false);
}
// P/S/R/T/A/F/L/animated: matching properties, in tree order, each once.
let mut wanted = std::collections::BTreeSet::new();
for kind in kinds {
let Some((group, ids)) = reveal_targets(kind) else { continue };
let root = match group {
"" => Some(&l.props),
"masks" => l.masks(),
g => l.props.sub(g),
};
if let Some(g) = root {
let mut found = vec![];
collect_props(g, &mut found, &|p| prop_visible(p, l) && reveal_matches(p, ids, kind));
wanted.extend(found);
}
}
// SS: the selected properties and groups (as Animation ▸ Reveal Properties shows them).
let selected: std::collections::BTreeSet<u64> = app.session.state.selected_props.iter().filter(|(lid, _)| *lid == l.id).map(|(_, u)| *u).collect();
if has("props") || has("selected") {
let picked: std::collections::BTreeSet<u64> =
if has("props") { tl.reveal_props.iter().copied().chain(selected.iter().copied().filter(|_| has("selected"))).collect() } else { selected };
let reveal_props = &picked;
// Animation ▸ Reveal Properties…: the engine picked the uids.
fn groups_in<'a>(g: &'a PropGroup, set: &std::collections::BTreeSet<u64>, out: &mut Vec<&'a PropGroup>) {
for sg in g.groups() {
if set.contains(&sg.uid) {
out.push(sg);
} else {
groups_in(sg, set, out);
}
}
}
let mut groups = vec![];
groups_in(&l.props, reveal_props, &mut groups);
for g in groups {
if shown_groups.contains(&g.uid) {
continue;
}
let open = tl.open_groups.contains(&g.uid);
rows.push(Row {
layer: l.id,
depth: 1,
kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open, has_children: !g.children.is_empty(), fx: None, eye: None },
});
if open {
push_group(rows, l, g, 2, &tl.open_groups);
}
shown_groups.push(g.uid);
}
wanted.extend(picked.iter().copied());
}
revealed_rows(rows, l, &wanted, &shown_groups, &tl.open_groups, tl.full_groups.get(&l.id.0));
if has("waveform")
&& let Some(item) = super::waveform::audio_item(&app.session.project, l)
{
rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Waveform { item: item.0 } });
}
}
/// Rows for the revealed properties `shown` of `l`, in tree order. Those inside an effect show
/// under the effect and its groups, as in After Effects (Puppet ▸ Mesh 1 ▸ Deform ▸ Puppet
/// Pin 1 ▸ Position: every pin's Position is told apart, #273); the others (Transform, masks…)
/// on their own. Groups in `skip` already show with their contents; those in `full` (twirled
/// open by hand since the reveal) show all of theirs.
fn revealed_rows(
rows: &mut Vec<Row>,
l: &Layer,
shown: &std::collections::BTreeSet<u64>,
skip: &[u64],
open: &std::collections::BTreeSet<u64>,
full: Option<&std::collections::BTreeSet<u64>>,
) {
struct Tree<'a> {
l: &'a Layer,
wanted: &'a dyn Fn(&Property) -> bool,
skip: &'a [u64],
open: &'a std::collections::BTreeSet<u64>,
full: Option<&'a std::collections::BTreeSet<u64>>,
}
fn holds(g: &PropGroup, wanted: &dyn Fn(&Property) -> bool) -> bool {
g.children.iter().any(|c| match c {
Node::Prop(p) => wanted(p),
Node::Group(sg) => holds(sg, wanted),
})
}
// A group holding revealed properties (twirled as the user left it), then those inside.
fn group(rows: &mut Vec<Row>, t: &Tree<'_>, g: &PropGroup, depth: usize) {
if t.skip.contains(&g.uid) || !holds(g, t.wanted) {
return;
}
let o = t.open.contains(&g.uid);
let fx = matches!(g.kind, GroupKind::Effect { .. }).then_some(g.enabled);
rows.push(Row { layer: t.l.id, depth, kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open: o, has_children: true, fx, eye: None } });
if !o {
return;
}
if t.full.is_some_and(|f| f.contains(&g.uid)) {
push_group(rows, t.l, g, depth + 1, t.open);
return;
}
for c in &g.children {
match c {
Node::Prop(p) if (t.wanted)(p) => rows.push(Row { layer: t.l.id, depth: depth + 1, kind: RowKind::Prop { uid: p.uid } }),
Node::Group(sg) => group(rows, t, sg, depth + 1),
Node::Prop(_) => {}
}
}
}
// Whole groups already emitted above own their properties, including under mixed reveals.
fn props(rows: &mut Vec<Row>, t: &Tree<'_>, g: &PropGroup) {
if t.skip.contains(&g.uid) {
return;
}
for child in &g.children {
match child {
Node::Prop(p) if (t.wanted)(p) => rows.push(Row { layer: t.l.id, depth: 1, kind: RowKind::Prop { uid: p.uid } }),
Node::Group(g) => props(rows, t, g),
_ => {}
}
}
}
let wanted = |p: &Property| shown.contains(&p.uid) && prop_visible(p, l);
let tree = Tree { l, wanted: &wanted, skip, open, full };
for c in &l.props.children {
match c {
Node::Group(fx) if fx.match_id == "effects" => {
for e in fx.groups() {
group(rows, &tree, e, 1);
}
}
Node::Group(g) => props(rows, &tree, g),
Node::Prop(p) if wanted(p) => rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid: p.uid } }),
Node::Prop(_) => {}
}
}
}
/// Uids of the groups that hold property `uid` of `l`, outermost first.
fn enclosing_groups(l: &Layer, uid: u64) -> Vec<u64> {
let path = l.props.path_of(uid).unwrap_or_default();
let mut ids: Vec<u64> = path.split('/').filter_map(|s| s.strip_prefix('@')?.parse().ok()).collect();
ids.pop();
ids
}
/// Twirl open the groups holding `props` of `layer` that sit inside effects, so their rows show
/// (a U reveal, an EE reveal, a puppet pin placed or moved in the viewer).
pub(crate) fn open_effect_paths(app: &mut EffectcraftApp, layer: u64, props: &[u64]) {
let Some(l) = app.session.active_comp().and_then(|c| c.layer(LayerId(layer))) else { return };
let Some(fx) = l.effects().map(|g| g.uid) else { return };
let mut open = vec![];
for p in props {
let path = enclosing_groups(l, *p);
if path.first() == Some(&fx) {
open.extend(path);
}
}
app.ui.timeline.open_groups.extend(open);
}
/// After a reveal shortcut that picks properties by state (EE expressions): those inside effects
/// on `layers` show under their effect, twirled open.
pub(crate) fn open_revealed(app: &mut EffectcraftApp, layers: &[u64], kinds: &[String]) {
for kind in kinds.iter().filter(|k| matches!(k.as_str(), "animated" | "expressions")) {
for id in layers {
let Some(l) = app.session.active_comp().and_then(|c| c.layer(LayerId(*id))) else { continue };
let mut found = vec![];
collect_props(&l.props, &mut found, &|p| prop_visible(p, l) && reveal_matches(p, &[], kind));
open_effect_paths(app, *id, &found);
}
}
}
/// After the viewer edits a property (a puppet pin placed, moved or recorded): when `layer` is
/// twirled open, its groups open, and a U reveal of the layer takes it in, so its keyframes show
/// (#273). A collapsed layer stays collapsed, as in After Effects.
pub fn keep_in_view(app: &mut EffectcraftApp, layer: LayerId, prop: u64) {
if !app.ui.timeline.open_layers.contains(&layer.0) {
return;
}
open_effect_paths(app, layer.0, &[prop]);
let tl = &mut app.ui.timeline;
if tl.layer_reveal.get(&layer.0).is_some_and(|k| k.iter().any(|k| k == "props")) {
tl.reveal_props.insert(prop);
}
}
fn collect_props(g: &PropGroup, out: &mut Vec<u64>, f: &dyn Fn(&Property) -> bool) {
for c in &g.children {
match c {
Node::Prop(p) if f(p) => out.push(p.uid),
Node::Group(g) => collect_props(g, out, f),
_ => {}
}
}
}
/// The rows for the children of `g` at `depth`: groups recurse into when their twirl-down is
/// in `open`, text animators list their selectors and properties directly (as in AE), and a
/// shape group's inner Contents (AE's elided `ADBE Vectors Group`) is skipped so its items
/// list directly under the group.
/// The eye switch of group `g` inside `parent`, as in After Effects: layer styles (not Blending
/// Options) and every item in a shape layer's Contents (#959), which the renderer skips when off.
fn eye_of(parent: &PropGroup, g: &PropGroup) -> Option<bool> {
let style = parent.match_id == effectcraft_engine::project::styles::GROUP && g.match_id != effectcraft_engine::project::styles::BLENDING;
let shape_item = parent.match_id == "contents" && g.match_id != "transform";
(style || shape_item).then_some(g.enabled)
}
fn push_group(rows: &mut Vec<Row>, l: &Layer, g: &PropGroup, depth: usize, open: &std::collections::BTreeSet<u64>) {
for c in &g.children {
match c {
// Text animators list their selectors and properties directly (as in AE).
Node::Group(sg) if g.match_id == "animator" && matches!(sg.match_id.as_str(), "selectors" | "properties") => {
push_group(rows, l, sg, depth, open);
}
// A shape group's inner Contents (AE's elided `ADBE Vectors Group`): the group's items
// (path, fill/stroke, Transform) list directly under it, with no "Contents" row.
Node::Group(sg) if g.match_id == "group" && sg.match_id == "contents" => {
push_group(rows, l, sg, depth, open);
}
Node::Group(sg) => {
let o = open.contains(&sg.uid);
let fx = matches!(sg.kind, GroupKind::Effect { .. }).then_some(sg.enabled);
let eye = eye_of(g, sg);
rows.push(Row {
layer: l.id,
depth,
kind: RowKind::Group { uid: sg.uid, name: sg.name.clone(), open: o, has_children: !sg.children.is_empty(), fx, eye },
});
if o {
push_group(rows, l, sg, depth + 1, open);
}
}
Node::Prop(p) if prop_visible(p, l) => rows.push(Row { layer: l.id, depth, kind: RowKind::Prop { uid: p.uid } }),
_ => {}
}
}
}
fn layer_icon(l: &Layer, project: &effectcraft_engine::project::Project) -> Icon {
match &l.source {
LayerSource::Text => Icon::TextLayer,
LayerSource::Shape => Icon::ShapeLayer,
LayerSource::Null => Icon::Null,
LayerSource::Camera => Icon::Camera,
LayerSource::Light { .. } => Icon::Light,
LayerSource::Model { .. } | LayerSource::Primitive { .. } => Icon::Cube,
LayerSource::Comp { .. } => Icon::Comp,
LayerSource::Solid { .. } => {
if l.switches.adjustment {
Icon::Adjustment
} else {
Icon::Solid
}
}
LayerSource::Footage { item } => match project.item(*item).map(|i| i.type_name()) {
Some("Image") | Some("Image Sequence") => Icon::Image,
Some("Audio") => Icon::Audio,
_ => Icon::Footage,
},
}
}
/// Layers being dragged to a new place in the stack (timeline rows).
#[derive(Clone)]
struct LayerDrag {
comp: ItemId,
owner: egui::Id,
layers: Vec<u64>,
}
fn layer_drag_id() -> egui::Id {
egui::Id::unique("tl-layer-drag")
}
/// An effect, mask, shape item or text animator being dragged to a new place among its
/// siblings (#2210).
#[derive(Clone)]
struct GroupDrag {
comp: ItemId,
owner: egui::Id,
layer: LayerId,
uid: u64,
}
fn group_drag_id() -> egui::Id {
egui::Id::unique("tl-group-drag")
}
/// A keyframe drag in progress: where the pointer started, the grabbed key's comp time
/// (seconds), how many frames the selected keys have moved so far and, for a Quick Offset
/// (Ctrl/Cmd+Alt), how many layers it staggers.
#[derive(Clone, Copy, Debug)]
struct KeyDrag {
origin_x: f32,
anchor: f64,
applied: i64,
quick: Option<usize>,
}
/// The `egui::Id` of the keyframe drag in progress ([`KeyDrag`]): its start, the grabbed key's
/// time and how far the selection has moved so far.
fn key_drag_id() -> egui::Id {
egui::Id::unique("tl-key-drag")
}
/// A Quick Offset drag of selected layer bars (Ctrl/Cmd+Alt, #1012): the bar it started on, where
/// the pointer started, how many layers it staggers and how many frames the last one has moved.
#[derive(Clone, Copy, Debug)]
struct QuickBar {
layer: LayerId,
origin_x: f32,
layers: usize,
applied: i64,
}
/// The `egui::Id` of a Quick Offset drag of layer bars in progress ([`QuickBar`]).
fn quick_bar_id() -> egui::Id {
egui::Id::unique("tl-quick-bar")
}
/// How many layers a Quick Offset of `layers` staggers (#1012): the unlocked ones, each once, if
/// there are at least two.
fn quick_offset_layers(comp: &Comp, layers: impl IntoIterator<Item = LayerId>) -> Option<usize> {
let mut seen: Vec<LayerId> = vec![];
for id in layers {
if !seen.contains(&id) && comp.layer(id).is_some_and(|l| !l.switches.locked) {
seen.push(id);
}
}
(seen.len() >= 2).then_some(seen.len())
}
/// A Quick Offset's feedback: the last layer's move and the step from one layer to the next.
fn quick_offset_note(frames: i64, layers: usize) -> String {
let per = frames as f64 / layers.saturating_sub(1).max(1) as f64;
format!("{}: {:+} f {}: {:+.2} f", tr("Total Offset"), frames, tr("Per Layer"), per)
}
/// A Timeline drag's readout by the pointer (After Effects shows it in the Info panel).
fn drag_tip(ctx: &egui::Context, t: &Tokens, ptr: Pos2, note: String) {
let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("tl-key-drag-tip")));
let g = fg.layout_no_wrap(note, Tokens::ui(11.5), t.text);
let r = Rect::from_min_size(ptr + vec2(14.0, 14.0), g.size() + vec2(10.0, 6.0));
fg.rect_filled(r, 3.0, t.panel_bg);
fg.rect_stroke(r, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
fg.galley(r.min + vec2(5.0, 3.0), g, t.text);
}
/// A switch pressed and dragged over other rows: the switch, the state the press gave it, and
/// the undo step the drag folds into.
#[derive(Clone, Debug)]
struct SwitchDrag {
switch: &'static str,
value: bool,
merge: String,
}
/// The `egui::Id` of the switch press-and-drag in progress ([`SwitchDrag`]): the switch, the
/// state the press gave it and the undo step the drag folds into.
fn switch_drag_id() -> egui::Id {
egui::Id::unique("tl-switch-drag")
}
/// The press-and-drag gesture of the Timeline's switches and stopwatches (`switch`, on `row`,
/// now `on`, pressed through `resp`): the press gives it the opposite state, and dragging on
/// gives every `switch` the pointer passes that same state, in one undo step, whatever its own
/// (After Effects). The state to set and the undo step to fold it into, when it changes now.
fn switch_gesture(ui: &egui::Ui, resp: &egui::Response, row: Rect, switch: &'static str, on: bool) -> Option<(bool, String)> {
if resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) {
let merge = format!("tl-switch-{}", ui.input(|i| i.time));
ui.data_mut(|d| d.insert_temp(switch_drag_id(), SwitchDrag { switch, value: !on, merge: merge.clone() }));
return Some((!on, merge));
}
let d = ui.data(|d| d.get_temp::<SwitchDrag>(switch_drag_id())).filter(|d| d.switch == switch && d.value != on)?;
// The heights the pointer moved over since the last frame, so a quick drag skips no row.
let (a, b) = ui.input(|i| i.pointer.interact_pos().map(|p| (p.y - i.pointer.delta().y, p.y)))?;
(a.min(b) < row.max.y && a.max(b) >= row.min.y).then_some((d.value, d.merge))
}
/// The layers an edit made on `layer`'s row applies to (a switch, its label, its blending mode,
/// its parent or track matte, moving its bar): the selection when the row is selected, else that
/// layer alone, as in After Effects (#1260).
fn edited_layers(layer: LayerId, selected: &[LayerId]) -> Vec<u64> {
if selected.contains(&layer) { selected.iter().map(|l| l.0).collect() } else { vec![layer.0] }
}
/// Whether the Timeline shows switch `name`'s cell on `layer`'s row (A/V Features and Switches
/// columns): a layer that has no such cell isn't one a click gives the switch to either.
fn switch_applies(layer: &Layer, name: &str, project: &Project) -> bool {
match name {
"video" | "lock" | "shy" => true,
"audio" => layer.props.sub("audio").is_some(),
"solo" | "quality" | "motionBlur" | "adjustment" => layer.source.is_av(),
"collapse" => collapse_applies(layer, project),
"fx" => layer.effects().is_some_and(|f| !f.children.is_empty()),
"frameBlend" => matches!(layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }),
"threeD" => layer.source.is_av() || matches!(layer.source, LayerSource::Null),
_ => true,
}
}
/// The layers a click on the `name` switch of `layer`'s row acts on: [`edited_layers`] without
/// the layers the switch doesn't apply to.
fn switch_targets(comp: &Comp, project: &Project, selected: &[LayerId], layer: u64, name: &str) -> Vec<u64> {
edited_layers(LayerId(layer), selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| switch_applies(l, name, project))).collect()
}
/// The layers a Modes column edit (blending mode, Preserve Transparency) or a Track Matte edit
/// on `layer`'s row acts on: [`edited_layers`] that have video. Selected nulls, cameras and
/// lights (no Modes or Track Matte on their rows) are left out.
fn video_targets(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec<u64> {
edited_layers(layer, selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| l.source.is_av())).collect()
}
/// Where and what a layer switch is: its cell, icon, state and the switch it sets.
#[derive(Clone, Copy)]
struct SwitchSpec {
row: Rect,
br: Rect,
icon: Icon,
on: bool,
id: egui::Id,
layer: u64,
name: &'static str,
others: bool,
}
/// A layer's switch (A/V Features and Switches columns) in `row`, with the
/// [`switch_gesture`].
fn layer_switch(
ui: &mut egui::Ui,
t: &Tokens,
spec: &SwitchSpec,
comp: &Comp,
project: &Project,
selected: &[LayerId],
actions: &mut Vec<(String, serde_json::Value)>,
) {
let SwitchSpec { row, br, icon, on, id, layer, name, others } = *spec;
let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag());
// What the switch does, as its column header says.
let resp = match switch_tip(name) {
Some(tip) => resp.on_hover_text(tr(tip)),
None => resp,
};
// Alt-click on an A/V feature sets the layer apart: the other layers go off (or locked),
// and come back on the next Alt-click. With several layers selected, the whole selection is
// set apart.
if others && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed() && i.modifiers.alt) {
let layers = edited_layers(LayerId(layer), selected);
actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "others": true})));
return;
}
// A press on a selected layer's switch gives the switch to every selected layer, as in After
// Effects; the targets are picked when the press happens, never while the row is drawn.
if let Some((value, merge)) = switch_gesture(ui, &resp, row, name, on) {
let layers = switch_targets(comp, project, selected, layer, name);
actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "value": value, "merge": merge})));
} else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
// Keyboard or accessibility activation (a pointer press was handled above).
let layers = switch_targets(comp, project, selected, layer, name);
actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "value": !on})));
}
}
/// The `egui::Id` under which composition `comp` remembers the layer the last click selected, so
/// a following Shift-click can select the range from there ([`shift_range`]).
fn anchor_id(comp: ItemId) -> egui::Id {
egui::Id::unique(("timeline-selection-anchor", comp.0))
}
/// Remember `layer` as where the next Shift-click selection starts.
fn set_anchor(ctx: &egui::Context, comp: ItemId, layer: LayerId) {
ctx.data_mut(|d| d.insert_temp(anchor_id(comp), layer));
}
/// The layers a Shift-click on `layer` (its name or its bar) selects: those from the last layer
/// clicked (else the first selected) to it in stack order, but locked layers and layers the
/// Timeline doesn't show. `None` without such a layer.
fn shift_range(ctx: &egui::Context, cid: ItemId, comp: &Comp, rows: &[Row], selected: &[LayerId], layer: LayerId) -> Option<Vec<u64>> {
let anchor = ctx.data(|d| d.get_temp::<LayerId>(anchor_id(cid))).or_else(|| selected.first().copied())?;
let a = comp.layers.iter().position(|l| l.id == anchor)?;
let b = comp.layers.iter().position(|l| l.id == layer)?;
let span = comp.layers.iter().skip(a.min(b)).take(a.max(b) - a.min(b) + 1);
Some(span.filter(|l| !l.switches.locked && rows.iter().any(|r| r.layer == l.id)).map(|l| l.id.0).collect())
}
/// Set on the frame the reorder drag is released.
/// Where layers dropped at height `py` land among the visible layer rows: above the first row
/// whose middle is below it (its index in `layer_rows`), else below the last row. Returns that
/// and the y of the line that shows it (`top` without rows).
fn insertion<T>(layer_rows: &[(Rect, T)], py: f32, top: f32) -> (Option<usize>, f32) {
let at = layer_rows.iter().position(|(r, _)| py < r.center().y);
let y = match at {
Some(i) => layer_rows.get(i).map_or(top, |(r, _)| r.min.y),
None => layer_rows.last().map_or(top, |(r, _)| r.max.y),
};
(at, y)
}
/// Resolve a visible insertion boundary through the full outline, including offscreen layers.
fn layer_drop_target(rows: &[Row], visible: &[(Rect, LayerId)], at: Option<usize>, moving: &[u64]) -> Option<LayerId> {
let (anchor, after) = match at {
Some(i) => (visible.get(i)?.1, false),
None => (visible.last()?.1, true),
};
rows.iter()
.filter(|row| matches!(row.kind, RowKind::Layer))
.skip_while(|row| row.layer != anchor)
.skip(usize::from(after))
.find(|row| !moving.contains(&row.layer.0))
.map(|row| row.layer)
}
/// Whether a Project item can replace a layer's source (the same set `layer.replaceSource`
/// accepts): compositions, footage and solids; not folders, data files or 3D models.
fn replaceable_item(it: &effectcraft_engine::project::Item) -> bool {
match &it.kind {
ItemKind::Comp(_) | ItemKind::Solid(_) => true,
ItemKind::Footage(f) => !matches!(f.kind, FootageKind::Data | FootageKind::Model),
ItemKind::Folder => false,
}
}
/// The Alt/Option-drag target: the layer under the pointer can take this item as its source, and
/// the item wouldn't put the composition inside itself.
pub(crate) fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: LayerId, comp: &Comp) -> bool {
app.session.project.item(item).is_some_and(replaceable_item)
&& !app.session.project.comp_contains(item, cid)
&& comp
.layer(lid)
.is_some_and(|l| !l.switches.locked && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }))
}
/// The frame's layout and state shared by the Timeline's drawing steps.
struct Tl {
t: Tokens,
cid: ItemId,
time: Tick,
fr: effectcraft_engine::time::FrameRate,
rect: Rect,
header_h: f32,
colhdr_h: f32,
footer_h: f32,
vis: Vis,
left_w: f32,
overflow: f32,
oscroll: f32,
cw: Cols,
graph_x0: f32,
graph_x1: f32,
fit_pps: f64,
pps: f64,
scroll_to: Option<crate::state::ScrollTo>,
tm: TMap,
top: f32,
rows_rect: Rect,
ruler: Rect,
bin: Rect,
tc_rect: Rect,
wa_row: Rect,
fd: f64,
rh: f32,
max_scroll: f32,
ctx: egui::Context,
comp: Arc<Comp>,
p: egui::Painter,
rows: Arc<Vec<Row>>,
actions: Vec<(String, serde_json::Value)>,
}
/// [`Tl`] plus what the layer rows and the interactions after them share.
struct Rw {
tl: Tl,
lp: egui::Painter,
gp: egui::Painter,
graph_on: bool,
full_clip: Rect,
left_clip: Rect,
right_clip: Rect,
empty_rect: Rect,
empty: egui::Response,
outline_empty: egui::Response,
selected: Vec<LayerId>,
sel_keys: Vec<effectcraft_engine::KeyRef>,
ui_actions: Vec<UiAct>,
hit_rows: Vec<(Rect, Row)>,
dim_hits: Vec<(u64, Vec<Rect>)>,
}
/// One visible row: what the per-kind drawing steps need to know about it.
struct RowCtx<'a> {
row: &'a Row,
layer: &'a Layer,
r: Rect,
left: Rect,
cy: f32,
is_sel: bool,
ri: usize,
indent: f32,
}
/// The responses of a layer bar's body and its two trim edges.
struct BarResps {
body: egui::Response,
lin: egui::Response,
lout: egui::Response,
}
/// An expression row's property, expression and editor ids.
#[derive(Clone, Copy)]
struct ExprRow<'a> {
prop: &'a Property,
ex: &'a effectcraft_engine::project::Expression,
buf_id: egui::Id,
edit_id: egui::Id,
}
/// A keyframe marker's hit result.
struct KeyHit<'a> {
k: &'a effectcraft_engine::keyframe::Keyframe,
ct: Tick,
ks: bool,
kresp: egui::Response,
mods: egui::Modifiers,
this_key: serde_json::Value,
}
/// Computes this frame's layout: zoom, scroll and the panel's regions.
fn layout(app: &mut EffectcraftApp, ui: &egui::Ui, rect: Rect, cid: ItemId, comp: &Arc<Comp>) -> Tl {
let t = app.tokens;
let ctx = ui.ctx().clone();
let p = ui.painter().clone();
let time = app.session.time();
let fr = comp.frame_rate;
// Geometry.
let header_h = 48.0;
let ruler_h = 34.0;
let colhdr_h = 22.0;
let footer_h = 24.0;
let vis = Vis::of(&app.ui.timeline);
let left_w = (vis.fixed() + 190.0).clamp(420.0, (rect.width() * 0.72).max(420.0));
// Columns wider than the pane scroll horizontally (Shift+wheel / trackpad over the outline,
// or the scroll bar above the footer); the name column keeps its minimum width.
let natural_w = vis.fixed() + 190.0;
let overflow = (natural_w - left_w).max(0.0);
app.ui.timeline.outline_scroll = app.ui.timeline.outline_scroll.clamp(0.0, overflow);
let oscroll = app.ui.timeline.outline_scroll;
let cw = cols(rect.min.x - oscroll, left_w + overflow, vis);
let graph_x0 = rect.min.x + left_w + 1.0;
let graph_x1 = rect.max.x - GUTTER_W;
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0)));
let fit_pps = fit_pps(graph_x1 - graph_x0, comp);
let pps = app.ui.timeline.pps.unwrap_or(fit_pps);
if app.ui.timeline.pps.is_none() {
app.ui.timeline.start = 0.0;
} else if app.playback.playing {
// During playback the view pages to keep the CTI in sight (After Effects).
let span = ((graph_x1 - graph_x0 - 6.0) as f64 / pps).max(0.0);
app.ui.timeline.start = page_to_cti(app.ui.timeline.start, span, comp.duration.seconds(), app.session.time().seconds());
}
let scroll_to = app.ui.timeline.scroll_to.take();
if scroll_to == Some(crate::state::ScrollTo::Time) && app.ui.timeline.pps.is_some() {
let span = ((graph_x1 - graph_x0 - 6.0) as f64 / pps).max(0.0);
app.ui.timeline.start = centered_on(span, comp.duration.seconds(), app.session.time().seconds());
}
// As in After Effects, the ruler never scrolls past the comp's end: zoomed in, the last
// visible time is at most the comp duration.
let visible_secs = ((graph_x1 - graph_x0 - 6.0).max(1.0) as f64 / pps.max(1e-6)).max(0.0);
let max_start = (comp.duration.seconds() - visible_secs).max(0.0);
app.ui.timeline.start = app.ui.timeline.start.clamp(0.0, max_start);
let tm = TMap { x0: graph_x0 + 6.0, start: app.ui.timeline.start, pps };
ctx.data_mut(|d| d.insert_temp(tl_map_id(), (tm.x0, tm.start, tm.pps)));
let top = rect.min.y;
let rows_top = top + header_h + colhdr_h;
let rows_rect = Rect::from_min_max(pos2(rect.min.x, rows_top), pos2(rect.max.x, rect.max.y - footer_h));
p.rect_filled(Rect::from_min_max(pos2(graph_x0, top), rect.max), 0.0, t.tl_bg);
p.line_segment([pos2(graph_x0 - 1.0, top), pos2(graph_x0 - 1.0, rect.max.y)], Stroke::new(1.0, t.app_bg));
let ruler = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h), pos2(rect.max.x, top + header_h));
// The composition marker bin, at the right end of the ruler above the layers' scroll bar.
let bin = Rect::from_min_max(pos2(graph_x1 + 2.0, ruler.max.y - 16.0), pos2(rect.max.x - 2.0, ruler.max.y - 2.0));
let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0));
let wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
let fd = comp.frame_duration().seconds();
Tl {
t,
cid,
time,
fr,
rect,
header_h,
colhdr_h,
footer_h,
vis,
left_w,
overflow,
oscroll,
cw,
graph_x0,
graph_x1,
fit_pps,
pps,
scroll_to,
tm,
top,
rows_rect,
ruler,
bin,
tc_rect,
wa_row,
fd,
rh: t.row_h,
max_scroll: 0.0,
ctx,
comp: comp.clone(),
p,
rows: Arc::new(Vec::new()),
actions: Vec::new(),
}
}
/// Without an active composition: a hint, and dropping footage creates the first composition.
fn empty_timeline(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
ui.painter().text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint);
// Dropping footage into an empty Timeline should create its first composition,
// just like dropping onto the Project panel's New Composition button (#649).
if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx).as_deref()
&& ui.rect_contains_pointer(rect)
{
let items: Vec<ItemId> = crate::panels::project_drop_items(app, *dragged, None)
.into_iter()
.map(ItemId)
.filter(|id| {
app.session.project.item(*id).is_some_and(|item| match &item.kind {
effectcraft_engine::project::ItemKind::Footage(footage) => footage.kind != effectcraft_engine::project::FootageKind::Data,
effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true,
_ => false,
})
})
.collect();
if !items.is_empty() {
ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
app.auto.add("timeline.newCompDrop", rect, "Drop footage to create a composition");
if ctx.input(|i| i.pointer.any_released()) {
app.session.state.project_selection = items;
if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) {
app.ui.status = e;
}
egui::DragAndDrop::clear_payload(&ctx);
}
}
}
}
/// Starts the rows pass: the clipped painters and the background responses.
fn begin_rows(app: &mut EffectcraftApp, ui: &mut egui::Ui, tl: Tl) -> Rw {
let rows_rect = tl.rows_rect;
let graph_x0 = tl.graph_x0;
let rect = tl.rect;
let lp = tl.p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y))));
// The rows reach through the gutter right of the time graph, but stop before the scroll bar.
let graph_rows = Rect::from_min_max(
pos2(graph_x0, rows_rect.min.y),
pos2(if tl.max_scroll > 0.0 { rect.max.x - widgets::SCROLL_GUTTER } else { rect.max.x }, rows_rect.max.y),
);
let gp = tl.p.with_clip_rect(rows_rect.intersect(graph_rows));
let graph_on = app.ui.timeline.graph_editor;
super::graph::track_open(ui.ctx(), graph_on);
let full_clip = ui.clip_rect();
let left_clip = full_clip.intersect(Rect::from_min_max(pos2(rect.min.x, rows_rect.min.y), pos2(graph_x0 - 1.0, rows_rect.max.y)));
let right_clip = full_clip.intersect(graph_rows);
// Registered before the rows so keys, bars and editors on top of it get the pointer first.
let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) };
let empty = ui.interact(empty_rect, egui::Id::unique("tl-graph-bg"), Sense::click_and_drag());
let outline_rect = Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y));
let outline_empty = ui.interact(outline_rect, egui::Id::unique("tl-layer-bg"), Sense::click_and_drag());
app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers");
Rw {
lp,
gp,
graph_on,
full_clip,
left_clip,
right_clip,
empty_rect,
empty,
outline_empty,
selected: app.session.state.selected_layers.clone(),
sel_keys: app.session.state.selected_keys.clone(),
ui_actions: Vec::new(),
hit_rows: Vec::new(),
dim_hits: Vec::new(),
tl,
}
}
/// The Timeline panel for the active composition inside `rect`: draws the header, ruler, work
/// area and cache bars, the layer rows with their switch and value cells, the time graph and its
/// keys, and the footer; and turns this frame's clicks, drags and drops into commands. Without an
/// active comp it shows a hint, and dropping footage on it creates a composition. Most edits
/// use [`crate::menus::invoke`]; time, switch and work-area gestures call `Session::execute`
/// directly and ignore errors. Reported failures go to `app.ui.status`. UI state (scroll, zoom,
/// open rows and the drags in progress) lives in
/// `app.ui.timeline` and egui's temp data.
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ignore_middle_press(ui, rect);
let Some(cid) = app.session.active_comp_id() else {
empty_timeline(app, ui, rect);
return;
};
let Some(comp) = app.session.project.comp_arc(cid) else { return };
let mut tl = layout(app, ui, rect, cid, &comp);
let g = &mut tl;
if !ui.input(|i| i.pointer.primary_down()) {
g.ctx.data_mut(|d| d.remove::<SwitchDrag>(switch_drag_id()));
}
header_timecode(app, ui, g);
header_search_and_switches(app, ui, g);
time_navigator(app, ui, g);
ruler_background(app, g);
work_area_bar(app, ui, g);
cache_bars(app, g);
ruler_ticks(app, g);
ruler_scrub(app, ui, g);
column_header_icons(app, g);
column_header_interact(app, ui, g);
let rows = Arc::new(with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline, &g.ctx));
let total_h: f32 = rows.iter().map(|r| row_height(r, g.rh)).sum();
g.max_scroll = (total_h - g.rows_rect.height() + g.rh).max(0.0);
g.rows = rows;
reveal_scroll(app, g);
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: g.time,
expr: snap_expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
reads: None,
};
let mut rw = begin_rows(app, ui, tl);
let g = &mut rw;
marquee::prepare(app, ui, g);
wheel_zoom_scroll(app, ui, &g.tl);
pick_whip_edge_scroll(app, &g.tl);
marquee::scroll(app, g);
draw_rows(app, ui, g, &ectx);
ui.set_clip_rect(g.full_clip);
background_menus(app, ui, g);
outline_marquee(ui, g);
pick_whip_drag(g);
alt_scale_and_graph(app, ui, g, &ectx);
graph_empty_area(ui, g);
layer_reorder_drag(ui, g);
group_reorder_drag(ui, g);
quick_offset_follow(g);
key_drag_follow(app, g);
marquee::finish(app, ui, g);
current_time_indicator(app, g);
let footer = footer_bar(app, ui, g);
footer_zoom(app, ui, g, footer);
comp_markers_ui(app, ui, g);
hand_tool_pan(app, ui, g);
drop_targets(app, ui, g);
let Rw { tl, ui_actions, .. } = rw;
apply_ui_acts(app, &tl.comp, ui_actions);
apply_actions(app, ui, &tl.ctx, tl.actions);
}
/// Applies the UI-only changes the frame collected (twirls, time, ending an undo merge).
fn apply_ui_acts(app: &mut EffectcraftApp, comp: &Comp, ui_actions: Vec<UiAct>) {
for a in ui_actions {
match a {
UiAct::ToggleLayer(id, all) => {
let tl = &mut app.ui.timeline;
// Twirled by hand, the layer shows its whole tree again.
tl.reveal.clear();
tl.layer_reveal.remove(&id);
tl.full_groups.remove(&id);
let open = !tl.open_layers.contains(&id);
if open {
tl.open_layers.insert(id);
} else {
tl.open_layers.remove(&id);
}
if all && let Some(l) = comp.layer(LayerId(id)) {
let mut uids = vec![];
walk_groups(&l.props, &mut uids);
for u in uids {
if open {
tl.open_groups.insert(u);
} else {
tl.open_groups.remove(&u);
}
}
}
}
UiAct::ToggleGroup(layer, uid, all) => {
let tl = &mut app.ui.timeline;
tl.toggle_group(layer, uid);
let open = tl.open_groups.contains(&uid);
if all && let Some(g) = comp.layer(LayerId(layer)).and_then(|l| find_group(&l.props, uid)) {
let mut uids = vec![];
walk_groups(g, &mut uids);
for u in uids {
if open {
tl.open_groups.insert(u);
} else {
tl.open_groups.remove(&u);
}
}
}
}
UiAct::SetTime(tt) => app.session.set_time(tt),
UiAct::EndMerge => app.session.history.merge_key = None,
UiAct::ChangeRenderer => {
if super::dialogs::open_comp_settings(app).is_ok() {
app.dialog_state.comp.tab = super::comp_settings::Tab::Renderer;
}
}
}
}
}
/// Runs the commands the frame collected through [`crate::menus::invoke`].
fn apply_actions(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, actions: Vec<(String, serde_json::Value)>) {
if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") {
app.ui.viewer.property_interacting = true;
ctx.request_repaint();
}
for (id, params) in actions {
if id == "__endMerge" {
app.session.history.merge_key = None;
continue;
}
if let Err(e) = crate::menus::invoke(app, ctx, &id, params) {
app.ui.status = e;
}
}
}
/// Draws the visible rows, top to bottom, each by its kind.
fn draw_rows(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, ectx: &EvalCtx<'_>) {
let comp = g.tl.comp.clone();
let rows = g.tl.rows.clone();
let (rect, rows_rect, rh, cw_name) = (g.tl.rect, g.tl.rows_rect, g.tl.rh, g.tl.cw.name);
let mut y = rows_rect.min.y - app.ui.timeline.scroll_y;
// Rows stripe by their index among the rows that are not expression editors, so adding or
// removing an expression leaves every other row's stripe alone; an editor takes the index
// (and so the stripe) of its property's row, the one just above.
let mut striped = 0usize;
for row in rows.iter() {
let ri = if matches!(row.kind, RowKind::Expr { .. }) {
striped.saturating_sub(1)
} else {
striped += 1;
striped - 1
};
// Outline widgets never spill into the time graph (narrow timelines).
ui.set_clip_rect(g.left_clip);
let hh = row_height(row, rh);
let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), hh));
y += hh;
if r.max.y < rows_rect.min.y || r.min.y > rows_rect.max.y {
continue;
}
g.hit_rows.push((r, row.clone()));
let Some(layer) = comp.layer(row.layer) else { continue };
let rc = RowCtx {
row,
layer,
r,
left: Rect::from_min_max(r.min, pos2(g.tl.graph_x0 - 1.0, r.max.y)),
cy: r.min.y + rh / 2.0,
is_sel: g.selected.contains(&layer.id),
ri,
indent: cw_name + 6.0 + 14.0 * row.depth as f32,
};
match &row.kind {
RowKind::Layer => layer_row(app, ui, g, &rc),
RowKind::Group { .. } => group_row(app, ui, g, &rc),
RowKind::Waveform { .. } => waveform_row(app, g, &rc),
RowKind::Expr { .. } => expr_row(app, ui, g, &rc),
RowKind::Prop { .. } => prop_row(app, ui, g, &rc, ectx),
}
}
}
/// A layer row: switches, label, name, cells and the layer bar.
fn layer_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
layer_row_background(g, rc);
layer_av_switches(app, ui, g, rc);
layer_label_swatch(app, ui, g, rc);
layer_number_and_cells(app, ui, g, rc);
let (row_resp, name_x) = layer_twirl_and_icon(app, ui, g, rc);
layer_name(app, ui, g, rc, name_x);
layer_row_actions(app, ui, g, rc, &row_resp);
layer_switches(app, ui, g, rc);
if app.ui.timeline.show_modes && rc.layer.source.is_av() {
layer_mode_cell(app, ui, g, rc);
layer_matte_cell(app, ui, g, rc);
}
if g.tl.vis.parent {
layer_parent_cell(app, ui, g, rc);
}
ui.set_clip_rect(g.right_clip);
if !g.graph_on {
layer_bar(app, ui, g, rc);
}
}
/// The layer's duration bar: source extent, move and trim, markers.
fn layer_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let (bar, lc) = layer_bar_paint(g, rc);
layer_source_extent(app, ui, g, rc, bar, lc);
let bars = layer_bar_hits(app, ui, g, rc, bar);
let quick = layer_quick_offset_begin(ui, g, rc, &bars.body);
layer_bar_drag(g, rc, &bars, quick);
layer_bar_markers(app, ui, g, rc);
}
/// A group row (effect, mask, shape group, layer style).
fn group_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let shown = group_background(app, g, rc);
group_switches(app, ui, g, rc);
group_name(app, ui, g, rc, &shown);
group_popups(app, ui, g, rc);
group_mask_mode(ui, g, rc);
group_row_click(app, ui, g, rc);
}
/// An expression row: its controls and the editor.
fn expr_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let RowKind::Expr { uid, .. } = &rc.row.kind else { return };
let Some(prop) = rc.layer.props.find(*uid) else { return };
let Some(ex) = prop.expr.as_ref() else { return };
let st = ExprRow { prop, ex, buf_id: expr_buf_id(*uid), edit_id: egui::Id::unique(("expr-edit", uid)) };
expr_controls(app, ui, g, rc, &st);
expr_language_menu(app, ui, g, rc, &st);
expr_editor_cell(app, ui, g, rc, &st);
}
/// A property row: keyframe navigator, stopwatch, name, value editors and the keyframes.
fn prop_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, ectx: &EvalCtx<'_>) {
let RowKind::Prop { uid } = &rc.row.kind else { return };
let Some(prop) = rc.layer.props.find(*uid) else { return };
prop_background(app, g, rc, prop);
prop_key_navigator(app, ui, g, rc, prop);
prop_stopwatch(app, ui, g, rc, prop);
let name_x = prop_name(app, ui, g, rc, prop);
prop_value_cell(app, ui, g, rc, ectx, prop, name_x);
prop_expression_hint(app, ui, g, rc, prop);
if !g.graph_on {
for k in &prop.keys {
key_marker(app, ui, g, rc, prop, k);
}
}
}
/// One keyframe marker: its look, selection, menu and drag.
fn key_marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, k: &effectcraft_engine::keyframe::Keyframe) {
let RowKind::Prop { uid } = &rc.row.kind else { return };
let ct = rc.layer.comp_time(k.time);
let x = g.tl.tm.x(ct.seconds());
if x < g.tl.graph_x0 - 8.0 || x > g.tl.rect.max.x + 8.0 {
return;
}
let kref = effectcraft_engine::KeyRef { layer: rc.layer.id, prop: *uid, time: k.time };
let ks = g.sel_keys.contains(&kref);
let kr = key_paint(g, rc, k, x, ks);
let kresp = key_hit(app, ui, g, rc, prop, k, kr);
let mods = ui.input(|i| i.modifiers);
let this_key = json!({"keys": [{"layer": rc.layer.id.0, "prop": uid, "time": k.time.seconds()}], "selectProperties": true});
let kh = KeyHit { k, ct, ks, kresp, mods, this_key };
key_select(g, &kh);
key_double_click(g, rc, prop, &kh);
key_context_menu(app, g, rc, prop, &kh);
key_drag_begin(app, g, &kh);
}
/// Drop targets: effects and presets on a layer, footage and files into the stack.
fn drop_targets(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) {
let ctx = g.tl.ctx.clone();
let rows_rect = g.tl.rows_rect;
if let Some(payload) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx)
&& !matches!(*payload, crate::panels::DragPayload::Property { .. })
&& let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rows_rect))
{
let released = ctx.input(|i| i.pointer.any_released());
// Alt/Option-drag from the Project panel replaces the source of the layer under the
// pointer (After Effects). The shortcut replaces every selected layer instead.
let alt_item = ctx.input(|i| i.modifiers.alt) && matches!(payload.as_ref(), crate::panels::DragPayload::Item(_));
if alt_item {
drop_replace_source(app, g, &payload, ptr, released);
} else if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) {
drop_on_layer(app, g, &payload, ptr, released);
} else {
drop_insert_layers(app, g, &payload, ptr, released);
}
if released {
egui::DragAndDrop::clear_payload(&ctx);
}
}
}
/// The header's time display: click to switch style, drag to scrub, double-click to type a time.
fn header_timecode(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
let t = g.t;
let time = g.time;
let fr = g.fr;
let tc_rect = g.tc_rect;
let ctx = g.ctx.clone();
let comp = g.comp.clone();
let p = g.p.clone();
let tc = crate::panels::timecode(&app.session, &comp, time);
let tc_resp = ui.interact(tc_rect, egui::Id::unique("tl-timecode"), Sense::click_and_drag());
p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(16.0), t.timecode);
// Under it the other style (frames under timecode, timecode under frames) and the rate.
let settings = &app.session.project.settings;
let other = if settings.time_display == effectcraft_engine::project::TimeDisplayStyle::Timecode {
format!("{:05}", fr.frame_at(time) + settings.frame_start)
} else {
effectcraft_engine::time::format_timecode_ae(fr.frame_at(time + comp.display_start), fr, false)
};
// Audio-only preview (Video off in the Preview panel): the current time stays put, so that
// line says why and where the sound is instead (#1118).
if let Some(af) = app.video_preview_off() {
let at = af.map(|f| format!(" · {}", effectcraft_engine::time::format_timecode_ae(f + fr.frame_at(comp.display_start), fr, false))).unwrap_or_default();
let msg = format!("{}{at}", crate::i18n::tr("Video preview off: audio only"));
p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, msg, Tokens::ui(10.5), t.cache_green);
} else {
p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, format!("{other} ({:.2} fps)", fr.as_f64()), Tokens::ui(10.5), t.text_faint);
}
app.auto.add("timeline.timecode", tc_rect, &tc);
super::toggle_time_display(app, &tc_resp);
if tc_resp.dragged() {
let d = tc_resp.drag_delta().x as f64;
let f = fr.frame_at(time) + (d * 0.5).round() as i64;
let _ = app.session.execute("time.set", json!({"frame": f.max(0)}));
}
if tc_resp.double_clicked() {
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-tc-edit"), tc.clone()));
}
if let Some(mut buf) = ctx.data(|d| d.get_temp::<String>(egui::Id::unique("tl-tc-edit"))) {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(tc_rect));
let r = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::semibold(16.0)).desired_width(150.0));
if r.lost_focus() {
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
let _ = app.session.execute("time.set", json!({"timecode": buf}));
}
ctx.data_mut(|d| d.remove::<String>(egui::Id::unique("tl-tc-edit")));
} else {
widgets::keep_focus(&ctx, &r, &buf);
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-tc-edit"), buf));
}
}
}
/// The header's search field and the composition switches.
fn header_search_and_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
let t = g.t;
let rect = g.rect;
let left_w = g.left_w;
let top = g.top;
let tc_rect = g.tc_rect;
let comp = g.comp.clone();
let sr = Rect::from_min_size(pos2(tc_rect.max.x + 14.0, top + 12.0), vec2((left_w - 420.0).clamp(120.0, 220.0), 22.0));
let mut search = app.ui.timeline.search.clone();
widgets::search_field(ui, sr, &mut search, crate::i18n::tr("Search"), &t);
app.ui.timeline.search = search;
app.auto.add("timeline.search", sr, "Search");
let mut bx = rect.min.x + left_w - 10.0;
for (icon, on, id, tip) in [
(Icon::Graph, app.ui.timeline.graph_editor, "graph", "Graph Editor"),
(Icon::MotionBlur, comp.enable_motion_blur, "motionBlur", "Enables Motion Blur for all layers with the Motion Blur switch set"),
(Icon::FrameBlend, comp.enable_frame_blending, "frameBlending", "Enables Frame Blending for all layers with the Frame Blend switch set"),
(Icon::Shy, comp.hide_shy, "hideShy", "Hides all layers for which the Shy switch is set"),
(Icon::Draft3D, comp.draft_3d, "draft3d", "Draft 3D"),
(Icon::Flowchart, false, "flowchart", "Composition Mini-Flowchart"),
] {
let r = Rect::from_min_size(pos2(bx - 24.0, top + 11.0), vec2(24.0, 24.0));
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::unique(("tl-sw", id))).on_hover_text(tip);
app.auto.add(&format!("timeline.switch.{id}"), r, tip);
if resp.clicked() {
match id {
"graph" => app.ui.timeline.graph_editor = !app.ui.timeline.graph_editor,
"flowchart" => app.show_panel(crate::dock::PanelKind::Flowchart),
_ => {
let _ = app.session.execute("comp.setSwitch", json!({"switch": id}));
}
}
}
bx -= 28.0;
}
}
/// The time navigator: the visible span as a bar with end handles.
fn time_navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
let t = g.t;
let graph_x0 = g.graph_x0;
let graph_x1 = g.graph_x1;
let fit_pps = g.fit_pps;
let pps = g.pps;
let tm = g.tm;
let top = g.top;
let ctx = g.ctx.clone();
let comp = g.comp.clone();
let p = g.p.clone();
let nav = Rect::from_min_max(pos2(graph_x0 + 6.0, top + 6.0), pos2(graph_x1, top + 14.0));
// Time navigator: the visible span as a grey bar with blue end handles (as in After
// Effects), over a dark track.
p.rect_filled(nav, 1.0, t.field_bg);
let vis0 = (tm.start / comp.duration.seconds()) as f32;
let vis1 = (tm.t(graph_x1) / comp.duration.seconds()) as f32;
let nav_vis =
Rect::from_min_max(pos2(nav.min.x + nav.width() * vis0.clamp(0.0, 1.0), nav.min.y), pos2(nav.min.x + nav.width() * vis1.clamp(0.0, 1.0), nav.max.y));
p.rect_filled(nav_vis, 0.0, t.tl_nav_bar);
for x in [nav_vis.min.x, nav_vis.max.x] {
p.rect_filled(Rect::from_min_max(pos2(x - 3.0, nav.min.y - 1.0), pos2(x + 3.0, nav.max.y + 1.0)), 3.0, t.accent);
}
// The middle scrolls at the current zoom; each end is a handle that moves that side of the
// visible span (pulled in: zoomed in; stretched to the whole track: the whole comp).
let nresp = ui.interact(nav, egui::Id::unique("tl-nav"), Sense::drag());
app.auto.add("timeline.navigator", nav, "Time navigator");
if nresp.dragged() {
let d = nresp.drag_delta().x as f64 / nav.width() as f64 * comp.duration.seconds();
app.ui.timeline.start = (app.ui.timeline.start + d).clamp(0.0, comp.duration.seconds());
if app.ui.timeline.pps.is_none() {
app.ui.timeline.pps = Some(pps);
}
}
let span_px = (graph_x1 - tm.x0) as f64;
let (t0, t1) = (tm.start, tm.t(graph_x1));
for (end, x) in [("start", nav_vis.min.x), ("end", nav_vis.max.x)] {
let hr = Rect::from_center_size(pos2(x, nav.center().y), vec2(10.0, nav.height() + 6.0));
let hresp = ui.interact(hr, egui::Id::unique(("tl-nav", end)), Sense::drag());
app.auto.add(&format!("timeline.navigator.{end}"), hr, if end == "start" { "Time navigator start" } else { "Time navigator end" });
if hresp.hovered() || hresp.dragged() {
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
if let Some(px) = hresp.interact_pointer_pos().filter(|_| hresp.dragged()) {
let t = ((px.x - nav.min.x) / nav.width()) as f64 * comp.duration.seconds();
// The other end stays; the span is at least what the most zoomed-in ruler shows.
let min_span = span_px / MAX_PPS;
let (keep_t, keep_px, span) = if end == "start" { (t1, span_px, t1 - t.min(t1 - min_span)) } else { (t0, 0.0, t.max(t0 + min_span) - t0) };
set_zoom(&mut app.ui.timeline, &comp, span_px / span.max(min_span), fit_pps, keep_t, keep_px);
}
}
}
fn ruler_background(app: &mut EffectcraftApp, g: &Tl) {
let t = g.t;
let tm = g.tm;
let ruler = g.ruler;
let p = g.p.clone();
p.rect_filled(ruler, 0.0, t.tl_ruler_bg);
app.auto.add("timeline.ruler", ruler, &format!("{},{}", tm.start, tm.pps));
}
/// The work area bar beside the column headers, with its begin / end handles.
fn work_area_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Tl) {
let t = g.t;
let fr = g.fr;
let pps = g.pps;
let tm = g.tm;
let wa_row = g.wa_row;
let ctx = g.ctx.clone();
let comp = g.comp.clone();
let p = g.p.clone();
let actions = &mut g.actions;
// Work area bar: below the ruler, beside the column headers (After Effects' layout), with
// blue begin / end handles; the cache bar runs under it. Dragging the bar moves the work
// area, the handles move its ends; Shift snaps (#252).
let (wa0, wa1) = (comp.work_area.0.seconds(), comp.work_area.1.seconds());
let wa = Rect::from_min_max(pos2(tm.x(wa0), wa_row.min.y + 3.0), pos2(tm.x(wa1), wa_row.max.y - 6.0));
p.with_clip_rect(wa_row).rect_filled(wa, 0.0, t.tl_work_area_bar);
let shift = ui.input(|i| i.modifiers.shift);
let bar = wa.intersect(wa_row);
let bresp = ui.interact(bar, egui::Id::unique("wa-bar"), Sense::click_and_drag());
app.auto.add("timeline.workArea.bar", bar, "Work area");
// Right-click: the work area's commands (After Effects).
crate::widgets::context_menu(&bresp, |ui| {
let mut chosen = None;
for id in ["comp.trimToWorkArea", "edit.liftWorkArea", "edit.extractWorkArea"] {
crate::menus::entry_for(app, ui, id, &mut chosen);
}
actions.extend(chosen);
});
let bar_start = egui::Id::unique("wa-bar-start");
if bresp.drag_started() {
ctx.data_mut(|d| d.insert_temp(bar_start, wa0));
}
if bresp.dragged()
&& let (Some(a0), Some(d)) = (ctx.data(|d| d.get_temp::<f64>(bar_start)), bresp.total_drag_delta())
{
let len = wa1 - wa0;
let mut a = a0 + d.x as f64 / pps.max(1e-6);
if shift {
a += work_area_snap(app, &comp, &[a, a + len], pps);
}
let a = fr.snap_nearest(Tick::from_seconds_f64(a.clamp(0.0, (comp.duration.seconds() - len).max(0.0)))).seconds();
let _ = app.session.execute("comp.workArea", json!({"start": a, "end": a + len, "merge": "wa-drag"}));
}
for (hx, set) in [(wa.min.x, "begin"), (wa.max.x, "end")] {
let hr = Rect::from_center_size(pos2(hx, wa.center().y), vec2(8.0, 14.0));
let handle = if set == "begin" {
Rect::from_min_max(pos2(hx - 1.0, wa.min.y), pos2(hx + 4.0, wa.max.y))
} else {
Rect::from_min_max(pos2(hx - 4.0, wa.min.y), pos2(hx + 1.0, wa.max.y))
};
p.rect_filled(handle, 2.0, t.accent);
let resp = ui.interact(hr, egui::Id::unique(("wa", set)), Sense::drag());
app.auto.add(&format!("timeline.workArea.{set}"), hr, set);
if resp.dragged() || resp.hovered() {
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
if resp.dragged()
&& let Some(pt) = resp.interact_pointer_pos()
{
let mut secs = tm.t(pt.x).max(0.0);
if shift {
secs += work_area_snap(app, &comp, &[secs], pps);
}
let secs = fr.snap_nearest(Tick::from_seconds_f64(secs)).seconds();
let key = if set == "begin" { "start" } else { "end" };
let _ = app.session.execute("comp.workArea", json!({key: secs, "merge": "wa-drag"}));
}
}
}
/// The RAM (green) and disk (blue) cache bars under the work area.
fn cache_bars(app: &mut EffectcraftApp, g: &Tl) {
let t = g.t;
let cid = g.cid;
let tm = g.tm;
let wa_row = g.wa_row;
let fd = g.fd;
let comp = g.comp.clone();
let p = g.p.clone();
// Cache bar (green: cached frames of what the viewer shows: its resolution, view and options).
let series = app.shown_series(cid);
let cached = app.frames.cached_frames(&series);
let cy0 = wa_row.max.y - 4.0;
let mut run: Option<(i64, i64)> = None;
let draw_run = |a: i64, b: i64| {
let x0 = tm.x(a as f64 * fd);
let x1 = tm.x((b + 1) as f64 * fd);
p.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_green);
};
for f in cached {
run = match run {
Some((a, b)) if f == b + 1 => Some((a, f)),
Some((a, b)) => {
draw_run(a, b);
Some((f, f))
}
None => Some((f, f)),
};
}
if let Some((a, b)) = run {
draw_run(a, b);
}
// Disk-cached frames not in RAM (blue), like After Effects.
if app.session.disk_cache.is_some() || app.frames.remote_disk() {
let frames = comp.frame_rate.frame_at(comp.duration);
let opts = app.frame_opts(cid, series.scale as f64 / 1000.0);
let clip = wa_row.intersect(p.clip_rect());
let range = disk_bar_range(&tm, clip, fd, frames);
let disk = app.frames.disk_frames(&app.render_source(), &series, range, &opts);
let blue_painter = p.with_clip_rect(clip);
let draw_blue = |a: i64, b: i64| {
let x0 = tm.x(a as f64 * fd);
let x1 = tm.x((b + 1) as f64 * fd);
blue_painter.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_blue);
};
let mut run: Option<(i64, i64)> = None;
for f in disk {
run = match run {
Some((a, b)) if f == b + 1 => Some((a, f)),
Some((a, b)) => {
draw_blue(a, b);
Some((f, f))
}
None => Some((f, f)),
};
}
if let Some((a, b)) = run {
draw_blue(a, b);
}
}
}
/// The ruler's tick marks and labels.
fn ruler_ticks(app: &mut EffectcraftApp, g: &Tl) {
let t = g.t;
let fr = g.fr;
let graph_x0 = g.graph_x0;
let graph_x1 = g.graph_x1;
let pps = g.pps;
let tm = g.tm;
let ruler = g.ruler;
let bin = g.bin;
let fd = g.fd;
let p = g.p.clone();
// Ticks + labels.
// Label spacing in whole frames (AE: `00:15f`-style seconds:frames labels).
let fps_i = fr.as_f64().round().max(1.0) as i64;
let half = (fps_i / 2).max(1);
let label_frames = [1, 2, 5, 10, half, fps_i, 2 * fps_i, 5 * fps_i, 10 * fps_i, 30 * fps_i, 60 * fps_i]
.into_iter()
.find(|f| *f as f64 * fd * pps >= 52.0)
.unwrap_or(60 * fps_i);
let secs_per_label = label_frames as f64 * fd;
let first = (tm.start / secs_per_label).floor() * secs_per_label;
let mut s = first;
let pr = p.with_clip_rect(Rect::from_min_max(pos2(graph_x0, ruler.min.y), pos2(bin.min.x, ruler.max.y)));
while s <= tm.t(graph_x1) + secs_per_label {
let x = tm.x(s);
pr.line_segment([pos2(x, ruler.max.y - 10.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick));
let fno = (s / fd).round() as i64;
let label = ruler_label(fno, fps_i, &app.session.project.settings);
// A label that would run under the marker bin is left out rather than cut to a stray digit.
let g = pr.layout_no_wrap(label, Tokens::ui(10.0), t.tl_ruler_text);
if x + 3.0 + g.size().x <= bin.min.x {
pr.galley(pos2(x + 3.0, ruler.max.y - 16.0 - g.size().y / 2.0), g, t.tl_ruler_text);
}
for k in 1..5 {
let xx = tm.x(s + secs_per_label * k as f64 / 5.0);
pr.line_segment([pos2(xx, ruler.max.y - 4.0), pos2(xx, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick.gamma_multiply(0.6)));
}
s += secs_per_label;
}
}
/// Scrubbing in the ruler: the current time follows the pointer.
fn ruler_scrub(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
let graph_x0 = g.graph_x0;
let graph_x1 = g.graph_x1;
let pps = g.pps;
let tm = g.tm;
let ruler = g.ruler;
let bin = g.bin;
let comp = g.comp.clone();
// Scrub in the ruler: the current time jumps to the pointer as soon as the button goes down
// (After Effects), then follows it.
let rresp = ui.interact(
Rect::from_min_max(pos2(graph_x0, ruler.min.y + 12.0), pos2(bin.min.x, ruler.max.y)),
egui::Id::unique("tl-ruler"),
Sense::click_and_drag(),
);
if ((rresp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down())) || rresp.clicked())
&& let Some(pt) = rresp.interact_pointer_pos()
{
let mut x = pt.x;
// Zoomed in, a scrub held past either end of the ruler scrolls the timeline that way
// (faster the further), with the CTI at the edge (After Effects).
let mut tm = tm;
if rresp.dragged() && app.ui.timeline.pps.is_some() {
let speed = edge_scroll_speed_x(tm.x0, graph_x1, x);
if speed != 0.0 {
let dt = f64::from(ui.input(|i| i.stable_dt).clamp(0.0, 0.1));
let span = f64::from(graph_x1 - tm.x0) / pps;
let cur = app.ui.timeline.start;
let hi = (comp.duration.seconds() - span).max(0.0).max(cur);
app.ui.timeline.start = (cur + f64::from(speed) * dt / pps).clamp(0.0, hi);
tm.start = app.ui.timeline.start;
ui.ctx().request_repaint();
}
x = x.clamp(tm.x0, graph_x1);
}
let mut secs = tm.t(x).max(0.0);
// Shift-drag: snap to keyframes, in/out points, markers and the work area (8 px).
if ui.input(|i| i.modifiers.shift) {
let cands = snap_candidates(&comp, &build_rows(app, &comp));
secs = snap_time(&cands, secs, 8.0 / pps.max(1e-6));
}
let tt = Tick::from_seconds_f64(secs);
app.session.set_time(tt);
app.stop();
// Dragged past either edge of the ruler (over the layer outline, say), the view scrolls so
// the current time indicator stays in sight (After Effects).
if app.ui.timeline.pps.is_some() {
let now = app.session.time().seconds();
let span = tm.t(bin.min.x) - tm.start;
if now < tm.start {
app.ui.timeline.start = now.max(0.0);
} else if now > tm.start + span {
app.ui.timeline.start = (now - span).max(0.0);
}
}
// Ctrl/Cmd-drag scrubs the audio too (After Effects).
if rresp.dragged()
&& ui.input(|i| i.modifiers.command)
&& let Some(cid) = app.session.active_comp_id()
{
// The app clock: the audio scrub's idle timeout compares against it in the tick.
let now = app.now();
let t = app.session.time();
app.scrub_audio(cid, t, now);
}
}
}
/// The column headers' background, icons and titles.
fn column_header_icons(app: &mut EffectcraftApp, g: &Tl) {
let t = g.t;
let rect = g.rect;
let header_h = g.header_h;
let colhdr_h = g.colhdr_h;
let vis = g.vis;
let graph_x0 = g.graph_x0;
let top = g.top;
let p = g.p.clone();
let cw = &g.cw;
let ch = Rect::from_min_max(pos2(rect.min.x, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
p.rect_filled(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)), 0.0, t.panel_bg);
p.line_segment([pos2(rect.min.x, ch.max.y), pos2(rect.max.x, ch.max.y)], Stroke::new(1.0, t.separator));
let hy = ch.center().y;
// Column headers scroll with the outline and never spill into the time ruler.
let hp = p.with_clip_rect(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)));
let hic = |icon: Icon, x: f32| icons::paint(&hp, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim);
let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, crate::i18n::tr(s), Tokens::ui(11.0), t.text_dim);
if vis.av {
hic(Icon::Eye, cw.av + 10.0);
hic(Icon::Speaker, cw.av + 28.0);
hic(Icon::Solo, cw.av + 46.0);
hic(Icon::Lock, cw.av + 64.0);
}
if vis.keys {
htext(cw.keys + 6.0, "Keys");
}
if vis.label {
icons::paint(&hp, Rect::from_center_size(pos2(cw.label + 10.0, hy), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim);
}
if vis.num {
htext(cw.num + 6.0, "#");
}
let name_hdr = if app.ui.timeline.source_name { "Source Name" } else { "Layer Name" };
htext(cw.name + 22.0, name_hdr);
if vis.comment {
htext(cw.comment + 6.0, "Comment");
}
if vis.switches {
for (i, icon) in
[Icon::Shy, Icon::Collapse, Icon::Quality, Icon::Fx, Icon::FrameBlend, Icon::MotionBlur, Icon::Adjustment, Icon::Cube].into_iter().enumerate()
{
hic(icon, cw.switches + SW * i as f32 + SW / 2.0);
}
}
if vis.modes {
htext(cw.mode + 4.0, "Mode");
htext(cw.mode + 74.0, "T");
htext(cw.trkmat + 4.0, "Track Matte");
}
if vis.parent {
icons::paint(&hp, Rect::from_center_size(pos2(cw.parent + 10.0, hy), vec2(12.0, 12.0)), Icon::PickWhip, t.text_dim);
htext(cw.parent + 20.0, "Parent & Link");
}
for (on, x, s) in [(vis.in_, cw.in_, "In"), (vis.out, cw.out, "Out"), (vis.duration, cw.duration, "Duration"), (vis.stretch, cw.stretch, "Stretch")] {
if on {
htext(x + 6.0, s);
}
}
}
/// Column header clicks, automation ids and the right-click column menu.
fn column_header_interact(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Tl) {
let rect = g.rect;
let header_h = g.header_h;
let colhdr_h = g.colhdr_h;
let vis = g.vis;
let graph_x0 = g.graph_x0;
let top = g.top;
let ctx = g.ctx.clone();
let cw = &g.cw;
let actions = &mut g.actions;
let ch = Rect::from_min_max(pos2(rect.min.x, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
let hdr_rect = Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y));
let hresp = ui.interact(hdr_rect, egui::Id::unique("tl-colhdr"), Sense::click());
app.auto.add("timeline.header", hdr_rect, "Columns (right-click)");
// The icon-only headers say what they are on hover. Hover-only, on top of the header: its
// clicks and right-click menu still go to the header.
for (col, key, r) in header_tip_rects(cw, ch) {
let r = r.intersect(hdr_rect);
let Some((_, _, name, tip)) = HEADER_TIPS.iter().find(|t| t.1 == key) else { continue };
if !r.is_positive() {
continue;
}
// The node first: the hover text becomes its accessible description only on an existing node.
let resp = ui.interact(r, egui::Id::unique(("tl-colhdr-tip", key)), Sense::hover());
crate::a11y::role_name(&resp, egui::accesskit::Role::ColumnHeader, tr(name));
let _ = resp.on_hover_text(tr(tip));
// `label` and `#` are a column each: `timeline.header.label` / `.num` already name them.
if col != key {
app.auto.add(&format!("timeline.header.{col}.{key}"), r, name);
}
}
for (id, label) in COLUMNS {
if let Some(x0) = match id {
"av" => vis.av.then_some((cw.av, AV_W)),
"keys" => vis.keys.then_some((cw.keys, KEYS_W)),
"label" => vis.label.then_some((cw.label, LABEL_W)),
"num" => vis.num.then_some((cw.num, NUM_W)),
"name" => Some((cw.name, cw.name_end - cw.name)),
"comment" => vis.comment.then_some((cw.comment, COMMENT_W)),
"switches" => vis.switches.then_some((cw.switches, SW * 8.0 + 6.0)),
"modes" => vis.modes.then_some((cw.mode, MODES_W)),
"parent" => vis.parent.then_some((cw.parent, PARENT_W)),
"in" => vis.in_.then_some((cw.in_, TIME_W)),
"out" => vis.out.then_some((cw.out, TIME_W)),
"duration" => vis.duration.then_some((cw.duration, TIME_W)),
_ => vis.stretch.then_some((cw.stretch, STRETCH_W)),
} {
app.auto.add(&format!("timeline.header.{id}"), Rect::from_min_size(pos2(x0.0, ch.min.y), vec2(x0.1, colhdr_h)), label);
}
}
if hresp.clicked()
&& let Some(pt) = hresp.interact_pointer_pos()
&& column_at(cw, pt.x) == Some("name")
{
app.ui.timeline.source_name = !app.ui.timeline.source_name;
}
if hresp.secondary_clicked() {
let under = hresp.interact_pointer_pos().and_then(|pt| column_at(cw, pt.x));
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-colhdr-under"), under.unwrap_or("")));
}
crate::widgets::context_menu(&hresp, |ui| {
let under: &str = ctx.data(|d| d.get_temp(egui::Id::unique("tl-colhdr-under"))).unwrap_or("");
if !under.is_empty() && under != "name" && ui.button(crate::i18n::tr("Hide This")).clicked() {
actions.push(("timeline.column".into(), json!({"column": under, "visible": false})));
ui.close();
}
ui.menu_button(crate::i18n::tr("Columns"), |ui| {
for (id, label) in COLUMNS {
let label = if id == "name" { if app.ui.timeline.source_name { "Source Name" } else { "Layer Name" } } else { label };
let on = column_visible(&app.ui.timeline, id);
if ui.add_enabled(id != "name", egui::Button::new(crate::i18n::tr(label)).selected(on)).clicked() {
actions.push(("timeline.column".into(), json!({"column": id, "visible": !on})));
ui.close();
}
}
});
if ui.button(if app.ui.timeline.source_name { crate::i18n::tr("Show Layer Name") } else { crate::i18n::tr("Show Source Name") }).clicked() {
actions.push(("timeline.sourceName".into(), json!({})));
ui.close();
}
});
let _ = cw.end;
}
/// Scrolls an expression editor or the requested layer / property into view.
fn reveal_scroll(app: &mut EffectcraftApp, g: &Tl) {
let scroll_to = g.scroll_to;
let rows_rect = g.rows_rect;
let rh = g.rh;
let ctx = g.ctx.clone();
let comp = g.comp.clone();
let rows = &g.rows;
// An expression editor asked to take the keyboard comes into view; a request whose
// expression didn't appear, or that wasn't taken within a few passes, is dropped (it never
// takes the keyboard later).
if let Some(f) = ctx.data(|d| d.get_temp::<ExprFocus>(expr_focus_id())) {
match rows.iter().position(|r| matches!(r.kind, RowKind::Expr { uid, .. } if uid == f.prop)) {
Some(i) if ctx.cumulative_pass_nr() <= f.pass.saturating_add(8) => {
let top: f32 = rows.iter().take(i).map(|r| row_height(r, rh)).sum();
let fits = (top + rows.get(i).map_or(rh, |r| row_height(r, rh)) - rows_rect.height()).max(0.0).min(top);
app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.min(top).max(fits);
}
_ => ctx.data_mut(|d| d.remove::<ExprFocus>(expr_focus_id())),
}
}
if scroll_to == Some(crate::state::ScrollTo::Layer) {
// The topmost selected layer, in stack order.
let sel = &app.session.state.selected_layers;
let top = comp.layers.iter().find(|l| sel.contains(&l.id)).map(|l| l.id);
if let Some(i) = rows.iter().position(|r| matches!(r.kind, RowKind::Layer) && Some(r.layer) == top) {
app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum();
}
} else if let Some(crate::state::ScrollTo::Property(uid)) = scroll_to
&& let Some(i) = rows.iter().position(|r| matches!(r.kind, RowKind::Prop { uid: p } if p == uid))
{
app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum();
}
}
/// Mouse wheel and pinch over the rows: zoom time or scroll.
fn wheel_zoom_scroll(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
let overflow = g.overflow;
let graph_x0 = g.graph_x0;
let fit_pps = g.fit_pps;
let pps = g.pps;
let tm = g.tm;
let rows_rect = g.rows_rect;
let max_scroll = g.max_scroll;
let comp = g.comp.clone();
if ui.rect_contains_pointer(rows_rect) {
let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt));
// Ctrl/Cmd+wheel and a trackpad pinch reach egui as a zoom factor rather than a scroll
// (#428); like Alt+wheel they zoom time.
let pinch = ui.input(|i| i.zoom_delta()) as f64;
let at_x = ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0);
if (pinch - 1.0).abs() > 1e-4 {
set_zoom(&mut app.ui.timeline, &comp, tm.pps * pinch, fit_pps, tm.t(at_x), (at_x - tm.x0) as f64);
} else if zoom && dy.abs() > 0.0 {
// Alt+wheel zooms around the pointer, out until the whole comp shows.
set_zoom(&mut app.ui.timeline, &comp, tm.pps * (dy as f64 / 200.0).exp(), fit_pps, tm.t(at_x), (at_x - tm.x0) as f64);
} else {
let over_outline = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.x < graph_x0);
let shift = ui.input(|i| i.modifiers.shift);
if over_outline && overflow > 0.0 && (dx.abs() > 0.0 || (shift && dy.abs() > 0.0)) {
let d = if dx.abs() > 0.0 { dx } else { dy };
app.ui.timeline.outline_scroll = (app.ui.timeline.outline_scroll - d).clamp(0.0, overflow);
} else {
app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll);
}
if dx.abs() > 0.0 && app.ui.timeline.pps.is_some() && !over_outline {
app.ui.timeline.start = (app.ui.timeline.start - dx as f64 / pps).max(0.0);
}
}
}
}
/// A held pick whip near the top or bottom scrolls the rows.
fn pick_whip_edge_scroll(app: &mut EffectcraftApp, g: &Tl) {
let rect = g.rect;
let rows_rect = g.rows_rect;
let rh = g.rh;
let max_scroll = g.max_scroll;
let ctx = g.ctx.clone();
// 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::<PickWhip>(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);
}
fn layer_row_background(g: &Rw, rc: &RowCtx<'_>) {
let t = g.tl.t;
let rect = g.tl.rect;
let graph_x0 = g.tl.graph_x0;
let lp = g.lp.clone();
let gp = g.gp.clone();
let r = rc.r;
let left = rc.left;
let is_sel = rc.is_sel;
lp.rect_filled(left, 0.0, if is_sel { t.row_selected } else { t.row_alt });
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, if is_sel { t.tl_graph_selected } else { t.tl_bg });
lp.line_segment([pos2(rect.min.x, r.max.y - 0.5), pos2(graph_x0, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg));
gp.line_segment([pos2(graph_x0, r.max.y - 0.5), pos2(r.max.x, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg));
}
/// The A/V feature switches (video, audio, solo, lock).
fn layer_av_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let t = g.tl.t;
let vis = g.tl.vis;
let comp = g.tl.comp.clone();
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let selected = &g.selected;
let layer = rc.layer;
let r = rc.r;
let cy = rc.cy;
let sw = &layer.switches;
// Every layer has the Video switch (the eye), nulls, cameras and lights included,
// as in After Effects: it hides a null's outline in the viewer and turns a camera or
// light off.
let av_items: [(Icon, bool, &str, bool); 4] = [
(Icon::Eye, sw.video, "video", switch_applies(layer, "video", &app.session.project)),
(Icon::Speaker, sw.audio, "audio", switch_applies(layer, "audio", &app.session.project)),
(Icon::Solo, sw.solo, "solo", switch_applies(layer, "solo", &app.session.project)),
(Icon::Lock, sw.locked, "lock", switch_applies(layer, "lock", &app.session.project)),
];
for (i, (icon, on, name, applicable)) in av_items.into_iter().enumerate() {
let br = Rect::from_center_size(pos2(cw.av + 10.0 + 18.0 * i as f32, cy), vec2(15.0, 15.0));
if !applicable || !vis.av {
continue;
}
lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well);
layer_switch(
ui,
&t,
&SwitchSpec { row: r, br, icon, on, id: egui::Id::unique(("av", layer.id.0, i)), layer: layer.id.0, name, others: true },
&comp,
&app.session.project,
selected,
&mut *actions,
);
app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name);
}
}
/// The label swatch and its colour menu.
fn layer_label_swatch(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let t = g.tl.t;
let vis = g.tl.vis;
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let selected = &g.selected;
let layer = rc.layer;
let cy = rc.cy;
// Label swatch.
if vis.label {
let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0));
lp.rect_filled(lr, 2.0, t.label(layer.label));
let lresp = ui.interact(lr, egui::Id::unique(("label", layer.id.0)), Sense::click());
app.auto.add(&format!("timeline.layer.{}.label", layer.id.0), lr, layer.label.name());
// Click (or right-click) the swatch for the label menu (After Effects).
let pop = egui::Id::unique(("label-pop", layer.id.0));
if lresp.clicked() || lresp.secondary_clicked() {
widgets::open_popup(ui, pop);
}
if let Some(lab) = widgets::label_popup(ui, pop, lr.left_bottom(), layer.label, |l| app.session.prefs.label_name(l), &t) {
actions.push(("edit.label".into(), json!({"layers": edited_layers(layer.id, selected), "label": lab.name()})));
}
}
}
fn layer_number_and_cells(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let t = g.tl.t;
let vis = g.tl.vis;
let comp = g.tl.comp.clone();
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let layer = rc.layer;
let cy = rc.cy;
if vis.num {
lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", comp.index_of(layer.id).unwrap_or(0)), Tokens::ui(11.5), t.text_dim);
}
layer_cells(app, ui, &lp, cw, &comp, rc, &mut *actions);
}
/// The row's click target, twirl, icon and guide mark; returns the row response and the name's x.
fn layer_twirl_and_icon(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) -> (egui::Response, f32) {
let t = g.tl.t;
let vis = g.tl.vis;
let cw = &g.tl.cw;
let lp = g.lp.clone();
let ui_actions = &mut g.ui_actions;
let layer = rc.layer;
let r = rc.r;
let cy = rc.cy;
// Row click → select; double-click → rename; drag → reorder. Registered before the
// twirl and the name field so those sit on top and get their clicks.
let row_x0 = if vis.num { cw.num } else { cw.name + 16.0 };
let row_resp =
ui.interact(Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)), egui::Id::unique(("row", layer.id.0)), Sense::click_and_drag());
// Twirl + icon + name.
let tw = Rect::from_center_size(pos2(cw.name + 8.0, cy), vec2(14.0, 14.0));
let open = app.ui.timeline.open_layers.contains(&layer.id.0);
if widgets::twirl(ui, tw, open, egui::Id::unique(("twirl", layer.id.0)), &t).clicked() {
ui_actions.push(UiAct::ToggleLayer(layer.id.0, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
}
app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl");
icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim);
// A guide layer carries a guide mark before its name (After Effects).
let name_x = if layer.switches.guide {
let mark = Rect::from_center_size(pos2(cw.name + 40.0, cy), vec2(11.0, 11.0));
icons::paint(&lp, mark, Icon::Grid, t.text_dim);
app.auto.add(&format!("timeline.layer.{}.guide", layer.id.0), mark, "Guide Layer");
cw.name + 50.0
} else {
cw.name + 34.0
};
(row_resp, name_x)
}
/// The layer name: the rename field or the (selected) name cell.
/// Reserve space for source warnings so selection backgrounds and inline renaming cannot
/// obscure the marker or its recovery tooltip.
fn layer_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, name_x: f32) {
let t = g.tl.t;
let ctx = g.tl.ctx.clone();
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let layer = rc.layer;
let r = rc.r;
let cy = rc.cy;
let is_sel = rc.is_sel;
let problem = match &layer.source {
LayerSource::Footage { item } | LayerSource::Comp { item } => {
app.session.project.item(*item).and_then(|i| app.session.footage.item_problem(i).or_else(|| effectcraft_engine::render::missing_item_problem(i)))
}
_ => None,
};
let end = cw.name_end - 4.0;
if let Some(why) = &problem {
let marker = Rect::from_center_size(pos2(end - 7.0, cy), vec2(13.0, 12.0)).intersect(lp.clip_rect());
lp.add(egui::Shape::convex_polygon(vec![marker.center_top(), marker.right_bottom(), marker.left_bottom()], t.warning, Stroke::NONE));
lp.text(marker.center() + vec2(0.0, 1.5), Align2::CENTER_CENTER, "!", Tokens::semibold(9.0), Color32::BLACK);
ui.interact(marker, egui::Id::unique(("tl-problem", layer.id.0)), Sense::hover()).on_hover_text(why);
app.auto.add(&format!("timeline.layer.{}.problem", layer.id.0), marker, why);
}
let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2((end - if problem.is_some() { 17.0 } else { 0.0 }).max(name_x), r.max.y));
let rename_id = egui::Id::unique("tl-rename");
let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id));
if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect.shrink2(vec2(0.0, 2.0))));
let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(name_rect.width()));
app.auto.add(&format!("timeline.layer.{rl}.nameEdit"), er.rect, &buf);
if ctx.data_mut(|d| d.remove_temp::<bool>(rename_focus_id())).unwrap_or(false) {
// Just started: focus the field with the whole name selected.
er.request_focus();
widgets::select_all(&ctx, er.id, &buf);
}
if er.lost_focus() {
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
actions.push(("layer.rename".into(), json!({"layer": rl, "name": buf})));
}
ctx.data_mut(|d| d.remove::<(u64, String)>(rename_id));
} else {
ctx.data_mut(|d| d.insert_temp(rename_id, (rl, buf)));
}
} else {
// The selected layer's name sits in a light cell with dark text.
let name_col = if is_sel { Color32::from_gray(0x16) } else { t.text };
if is_sel {
let g = lp.layout_no_wrap(display_name(app, layer), Tokens::ui(12.0), name_col);
let cell = Rect::from_min_max(
pos2(name_rect.min.x - 3.0, r.min.y + 1.0),
pos2((name_rect.min.x + g.size().x + 4.0).max(name_rect.max.x).min(name_rect.max.x + 2.0), r.max.y - 1.0),
);
lp.rect_filled(cell, 0.0, SELECTED_NAME_BG);
}
let lpn = lp.with_clip_rect(name_rect.intersect(lp.clip_rect()));
lpn.text(pos2(name_rect.min.x, cy), Align2::LEFT_CENTER, display_name(app, layer), Tokens::ui(12.0), name_col);
}
}
/// Row click, drag, double-click and context menu.
fn layer_row_actions(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, row_resp: &egui::Response) {
let cid = g.tl.cid;
let ctx = g.tl.ctx.clone();
let comp = g.tl.comp.clone();
let rows = &g.tl.rows;
let actions = &mut g.tl.actions;
let selected = &g.selected;
let layer = rc.layer;
let left = rc.left;
let is_sel = rc.is_sel;
// Locked layers can't be selected or moved (unlock them with the lock switch).
let locked = layer.switches.locked;
if row_resp.drag_started_by(egui::PointerButton::Primary) && !locked {
// The selected layers move together; an unselected row moves alone.
let moving: Vec<u64> = if is_sel { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] };
ctx.data_mut(|d| d.insert_temp(layer_drag_id(), LayerDrag { comp: cid, owner: row_resp.id, layers: moving }));
}
app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name);
if row_resp.clicked() && !locked {
let m = ui.input(|i| i.modifiers);
if let Some(layers) = m.shift.then(|| shift_range(&ctx, cid, &comp, rows, selected, layer.id)).flatten() {
actions.push(("layer.select".into(), json!({"layers": layers})));
} else {
actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "toggle": m.command})));
set_anchor(&ctx, cid, layer.id);
}
}
if row_resp.double_clicked() {
match double_click_action(layer) {
Some(a) => actions.push(a),
None => start_rename(&ctx, layer.id.0, &layer.name),
}
}
layer_context_menu(row_resp, &comp, layer, selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut *actions);
}
/// The eight layer switches.
fn layer_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let t = g.tl.t;
let vis = g.tl.vis;
let comp = g.tl.comp.clone();
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let selected = &g.selected;
let layer = rc.layer;
let r = rc.r;
let cy = rc.cy;
let sw = &layer.switches;
let sws: [(Icon, bool, &str, bool); 8] = [
(Icon::Shy, sw.shy, "shy", switch_applies(layer, "shy", &app.session.project)),
(Icon::Collapse, sw.collapse, "collapse", switch_applies(layer, "collapse", &app.session.project)),
(Icon::Quality, sw.quality == effectcraft_engine::project::Quality::Best, "quality", switch_applies(layer, "quality", &app.session.project)),
(Icon::Fx, sw.effects, "fx", switch_applies(layer, "fx", &app.session.project)),
(
Icon::FrameBlend,
sw.frame_blend != effectcraft_engine::project::FrameBlend::Off,
"frameBlend",
switch_applies(layer, "frameBlend", &app.session.project),
),
(Icon::MotionBlur, sw.motion_blur, "motionBlur", switch_applies(layer, "motionBlur", &app.session.project)),
(Icon::Adjustment, sw.adjustment, "adjustment", switch_applies(layer, "adjustment", &app.session.project)),
(Icon::Cube, layer.is_3d(), "threeD", switch_applies(layer, "threeD", &app.session.project)),
];
for (i, (icon, on, name, applicable)) in sws.into_iter().enumerate() {
if !applicable || !vis.switches {
continue;
}
let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0));
lp.rect_filled(br.shrink(1.0), 1.0, t.switch_well);
layer_switch(
ui,
&t,
&SwitchSpec { row: r, br, icon, on, id: egui::Id::unique(("sw", layer.id.0, i)), layer: layer.id.0, name, others: false },
&comp,
&app.session.project,
selected,
&mut *actions,
);
app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name);
}
}
/// The blend mode dropdown and Preserve Transparency.
fn layer_mode_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let t = g.tl.t;
let comp = g.tl.comp.clone();
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let selected = &g.selected;
let layer = rc.layer;
let cy = rc.cy;
let mr = Rect::from_min_size(pos2(cw.mode + 2.0, cy - 9.0), vec2(80.0, 18.0));
let mresp = widgets::dropdown(ui, mr, layer.blend_mode.label(), &t, egui::Id::unique(("mode", layer.id.0)));
app.auto.add(&format!("timeline.layer.{}.mode", layer.id.0), mr, "Mode");
let pop = egui::Id::unique(("mode-pop", layer.id.0));
if mresp.clicked() {
widgets::open_popup(ui, pop);
}
let opts: Vec<String> =
BlendMode::ALL.iter().flat_map(|m| if m.ends_group() { vec![m.label().to_string(), "-".to_string()] } else { vec![m.label().to_string()] }).collect();
if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, opts.iter().position(|o| o == layer.blend_mode.label())) {
actions.push(("layer.setBlendMode".into(), json!({"layers": video_targets(&comp, layer.id, selected), "mode": opts[i]})));
}
let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0));
app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency");
let pt = widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::unique(("pt", layer.id.0)));
let pt = match switch_tip("preserveTransparency") {
Some(tip) => pt.on_hover_text(crate::i18n::tr(tip)),
None => pt,
};
if pt.changed() {
// The click gives the selection the state the clicked row toggles to,
// whatever the first selected layer's value (as the switches do).
actions.push((
"layer.setSwitch".into(),
json!({
"layers": video_targets(&comp, layer.id, selected),
"switch": "preserveTransparency",
"value": !layer.preserve_transparency
}),
));
}
}
/// The track matte picker.
fn layer_matte_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let t = g.tl.t;
let comp = g.tl.comp.clone();
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let selected = &g.selected;
let layer = rc.layer;
let cy = rc.cy;
// Track matte: a pick whip, then the layer picker with the mode. Like the parent, they set
// the matte of every selected layer when the row is selected (#1260).
pick_whip(
app,
ui,
&lp,
Rect::from_center_size(pos2(cw.trkmat + 9.0, cy), vec2(15.0, 15.0)),
(WHIP_MATTE, layer.id.0, 0),
WhipText {
auto: &format!("timeline.layer.{}.trackMattePickWhip", layer.id.0),
label: "Track Matte pick whip",
hint: crate::i18n::tr("Track Matte pick whip: drag onto a layer"),
},
);
let mr = Rect::from_min_size(pos2(cw.trkmat + 20.0, cy - 9.0), vec2(88.0, 18.0));
let label = match layer.track_matte {
Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", comp.index_of(l.id).unwrap_or(0), l.name)).unwrap_or("None".into()),
None => "No Track Matte".into(),
};
// The cell is too narrow for the mode as well: it is in the tooltip and ticked in the menu.
let tip = layer.track_matte.map(|m| format!("{label} · {}", crate::i18n::tr(m.kind.label())));
let tresp = widgets::dropdown_tip(ui, mr, &label, tip.as_deref(), &t, egui::Id::unique(("trkmat", layer.id.0)));
app.auto.add(&format!("timeline.layer.{}.trackMatte", layer.id.0), mr, "Track Matte");
let pop = egui::Id::unique(("trk-pop", layer.id.0));
if tresp.clicked() {
widgets::open_popup(ui, pop);
}
// The menu lists every layer: built only while it is open (per visible row
// it made a big comp's timeline quadratic).
let mut opts = vec!["No Track Matte".to_string(), "-".to_string()];
let mut candidates: Vec<&Layer> = vec![];
if widgets::popup_is_open(ui, pop) {
for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id && l.source.is_av()) {
opts.push(format!("{}. {}", i + 1, l.name));
candidates.push(l);
}
opts.push("-".into());
for k in MatteKind::ALL {
opts.push(k.label().into());
}
}
let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label()));
if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) {
let ids: Vec<LayerId> = candidates.iter().map(|l| l.id).collect();
if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(&video_targets(&comp, layer.id, selected), &ids, i, o)) {
actions.push(a);
}
}
if layer.track_matte.is_some() {
let kr = Rect::from_center_size(pos2(mr.max.x - 26.0, cy), vec2(10.0, 10.0));
let _ = kr;
}
}
/// The parent pick whip and dropdown.
fn layer_parent_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let t = g.tl.t;
let comp = g.tl.comp.clone();
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let selected = &g.selected;
let layer = rc.layer;
let cy = rc.cy;
pick_whip(
app,
ui,
&lp,
Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)),
(WHIP_PARENT, layer.id.0, 0),
WhipText {
auto: &format!("timeline.layer.{}.pickWhip", layer.id.0),
label: "Parent pick whip",
hint: crate::i18n::tr("Parent pick whip: drag onto a layer"),
},
);
let pr_rect = Rect::from_min_size(pos2(cw.parent + 20.0, cy - 9.0), vec2(94.0, 18.0));
let plabel = layer.parent.and_then(|p| comp.layer(p)).map(|l| format!("{}. {}", comp.index_of(l.id).unwrap_or(0), l.name)).unwrap_or_else(|| "None".into());
let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::unique(("parent", layer.id.0)));
app.auto.add(&format!("timeline.layer.{}.parent", layer.id.0), pr_rect, "Parent");
let pop = egui::Id::unique(("par-pop", layer.id.0));
if presp.clicked() {
widgets::open_popup(ui, pop);
}
let mut cands: Vec<&Layer> = vec![];
let mut popts = vec!["None".to_string(), "-".to_string()];
if widgets::popup_is_open(ui, pop) {
for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id) {
popts.push(format!("{}. {}", i + 1, l.name));
cands.push(l);
}
}
if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) {
let par = cands.get(i.wrapping_sub(2)).map(|l| l.id.0);
// (The new parent itself, if selected, stays as it is.)
let layers: Vec<u64> = edited_layers(layer.id, selected).into_iter().filter(|l| Some(*l) != par).collect();
actions.push(("layer.setParent".into(), json!({"layers": layers, "parent": par})));
}
}
/// Paints the layer bar; returns it and its label colour.
fn layer_bar_paint(g: &Rw, rc: &RowCtx<'_>) -> (Rect, Color32) {
let t = g.tl.t;
let tm = g.tl.tm;
let gp = g.gp.clone();
let layer = rc.layer;
let r = rc.r;
let is_sel = rc.is_sel;
let x_in = tm.x(layer.in_point.seconds());
let x_out = tm.x(layer.out_point.seconds());
let bar = Rect::from_min_max(pos2(x_in, r.min.y + 1.0), pos2(x_out, r.max.y - 1.0));
let lc = t.label(layer.label);
let fill = bar_color(lc, is_sel);
gp.rect_filled(bar, 0.0, fill);
if is_sel {
gp.rect_stroke(bar, 0.0, Stroke::new(1.0, Color32::from_white_alpha(90)), StrokeKind::Inside);
}
(bar, lc)
}
/// Dragging the source bar outside the in/out span slips the source.
fn slip_source_handles(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, ghost: Rect, bar: Rect) {
let pps = g.tl.pps;
let fd = g.tl.fd;
let ctx = g.tl.ctx.clone();
let actions = &mut g.tl.actions;
let ui_actions = &mut g.ui_actions;
let layer = rc.layer;
// Dragging the source bar outside the in/out span slips the source.
for (side, gr) in [("l", Rect::from_min_max(ghost.min, pos2(bar.min.x, ghost.max.y))), ("r", Rect::from_min_max(pos2(bar.max.x, ghost.min.y), ghost.max))] {
if gr.width() < 2.0 {
continue;
}
let gresp =
ui.interact(gr, egui::Id::unique(("ghost", side, layer.id.0)), Sense::click_and_drag()).on_hover_text(crate::i18n::tr("Drag to slip the source"));
// Outside the bar it is empty track area: a plain click deselects.
if gresp.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
actions.push(("layer.select".into(), json!({"layers": []})));
}
app.auto.add(&format!("timeline.layer.{}.source{}", layer.id.0, side), gr, "Slip");
if gresp.hovered() || gresp.dragged() {
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
let acc_id = egui::Id::unique(("slip-acc", layer.id.0));
if gresp.dragged() {
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
acc += gresp.drag_delta().x;
let frames = ((acc as f64 / pps) / fd).trunc() as i64;
if frames != 0 {
acc -= (frames as f64 * fd * pps) as f32;
actions.push(("layer.slip".into(), json!({"layers": [layer.id.0], "frames": frames, "merge": format!("slip-{}", layer.id.0)})));
}
ctx.data_mut(|d| d.insert_temp(acc_id, acc));
}
if gresp.drag_stopped() {
ctx.data_mut(|d| d.remove::<f32>(acc_id));
ui_actions.push(UiAct::EndMerge);
}
}
}
/// The source extent behind the bar (striped past its ends).
fn layer_source_extent(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, bar: Rect, lc: Color32) {
let tm = g.tl.tm;
let gp = g.gp.clone();
let layer = rc.layer;
let r = rc.r;
if let LayerSource::Footage { .. } | LayerSource::Comp { .. } = layer.source
&& let Some(d) = layer.source.item().and_then(|i| app.session.project.item(i)).and_then(|i| i.duration())
{
let xs = tm.x(layer.start_time.seconds());
let xe = tm.x(layer.comp_time(d).seconds());
let ghost = Rect::from_min_max(pos2(xs.min(xe), r.min.y + 9.0), pos2(xs.max(xe), r.max.y - 9.0));
gp.rect_filled(ghost, 1.0, lc.gamma_multiply(0.18));
// The bar past the source's end (its last frame held) or before its start
// is striped, so the source's own span shows (#290). Time remapping
// decides what shows there itself.
if layer.props.get("timeRemap").is_none() {
for (side, part) in [
("beforeSource", Rect::from_min_max(bar.min, pos2(ghost.min.x, bar.max.y))),
("afterSource", Rect::from_min_max(pos2(ghost.max.x, bar.min.y), bar.max)),
] {
if part.width() >= 1.0 {
stripes(&gp, part, lc);
app.auto.add(&format!("timeline.layer.{}.bar.{side}", layer.id.0), part, "Past the source");
}
}
}
slip_source_handles(app, ui, g, rc, ghost, bar);
}
}
/// The bar's move body and trim edges, click selection.
fn layer_bar_hits(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, bar: Rect) -> BarResps {
let cid = g.tl.cid;
let ctx = g.tl.ctx.clone();
let comp = g.tl.comp.clone();
let rows = &g.tl.rows;
let actions = &mut g.tl.actions;
let selected = &g.selected;
let layer = rc.layer;
let is_sel = rc.is_sel;
app.auto.add(&format!("timeline.layer.{}.bar", layer.id.0), bar, &layer.name);
// Interactions: move body, trim edges.
let (body_r, in_r, out_r) = trim_zones(bar);
let body = ui.interact(body_r, egui::Id::unique(("bar", layer.id.0)), Sense::click_and_drag());
let lin = ui.interact(in_r, egui::Id::unique(("bar-in", layer.id.0)), Sense::drag());
let lout = ui.interact(out_r, egui::Id::unique(("bar-out", layer.id.0)), Sense::drag());
app.auto.add(&format!("timeline.layer.{}.bar.in", layer.id.0), in_r, "Trim In");
app.auto.add(&format!("timeline.layer.{}.bar.out", layer.id.0), out_r, "Trim Out");
if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() {
trim_cursor(&ctx);
}
if body.double_clicked()
&& let Some(a) = double_click_action(layer)
{
actions.push(a);
}
let shift = ui.input(|i| i.modifiers.shift);
if let Some(layers) = (body.clicked() && shift).then(|| shift_range(&ctx, cid, &comp, rows, selected, layer.id)).flatten() {
// Shift-click: the layers from the last one clicked to this one, as in
// the layer outline.
actions.push(("layer.select".into(), json!({"layers": layers})));
} else {
if (body.clicked() || body.drag_started()) && !is_sel {
actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": shift})));
}
if (body.clicked() || body.drag_started()) && !shift {
set_anchor(&ctx, cid, layer.id);
}
}
layer_context_menu(&body, &comp, layer, selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut *actions);
BarResps { body, lin, lout }
}
fn layer_quick_offset_begin(ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>, body: &egui::Response) -> bool {
let ctx = g.tl.ctx.clone();
let comp = g.tl.comp.clone();
let selected = &g.selected;
let layer = rc.layer;
let is_sel = rc.is_sel;
let mods = ui.input(|i| i.modifiers);
if body.drag_started()
&& is_sel
&& mods.command
&& mods.alt
&& let Some(n) = quick_offset_layers(&comp, selected.iter().copied())
&& let Some(p) = ctx.input(|i| i.pointer.press_origin())
{
ctx.data_mut(|d| d.insert_temp(quick_bar_id(), QuickBar { layer: layer.id, origin_x: p.x, layers: n, applied: 0 }));
}
ctx.data(|d| d.get_temp::<QuickBar>(quick_bar_id())).is_some_and(|q| q.layer == layer.id)
}
/// One layer bar drag: the layers it edits, the pointer's travel (px), the grabbed layer's moving
/// ends and the snap targets when it started (comp seconds), and the whole frames sent so far.
#[derive(Clone)]
struct BarDrag {
edited: Vec<u64>,
px: f64,
ends: Vec<f64>,
targets: Arc<[f64]>,
sent: i64,
}
/// The whole frames a bar drag of `px` moves its ends by: truncated as the pointer goes, or with
/// `shift`, the move that puts one of `ends` on the nearest target within 8 px of the pointer
/// (#1828). Measured from the pointer, never the snapped result, so dragging on lets go. The
/// move stays whole frames, so an end off the frame grid lands within half a frame of its target.
fn bar_drag_frames(px: f64, pps: f64, fd: f64, ends: &[f64], targets: &[f64], shift: bool) -> i64 {
let secs = px / pps;
let snapped = shift
.then(|| {
ends.iter()
.flat_map(|e| targets.iter().map(move |t| t - e))
.filter(|d| ((d - secs) * pps).abs() <= 8.0)
.min_by(|a, b| (a - secs).abs().total_cmp(&(b - secs).abs()))
})
.flatten();
// `as` saturates (NaN → 0), so a zero frame duration or zoom can't panic.
snapped.map_or((secs / fd).trunc() as i64, |d| (d / fd).round() as i64)
}
/// Moving and trimming a layer bar by whole frames; Shift snaps its ends to the current time,
/// markers, keys, the work area and other layers' In / Out points, as in After Effects (#1828).
fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) {
let BarResps { body, lin, lout } = bars;
let ctx = g.tl.ctx.clone();
let layer = rc.layer;
let drag_key = format!("bar-{}", layer.id.0);
for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] {
if kind == "move" && quick {
continue;
}
let id = egui::Id::unique(("bar-drag", layer.id.0, kind));
if resp.dragged() {
let shift = ctx.input(|i| i.modifiers.shift);
// Moving or trimming a selected layer acts on the whole selection as it is when the
// drag starts; each layer trims from its own in/out point. A Shift-drag on an
// unselected bar adds it to the selection (`layer_bar_hits`) and moves them all.
let start = || {
let mut edited = edited_layers(layer.id, &g.selected);
if kind == "move" && shift && !g.selected.contains(&layer.id) {
edited.extend(g.selected.iter().map(|l| l.0));
}
let (i, o) = (layer.in_point.seconds(), layer.out_point.seconds());
let ends = match kind {
"move" => vec![i, o],
"in" => vec![i],
_ => vec![o],
};
let mut targets = snap_targets(&g.tl.comp, &g.tl.rows, &edited, kind == "move");
targets.push(g.tl.time.seconds());
BarDrag { edited, px: 0.0, ends, targets: targets.into(), sent: 0 }
};
let mut drag = ctx.data(|d| d.get_temp::<BarDrag>(id)).filter(|_| !resp.drag_started()).unwrap_or_else(start);
drag.px += f64::from(resp.drag_delta().x);
let frames = bar_drag_frames(drag.px, g.tl.pps, g.tl.fd, &drag.ends, &drag.targets, shift);
if frames != drag.sent {
let d = frames.saturating_sub(drag.sent) as f64 * g.tl.fd;
drag.sent = frames;
let params = match kind {
"move" => json!({"layers": drag.edited, "delta": d, "merge": drag_key}),
"in" => json!({"layers": drag.edited, "inDelta": d, "merge": drag_key}),
_ => json!({"layers": drag.edited, "outDelta": d, "merge": drag_key}),
};
g.tl.actions.push(("layer.timing".into(), params));
}
ctx.data_mut(|d| d.insert_temp(id, drag));
}
if resp.drag_stopped() {
ctx.data_mut(|d| d.remove::<BarDrag>(id));
g.ui_actions.push(UiAct::EndMerge);
}
}
}
fn layer_bar_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>) {
let graph_x0 = g.tl.graph_x0;
let tm = g.tl.tm;
let rows_rect = g.tl.rows_rect;
let comp = g.tl.comp.clone();
let layer = rc.layer;
let r = rc.r;
let gclip = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
super::markers_ui::layer_markers(app, ui, gclip, &comp, layer, tm, r);
}
/// The group row's background and effect label bar; returns the name to draw.
fn group_background(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) -> String {
let t = g.tl.t;
let graph_x0 = g.tl.graph_x0;
let lp = g.lp.clone();
let gp = g.gp.clone();
let layer = rc.layer;
let r = rc.r;
let left = rc.left;
let ri = rc.ri;
let RowKind::Group { uid, name, .. } = &rc.row.kind else { return String::new() };
let group = layer.props.find_group(*uid);
let shown = match group.and_then(|g| match &g.kind {
GroupKind::Effect { effect } => Some(effect.as_str()),
_ => None,
}) {
Some(effect) => crate::i18n::effects::group(effect, name),
None => name.clone(),
};
// Selected groups (an effect, a mask, a shape group…) highlight like selected
// property rows.
let sel_group = app.session.state.selected_props.contains(&(layer.id, *uid));
lp.rect_filled(
left,
0.0,
if sel_group {
t.row_selected
} else if ri.is_multiple_of(2) {
t.row
} else {
t.row_alt
},
);
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg);
// An effect's colour label (26.5): the same bar the Effect Controls header wears,
// on the row's left edge.
if let Some(lab) =
group.filter(|g| matches!(g.kind, GroupKind::Effect { .. })).and_then(|g| g.label).filter(|l| *l != effectcraft_engine::color::Label::None)
{
let [lr, lg, lb] = app.session.prefs.label_rgb(lab);
let bar = Rect::from_min_size(left.min, vec2(4.0, left.height()));
lp.rect_filled(bar, 0.0, Color32::from_rgb(lr, lg, lb));
app.auto.add(&format!("timeline.group.{uid}.label"), bar, &app.session.prefs.label_name(lab));
}
shown
}
/// The group's twirl, effect switch and style eye.
fn group_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let t = g.tl.t;
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let ui_actions = &mut g.ui_actions;
let layer = rc.layer;
let cy = rc.cy;
let indent = rc.indent;
let RowKind::Group { uid, name, open, has_children, fx, eye, .. } = &rc.row.kind else { return };
if *has_children {
let tw = Rect::from_center_size(pos2(indent, cy), vec2(13.0, 13.0));
if widgets::twirl(ui, tw, *open, egui::Id::unique(("gtw", uid)), &t).clicked() {
// Alt/Cmd-click twirls the whole tree under the group (as on a layer).
ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
}
app.auto.add(&format!("timeline.group.{uid}.twirl"), tw, name);
}
if let Some(en) = fx {
let fr_ = Rect::from_center_size(pos2(cw.switches + SW * 3.0 + SW / 2.0, cy), vec2(16.0, 16.0));
if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::unique(("gfx", uid)), Sense::click()).clicked() {
actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": uid})));
}
app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name);
}
if let Some(en) = eye {
// A layer style's or shape item's eye switch in the A/V column, like AE.
let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(15.0, 15.0));
lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::unique(("geye", uid)), Sense::click()).clicked() {
let style = layer.props.sub(effectcraft_engine::project::styles::GROUP).is_some_and(|s| s.find_group(*uid).is_some());
actions.push(if style {
("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid}))
} else {
("prop.setGroupEnabled".into(), json!({"layer": layer.id.0, "prop": uid, "value": !*en}))
});
}
app.auto.add(&format!("timeline.group.{uid}.eye"), er, name);
}
}
/// The group name, or its rename field.
fn group_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, shown: &str) {
let t = g.tl.t;
let ctx = g.tl.ctx.clone();
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let layer = rc.layer;
let r = rc.r;
let cy = rc.cy;
let indent = rc.indent;
let RowKind::Group { uid, name, .. } = &rc.row.kind else { return };
let group_edit: Option<(u64, u64, String)> = ctx.data(|d| d.get_temp(group_rename_id()));
if let Some((_, _, mut buf)) = group_edit.filter(|(rl, ru, _)| *rl == layer.id.0 && ru == uid) {
let field = Rect::from_min_max(pos2(indent + 8.0, r.min.y + 2.0), pos2((cw.switches - 4.0).max(indent + 60.0), r.max.y - 2.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field));
let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(field.width()));
app.auto.add(&format!("timeline.group.{uid}.nameEdit"), er.rect, &buf);
if ctx.data_mut(|d| d.remove_temp::<bool>(rename_focus_id())).unwrap_or(false) {
er.request_focus();
widgets::select_all(&ctx, er.id, &buf);
}
if er.lost_focus() {
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !buf.trim().is_empty() && buf != *name {
actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": uid, "name": buf})));
}
ctx.data_mut(|d| d.remove::<(u64, u64, String)>(group_rename_id()));
} else {
ctx.data_mut(|d| d.insert_temp(group_rename_id(), (layer.id.0, *uid, buf)));
}
} else {
lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, shown, Tokens::ui(12.0), t.text);
}
}
/// Add / animate popups, Change Renderer and dash buttons.
fn group_popups(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let t = g.tl.t;
let ctx = g.tl.ctx.clone();
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let ui_actions = &mut g.ui_actions;
let layer = rc.layer;
let cy = rc.cy;
let RowKind::Group { uid, .. } = &rc.row.kind else { return };
text_anim_popups(app, ui, &lp, layer, *uid, pos2(cw.switches, cy), &mut *actions);
shape_add_popup(app, ui, &lp, layer, *uid, pos2(cw.switches, cy), &mut *actions);
if *uid == GEOM_BIT | layer.id.0 {
let lr = lp.text(pos2(cw.switches + 6.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Change Renderer…"), Tokens::ui(11.5), t.accent);
let resp = ui
.interact(lr, egui::Id::unique(("tl-change-renderer", layer.id.0)), Sense::click())
.on_hover_text(crate::i18n::tr("Composition Settings ▸ 3D Renderer"));
if resp.hovered() {
ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
}
app.auto.add(&format!("timeline.layer.{}.changeRenderer", layer.id.0), lr, "Change Renderer…");
if resp.clicked() {
ui_actions.push(UiAct::ChangeRenderer);
}
}
dash_buttons(app, ui, &lp, layer, *uid, pos2(cw.switches, cy), &mut *actions);
}
/// Mask mode and inverted, inline.
fn group_mask_mode(ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let t = g.tl.t;
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let layer = rc.layer;
let cy = rc.cy;
let RowKind::Group { uid, .. } = &rc.row.kind else { return };
// Mask mode + inverted inline.
if let Some(g) = layer.props.find_group(*uid)
&& let GroupKind::Mask { mode, inverted, .. } = g.kind
{
let mr = Rect::from_min_size(pos2(cw.switches + 4.0, cy - 9.0), vec2(80.0, 18.0));
let pop = egui::Id::unique(("maskmode", uid));
if widgets::dropdown(ui, mr, mode.label(), &t, egui::Id::unique(("mm", uid))).clicked() {
widgets::open_popup(ui, pop);
}
let opts: Vec<String> = effectcraft_engine::project::MaskMode::ALL.iter().map(|m| m.label().to_string()).collect();
if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, None) {
actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "mode": opts[i]})));
}
let ir = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 8.0), vec2(16.0, 16.0));
if widgets::checkbox(ui, ir, inverted, &t, egui::Id::unique(("minv", uid))).changed() {
actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "inverted": !inverted})));
}
lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Inverted"), Tokens::ui(11.0), t.text_dim);
}
}
/// Group row click and double-click.
fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
let cid = g.tl.cid;
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let ui_actions = &mut g.ui_actions;
let layer = rc.layer;
let r = rc.r;
let indent = rc.indent;
let RowKind::Group { uid, name, fx, .. } = &rc.row.kind else { return };
let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
// Effects, masks, shape items and animators drag to a new place among their siblings.
let movable = !layer.switches.locked && layer.props.find_group(*uid).is_some_and(PropGroup::is_instance);
let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), if movable { Sense::click_and_drag() } else { Sense::click() });
if movable && gr.drag_started_by(egui::PointerButton::Primary) {
ui.ctx().data_mut(|d| d.insert_temp(group_drag_id(), GroupDrag { comp: cid, owner: gr.id, layer: layer.id, uid: *uid }));
}
app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
// Shift or Cmd/Ctrl adds the group to the selection, or takes a selected one out, as
// property rows and Effect Controls headers do (#2552: several effects and styles for
// one animation preset).
let add = ui.input(|i| i.modifiers.shift || i.modifiers.command);
if add && app.session.state.selected_props.contains(&(layer.id, *uid)) {
actions.push(("prop.deselect".into(), json!({"layer": layer.id.0, "prop": uid})));
} else {
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false, "add": add})));
}
}
if gr.double_clicked() {
if fx.is_some() {
// An effect's name: open it in Effect Controls (as in After Effects).
actions.push(("layer.select".into(), json!({"layers": [layer.id.0]})));
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
actions.push(("window.panel".into(), json!({"panel": "EffectControls"})));
} else {
ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, false));
}
}
}
fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) {
let t = g.tl.t;
let graph_x0 = g.tl.graph_x0;
let tm = g.tl.tm;
let ctx = g.tl.ctx.clone();
let cw = &g.tl.cw;
let lp = g.lp.clone();
let gp = g.gp.clone();
let row = rc.row;
let layer = rc.layer;
let r = rc.r;
let left = rc.left;
let cy = rc.cy;
let ri = rc.ri;
let RowKind::Waveform { item, .. } = &rc.row.kind else { return };
lp.rect_filled(left, 0.0, if ri.is_multiple_of(2) { t.row } else { t.row_alt });
let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Waveform"), Tokens::ui(12.0), t.text);
let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max);
gp.rect_filled(wr, 0.0, t.tl_bg);
app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform");
match super::waveform::summary(app, &ctx, ItemId(*item)) {
Some(s) => super::waveform::draw(&gp, wr.shrink2(vec2(0.0, 2.0)), &tm, layer, &s, Color32::from_rgb(0x5f, 0xc8, 0x8a)),
None => {
gp.text(
pos2(tm.x(layer.in_point.seconds()).max(wr.min.x) + 6.0, wr.center().y),
Align2::LEFT_CENTER,
crate::i18n::tr("Building waveform…"),
Tokens::ui(11.0),
t.text_faint,
);
}
}
}
/// The expression row's outline side: enable, pick whip and syntax warning.
fn expr_controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, st: &ExprRow<'_>) {
let ExprRow { prop, ex, buf_id, edit_id, .. } = *st;
let t = g.tl.t;
let ctx = g.tl.ctx.clone();
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let row = rc.row;
let layer = rc.layer;
let left = rc.left;
let cy = rc.cy;
let ri = rc.ri;
let RowKind::Expr { uid, .. } = &rc.row.kind else { return };
let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
lp.rect_filled(
left,
0.0,
if sel_prop {
t.row_selected
} else if ri.is_multiple_of(2) {
t.row
} else {
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);
lp.text(pos2(indent + 82.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(11.5), t.text);
// "=" enable switch and the property pick whip (AE's expression controls).
let en_r = Rect::from_center_size(pos2(cw.switches + 10.0, cy), vec2(16.0, 16.0));
let en = ui.interact(en_r, egui::Id::unique(("expr-en", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Enable Expression"));
lp.rect_stroke(en_r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
let expr_col = t.expr_text;
lp.text(en_r.center(), Align2::CENTER_CENTER, if ex.enabled { "=" } else { "≠" }, Tokens::semibold(13.0), if ex.enabled { expr_col } else { t.text_dim });
app.auto.add(&format!("timeline.prop.{uid}.exprEnable"), en_r, "Enable Expression");
if en.clicked() {
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "enabled": !ex.enabled})));
}
let pw = pick_whip(
app,
ui,
&lp,
Rect::from_center_size(pos2(cw.switches + 30.0, cy), vec2(15.0, 15.0)),
(WHIP_EXPR, layer.id.0, *uid),
WhipText {
auto: &format!("timeline.prop.{uid}.pickWhip"),
label: "Expression pick whip",
hint: crate::i18n::tr("Expression pick whip: drag onto a property"),
},
);
if pw.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) {
// 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>(buf_id)).map(|buf| {
let n = buf.chars().count();
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)
});
ctx.data_mut(|d| {
if let Some(into) = into {
d.insert_temp(whip_into_id(*uid), into);
} else {
d.remove::<WhipInto>(whip_into_id(*uid));
}
});
}
// Syntax errors: warning in the outline (the expression is disabled).
if let Some(check) = app.session.expr_check
&& let Err(e) = check(&ex.text)
{
let wr = Rect::from_center_size(pos2(cw.switches + 50.0, cy), vec2(14.0, 14.0));
lp.text(wr.center(), Align2::CENTER_CENTER, "⚠", Tokens::ui(12.0), Color32::from_rgb(0xf0, 0xa0, 0x30));
let _ = ui.interact(wr, egui::Id::unique(("expr-err", uid)), Sense::hover()).on_hover_text(e);
}
}
/// The Expression Language menu inserts at the cursor.
fn expr_language_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, st: &ExprRow<'_>) {
let ExprRow { ex, buf_id, edit_id, .. } = *st;
let t = g.tl.t;
let ctx = g.tl.ctx.clone();
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let layer = rc.layer;
let cy = rc.cy;
let RowKind::Expr { uid, .. } = &rc.row.kind else { return };
// ⓕ: the Expression Language menu inserts at the cursor.
let lang_r = Rect::from_center_size(pos2(cw.switches + 70.0, cy), vec2(16.0, 16.0));
let lang = ui.interact(lang_r, egui::Id::unique(("expr-lang", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Expression Language Menu"));
lp.circle_stroke(lang_r.center(), 6.5, Stroke::new(1.0, if lang.hovered() { t.text } else { t.text_dim }));
lp.text(lang_r.center(), Align2::CENTER_CENTER, "f", Tokens::semibold(10.0), if lang.hovered() { t.text } else { t.text_dim });
app.auto.add(&format!("timeline.prop.{uid}.exprLanguage"), lang_r, "Expression Language Menu");
let mut picked: Option<&'static str> = None;
egui::Popup::menu(&lang).style(crate::widgets::menu_style).show(|ui| {
for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() {
ui.menu_button(cat, |ui| {
widgets::menu_scroll(ui, false, |ui| {
for (label, text) in items {
if ui.button(label).clicked() {
picked = Some(text);
ui.close();
}
}
})
});
}
});
if let Some(text) = picked {
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())
.map(|r| r.primary.index.0)
.unwrap_or(cur.chars().count())
.min(cur.chars().count());
let byte = cur.char_indices().nth(at).map(|(b, _)| b).unwrap_or(cur.len());
let mut next = cur.clone();
next.insert_str(byte, text);
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": next})));
ctx.data_mut(|d| d.remove::<String>(buf_id));
}
}
/// The expression editor in the time-graph area.
fn expr_editor_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, st: &ExprRow<'_>) {
let ExprRow { prop, ex, buf_id, edit_id, .. } = *st;
let t = g.tl.t;
let rect = g.tl.rect;
let graph_x0 = g.tl.graph_x0;
let rh = g.tl.rh;
let ctx = g.tl.ctx.clone();
let actions = &mut g.tl.actions;
let right_clip = g.right_clip;
let gp = g.gp.clone();
let layer = rc.layer;
let r = rc.r;
let RowKind::Expr { uid, lines, .. } = &rc.row.kind else { return };
let expr_col = t.expr_text;
// The editor in the time-graph area.
ui.set_clip_rect(right_clip);
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.expr_bg);
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 mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
if let Some(f) = ctx.data(|d| d.get_temp::<ExprFocus>(expr_focus_id())).filter(|f| f.prop == *uid) {
// Asked for once (a pick whip put a reference in, or an Alt-click added the
// expression): editing starts in the expression as it is now.
ctx.data_mut(|d| d.remove::<ExprFocus>(expr_focus_id()));
buf = ex.text.clone();
let n = buf.chars().count();
let [a, b] = f.select.map(|before_end| egui::text::CCursor::new(n.saturating_sub(before_end)));
let mut st = egui::text_edit::TextEditState::load(&ctx, edit_id).unwrap_or_default();
st.cursor.set_char_range(Some(egui::text::CCursorRange::two(a, b)));
st.store(&ctx, edit_id);
ctx.memory_mut(|m| m.request_focus(edit_id));
}
// Settings ▸ Scripting & Expressions ▸ Expressions Editor.
let sp = app.session.prefs.scripting.clone();
let resp = super::expr_editor::editor(
ui,
edit_id,
&mut buf,
er,
super::expr_editor::EditorOpts { prefs: &sp, color: if ex.enabled { expr_col } else { t.text_dim }, rows: *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::unique(("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) / 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();
}
if resp.has_focus() && !commit {
ctx.data_mut(|d| d.insert_temp(buf_id, buf));
} else {
if (resp.lost_focus() || commit) && buf.trim_end() != ex.text {
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": buf.trim_end()})));
}
ctx.data_mut(|d| d.remove::<String>(buf_id));
}
}
fn prop_background(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>, prop: &Property) {
let t = g.tl.t;
let graph_x0 = g.tl.graph_x0;
let lp = g.lp.clone();
let gp = g.gp.clone();
let layer = rc.layer;
let r = rc.r;
let left = rc.left;
let ri = rc.ri;
let uid = &prop.uid;
app.auto.add(&format!("timeline.prop.{uid}.row"), r, &prop.name);
let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
lp.rect_filled(
left,
0.0,
if sel_prop {
t.row_selected
} else if ri.is_multiple_of(2) {
t.row
} else {
t.row_alt
},
);
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg);
}
/// The keyframe navigator (previous, toggle, next).
fn prop_key_navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property) {
let t = g.tl.t;
let time = g.tl.time;
let fr = g.tl.fr;
let vis = g.tl.vis;
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let ui_actions = &mut g.ui_actions;
let layer = rc.layer;
let cy = rc.cy;
let uid = &prop.uid;
// Keyframe navigator (in the A/V column, like AE).
// (in the Keys column when it is shown).
if prop.is_animated() && (vis.keys || vis.av) {
let lt = layer.layer_time(time);
let at_key = effectcraft_engine::keyframe::key_at(&prop.keys, lt).is_some();
let nav_x = if vis.keys { cw.keys + 12.0 } else { cw.av + 18.0 };
let prev = Rect::from_center_size(pos2(nav_x, cy), vec2(12.0, 14.0));
let mid = Rect::from_center_size(pos2(nav_x + 16.0, cy), vec2(14.0, 14.0));
let next = Rect::from_center_size(pos2(nav_x + 32.0, cy), vec2(12.0, 14.0));
icons::paint(&lp, prev.shrink(2.0), Icon::ChevronLeft, t.text_dim);
icons::paint(&lp, next.shrink(2.0), Icon::ChevronRight, t.text_dim);
let kc = if at_key { t.keyframe_selected } else { Color32::TRANSPARENT };
lp.add(egui::Shape::convex_polygon(
vec![mid.center() + vec2(0.0, -5.0), mid.center() + vec2(5.0, 0.0), mid.center() + vec2(0.0, 5.0), mid.center() + vec2(-5.0, 0.0)],
kc,
Stroke::new(1.0, t.text_dim),
));
if ui.interact(prev, egui::Id::unique(("kprev", uid)), Sense::click()).clicked()
&& let Some(k) = prop.keys.iter().rev().find(|k| layer.comp_time(k.time).0 < time.0 - fr.frame_duration().0 / 2)
{
ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
}
if ui.interact(next, egui::Id::unique(("knext", uid)), Sense::click()).clicked()
&& let Some(k) = prop.keys.iter().find(|k| layer.comp_time(k.time).0 > time.0 + fr.frame_duration().0 / 2)
{
ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
}
if ui.interact(mid, egui::Id::unique(("kmid", uid)), Sense::click()).clicked() {
actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid})));
}
app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe");
}
}
/// The stopwatch.
fn prop_stopwatch(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property) {
let t = g.tl.t;
let ctx = g.tl.ctx.clone();
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let layer = rc.layer;
let r = rc.r;
let cy = rc.cy;
let indent = rc.indent;
let uid = &prop.uid;
// Stopwatch: Alt+click adds an expression; a press toggles the animation, and
// dragging on over other stopwatches gives them the same state (#284).
if !prop.static_only {
let swr = Rect::from_center_size(pos2(indent, cy), vec2(15.0, 15.0));
let resp = ui.interact(swr, egui::Id::unique(("stopwatch", uid)), Sense::click_and_drag());
let col = if prop.is_animated() {
t.hot_text
} else if resp.hovered() {
t.text
} else {
t.text_dim
};
icons::paint(&lp, swr, Icon::Stopwatch, col);
app.auto.add(&format!("timeline.prop.{uid}.stopwatch"), swr, &prop.name);
if ui.input(|i| i.modifiers.alt) {
if resp.clicked() {
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid})));
if prop.expr.is_none() {
// Adding one: its editor opens, comes into view and takes the
// keyboard with the new expression selected (#1072).
app.ui.timeline.expr_closed.remove(uid);
focus_expression(&ctx, *uid, [usize::MAX, 0]);
} else if ctx.data(|d| d.get_temp::<ExprFocus>(expr_focus_id())).is_some_and(|f| f.prop == *uid) {
// Removing it: nothing is left to take the keyboard.
ctx.data_mut(|d| d.remove::<ExprFocus>(expr_focus_id()));
}
}
} else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) {
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge})));
} else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
// Keyboard or accessibility activation.
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid})));
}
}
}
/// The property name; returns its x.
fn prop_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property) -> f32 {
let t = g.tl.t;
let cid = g.tl.cid;
let ctx = g.tl.ctx.clone();
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let layer = rc.layer;
let r = rc.r;
let cy = rc.cy;
let indent = rc.indent;
let uid = &prop.uid;
let name_x = indent + 12.0;
// 3D layers show Rotation as "Z Rotation" next to X/Y Rotation.
let is_tr_rot = layer.transform().and_then(|t| t.get("rotation")).is_some_and(|r| r.uid == prop.uid);
let base = if is_tr_rot && prop.name == "Rotation" && layer.is_3d() { "Z Rotation".to_string() } else { prop.name.clone() };
let pname = if prop.has_expression() { format!("{base} =") } else { base };
lp.text(pos2(name_x, cy), Align2::LEFT_CENTER, &pname, Tokens::ui(12.0), if prop.has_expression() { t.expr_text } else { t.text });
let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y));
let name_resp = ui.interact(name_rect, egui::Id::unique(("pname", uid)), Sense::click_and_drag());
app.auto.add(&format!("timeline.prop.{uid}.name"), name_rect, &prop.name);
if name_resp.drag_started() {
// Drop on the Essential Graphics panel to expose the property.
egui::DragAndDrop::set_payload(&ctx, crate::panels::DragPayload::Property { layer: layer.id.0, prop: *uid });
}
if name_resp.dragged() {
egui::Tooltip::always_open(ctx.clone(), ui.layer_id(), egui::Id::unique("prop-drag-tip"), egui::PopupAnchor::Pointer).show(|ui| ui.label(&prop.name));
}
if name_resp.clicked() {
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)})));
}
crate::widgets::context_menu(&name_resp, |ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut *actions));
name_x
}
/// The value editors and the property pick whip.
fn prop_value_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, ectx: &EvalCtx<'_>, prop: &Property, name_x: f32) {
let vis = g.tl.vis;
let cw = &g.tl.cw;
let actions = &mut g.tl.actions;
let lp = g.lp.clone();
let dim_hits = &mut g.dim_hits;
let layer = rc.layer;
let cy = rc.cy;
let uid = &prop.uid;
let value = ectx.value(layer, prop);
let vx = (cw.switches + 4.0).max(name_x + 120.0);
let ve = ValueEdit::new(ui, &lp, layer, prop, pos2(vx, cy));
let dims = value_editor(app, ui, &ve, &value, &mut *actions);
if !dims.is_empty() {
dim_hits.push((*uid, dims));
}
// The property pick whip (Parent & Link column, AE CC 2018 and later): dropped on
// a property it links this one to it, adding the expression if there is none.
if vis.parent && !prop.static_only {
pick_whip(
app,
ui,
&lp,
Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)),
(WHIP_PROP, layer.id.0, *uid),
WhipText {
auto: &format!("timeline.prop.{uid}.propertyPickWhip"),
label: "Property pick whip",
hint: crate::i18n::tr("Property pick whip: drag onto a property"),
},
);
}
}
/// The one-line expression hint when its editor is closed.
fn prop_expression_hint(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>, prop: &Property) {
let t = g.tl.t;
let graph_x0 = g.tl.graph_x0;
let right_clip = g.right_clip;
let gp = g.gp.clone();
let cy = rc.cy;
let uid = &prop.uid;
ui.set_clip_rect(right_clip);
// Expression text row hint.
if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) {
gp.text(pos2(graph_x0 + 8.0, cy), Align2::LEFT_CENTER, format!("= {}", e.text.lines().next().unwrap_or("")), Tokens::mono(11.0), t.expr_text);
}
}
/// Key click selection and interpolation shortcuts.
fn key_select(g: &mut Rw, kh: &KeyHit<'_>) {
let KeyHit { k, ks, mods, ref kresp, ref this_key, .. } = *kh;
let actions = &mut g.tl.actions;
if kresp.clicked() && mods.command {
// Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off.
actions.push(("keys.select".into(), this_key.clone()));
if mods.alt {
actions.push(("keys.toggleHold".into(), json!({})));
} else {
let linear = k.in_interp == Interp::Linear && k.out_interp == Interp::Linear;
actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
}
} else if kresp.clicked() && mods.shift {
// Shift+click: in or out of the selection.
let mut p = this_key.clone();
p["toggle"] = json!(true);
actions.push(("keys.select".into(), p));
} else if (kresp.clicked() || kresp.drag_started()) && !ks {
let mut p = this_key.clone();
p["add"] = json!(mods.shift);
actions.push(("keys.select".into(), p));
}
}
fn key_double_click(g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_>) {
let KeyHit { k, ct, ref kresp, .. } = *kh;
let actions = &mut g.tl.actions;
let ui_actions = &mut g.ui_actions;
let layer = rc.layer;
let uid = &prop.uid;
if kresp.double_clicked() {
// Edit the key's value (numeric properties), as After Effects does.
if matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
actions.push(("keys.set".into(), json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})));
} else {
ui_actions.push(UiAct::SetTime(ct));
}
}
}
/// The keyframe right-click menu.
fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_>) {
let KeyHit { k, ks, ref kresp, .. } = *kh;
let actions = &mut g.tl.actions;
let layer = rc.layer;
crate::widgets::context_menu(kresp, |ui| key_menu(ui, app, layer, prop, k, ks, actions));
}
/// The commands of a keyframe's right-click menu, for the Timeline and the Graph Editor alike. They act
/// on the selected keys, so a menu opened on an unselected key selects it first. A locked layer's keys
/// can still be copied and selected, but not edited.
pub(crate) fn key_menu(
ui: &mut egui::Ui,
app: &EffectcraftApp,
layer: &Layer,
prop: &Property,
k: &effectcraft_engine::keyframe::Keyframe,
selected: bool,
actions: &mut Vec<(String, serde_json::Value)>,
) {
let t = app.tokens;
let locked = layer.switches.locked;
let uid = &prop.uid;
let this_key = json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()});
for (lbl, cmd, params) in [
("Edit Value…", "keys.set", this_key.clone()),
("-", "", json!({})),
("Copy", "keys.copy", json!({})),
("Paste", "keys.paste", json!({})),
("-", "", json!({})),
("Select Equal Keyframes", "keys.selectEqual", json!({})),
("Select Previous Keyframes", "keys.selectPrevious", json!({})),
("Select Following Keyframes", "keys.selectFollowing", json!({})),
("-", "", json!({})),
("Keyframe Interpolation…", "keys.interpolation", json!({})),
("Keyframe Velocity…", "keys.velocity", json!({})),
("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
(if k.muted { "Unmute Keyframes" } else { "Mute Keyframes" }, "keys.setMuted", json!({"muted": !k.muted})),
("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
("-", "", json!({})),
("Easy Ease", "keys.easyEase", json!({})),
("Easy Ease In", "keys.easyEaseIn", json!({})),
("Easy Ease Out", "keys.easyEaseOut", json!({})),
("Time-Reverse Keyframes", "keys.timeReverse", json!({})),
("-", "", json!({})),
("Linear", "keys.interpolation", json!({"interpolation": "linear"})),
("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})),
("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})),
("Select All Keyframes", "keys.selectAll", json!({})),
("Delete", "keys.delete", json!({})),
] {
if lbl == "Select All Keyframes" {
// Keyframe colour labels.
ui.add_enabled_ui(!locked, |ui| {
ui.menu_button(crate::i18n::tr("Label"), |ui| {
for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() {
if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), k.label as usize == i, &t).clicked() {
if !selected {
actions.push(("keys.select".into(), json!({"keys": [this_key.clone()]})));
}
actions.push(("keys.setLabel".into(), json!({"label": i})));
ui.close();
}
}
});
});
}
if lbl == "-" {
ui.separator();
continue;
}
if lbl == "Rove Across Time" && !prop.spatial {
continue;
}
if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
continue;
}
let edits = !matches!(cmd, "keys.copy" | "keys.selectEqual" | "keys.selectPrevious" | "keys.selectFollowing" | "keys.selectAll");
// An unselected key is selected before the command runs, and locked keys cannot be selected.
let unselectable = locked && !selected && cmd != "keys.selectAll";
if ui.add_enabled(!(unselectable || locked && edits), egui::Button::new(crate::i18n::context_item(cmd, &params, lbl))).clicked() {
if !selected {
actions.push(("keys.select".into(), json!({"keys": [this_key.clone()]})));
}
actions.push((cmd.into(), params));
ui.close();
}
}
}
/// Starts a key drag (or Alt-scale).
fn key_drag_begin(app: &mut EffectcraftApp, g: &Rw, kh: &KeyHit<'_>) {
let KeyHit { ct, ks, mods, ref kresp, .. } = *kh;
let ctx = g.tl.ctx.clone();
let comp = g.tl.comp.clone();
let sel_keys = &g.sel_keys;
// Alt-drag the first or last key of a selected group: scale the group in time.
if kresp.drag_started()
&& mods.alt
&& !mods.command
&& let Some(c) = app.session.active_comp_arc()
{
super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
}
// A plain drag moves the selected keys. The timeline follows it (below): this
// widget's id changes with the key's time, so its own drag would end after
// the first frame of movement.
// (From where the button went down: a drag starts a few pixels later.)
if kresp.drag_started()
&& !super::graph_tools::alt_scale_active(&ctx)
&& let Some(p) = ctx.input(|i| i.pointer.press_origin())
{
// Ctrl/Cmd+Alt: Quick Offset of the selected keys' layers (#1012).
let quick = (mods.command && mods.alt && ks).then(|| quick_offset_layers(&comp, sel_keys.iter().map(|k| k.layer))).flatten();
ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0, quick }));
}
}
/// The size of a keyframe glyph and the radius of a roving key's dot.
const KEY_GLYPH_SIZE: f32 = 11.0;
const ROVING_KEY_RADIUS: f32 = 2.5;
/// The rect a key's marker covers on screen (its hit rect is larger).
fn key_glyph_rect(k: &effectcraft_engine::keyframe::Keyframe, center: Pos2) -> Rect {
if k.roving {
Rect::from_center_size(center, Vec2::splat(ROVING_KEY_RADIUS * 2.0))
} else {
let icon = k.icon();
icons::keyframe_bounds(center, KEY_GLYPH_SIZE, icon.left, icon.right)
}
}
/// Paints one keyframe marker; returns its hit rect.
fn key_paint(g: &Rw, rc: &RowCtx<'_>, k: &effectcraft_engine::keyframe::Keyframe, x: f32, ks: bool) -> Rect {
let t = g.tl.t;
let gp = g.gp.clone();
let cy = rc.cy;
let icon = k.icon();
let c = pos2(x, cy);
// Labelled keys take their label colour (brighter when selected).
let kcol = match effectcraft_engine::color::Label::ALL.get(k.label as usize).filter(|_| k.label > 0) {
Some(l) => {
let c = t.label(*l);
if ks { c } else { c.gamma_multiply(0.75) }
}
None if ks => t.keyframe_selected,
None => t.keyframe,
};
// Muted keys stay visible and editable but no longer drive the value.
let kcol = if k.muted { kcol.gamma_multiply(0.35) } else { kcol };
if k.roving {
// Roving keys: a small dot (their time follows their neighbours).
gp.circle_filled(c, ROVING_KEY_RADIUS, kcol);
} else {
icons::keyframe(&gp, c, KEY_GLYPH_SIZE, icon.left, icon.right, kcol);
}
Rect::from_center_size(c, vec2(12.0, 14.0))
}
/// The key's hit target and automation ids.
fn key_hit(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
g: &Rw,
rc: &RowCtx<'_>,
prop: &Property,
k: &effectcraft_engine::keyframe::Keyframe,
kr: Rect,
) -> egui::Response {
let fr = g.tl.fr;
let layer = rc.layer;
let uid = &prop.uid;
let ct = layer.comp_time(k.time);
let kresp = ui.interact(kr, egui::Id::unique(("key", uid, k.time.0)), Sense::click_and_drag());
app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
// Exact (layer-time ticks): keys a Quick Offset puts on subframes can share a frame.
app.auto.add(&format!("timeline.keyAt.{uid}.{}", k.time.0), kr, &format!("{} key", prop.name));
kresp
}
/// Right-click menus on the empty outline and graph areas.
fn background_menus(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) {
let actions = &mut g.tl.actions;
let graph_on = g.graph_on;
let full_clip = g.full_clip;
let empty_rect = g.empty_rect;
let empty = g.empty.clone();
let outline_empty = g.outline_empty.clone();
ui.set_clip_rect(full_clip);
// The background responses sit below rows, bars and editors, preserving their menus.
if !graph_on {
app.auto.add("timeline.empty", empty_rect, "Right-click to create a layer");
}
for (response, area) in [(&outline_empty, "outline"), (&empty, "graph")] {
crate::widgets::context_menu(response, |ui| {
let menu = ui.menu_button(crate::i18n::tr("New"), |ui| {
for (key, label, command, params) in [
("text", "Text", "layer.newText", json!({})),
("solid", "Solid…", "layer.newSolid", json!({})),
("light", "Light…", "layer.newLight", json!({})),
("camera", "Camera…", "layer.newCamera", json!({})),
("null", "Null Object", "layer.newNull", json!({})),
("shape", "Shape Layer", "layer.newShape", json!({"kind": "none"})),
("adjustment", "Adjustment Layer", "layer.newAdjustment", json!({})),
] {
let response = ui.button(crate::i18n::context_item(command, &params, label));
app.auto.add(&format!("timeline.context.{area}.{key}"), response.rect, label);
if response.clicked() {
actions.push((command.into(), params));
ui.close();
}
}
});
app.auto.add(&format!("timeline.context.{area}.new"), menu.response.rect, "New");
let response = ui.button(crate::i18n::tr("Composition Settings…"));
app.auto.add(&format!("timeline.context.{area}.settings"), response.rect, "Composition Settings…");
if response.clicked() {
actions.push(("comp.settings".into(), json!({})));
ui.close();
}
let response = ui.button(crate::i18n::tr("Open in Essential Graphics"));
app.auto.add(&format!("timeline.context.{area}.essentialGraphics"), response.rect, "Open in Essential Graphics");
if response.clicked() {
actions.push(("comp.openInEssentialGraphics".into(), json!({})));
ui.close();
}
});
}
}
/// Click on the empty outline (its drag is the Timeline's one box, [`marquee`]).
fn outline_marquee(ui: &mut egui::Ui, g: &mut Rw) {
let actions = &mut g.tl.actions;
let outline_empty = g.outline_empty.clone();
if outline_empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
actions.push(("layer.select".into(), json!({"layers": []})));
}
// Pick whips: a line follows the pointer; releasing over a row links to it (an expression's
}
/// A pick whip in flight: its line, the target and the link on release.
fn pick_whip_drag(g: &mut Rw) {
let t = g.tl.t;
let graph_x0 = g.tl.graph_x0;
let ctx = g.tl.ctx.clone();
let comp = g.tl.comp.clone();
let actions = &mut g.tl.actions;
let hit_rows = &mut g.hit_rows;
let dim_hits = &mut g.dim_hits;
// or a property's, also over a property in Effect Controls).
if let Some((kind, src_layer, src_prop, start)) = ctx.data(|d| d.get_temp::<PickWhip>(pick_whip_id())) {
let ptr = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(start);
let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("tl-pickwhip-line")));
fg.line_segment([start, ptr], Stroke::new(1.5, t.accent));
fg.circle_stroke(ptr, 4.0, Stroke::new(1.5, t.accent));
let target = hit_rows.iter().find(|(r, _)| r.contains(ptr)).map(|(r, row)| (*r, row.clone()));
// Shown this pass or the last one (Effect Controls may draw after the Timeline).
let pass = ctx.cumulative_pass_nr();
let elsewhere = (target.is_none() && !matches!(kind, WHIP_PARENT | WHIP_MATTE))
.then(|| ctx.data(|d| d.get_temp::<WhipTargets>(whip_targets_id())))
.flatten()
.filter(|(p, _)| p.saturating_add(1) >= pass)
.and_then(|(_, targets)| targets.into_iter().find(|(r, ..)| r.contains(ptr)));
if let Some((tr, _)) = &target {
fg.rect_stroke(Rect::from_min_max(tr.min, pos2(graph_x0 - 1.0, tr.max.y)), 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
} else if let Some((tr, ..)) = &elsewhere {
fg.rect_stroke(*tr, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
}
if ctx.input(|i| !i.pointer.any_down()) {
let whip_layers = ctx.data_mut(|d| {
d.remove::<PickWhip>(pick_whip_id());
d.remove::<WhipTargets>(whip_targets_id());
d.remove_temp::<Vec<u64>>(whip_layers_id())
});
// The layers a layer pick whip links, less the one it was dropped on.
let linked = |onto: u64| -> Vec<u64> { whip_layers.clone().unwrap_or_else(|| vec![src_layer]).into_iter().filter(|l| *l != onto).collect() };
let into = (kind == WHIP_EXPR).then(|| ctx.data_mut(|d| d.remove_temp::<WhipInto>(whip_into_id(src_prop)))).flatten();
// The property dropped on, and the value (dimension) if on one of them: `position[0]`.
let dim = |fields: &[Rect]| fields.iter().position(|r| r.contains(ptr));
let prop_target = match (&target, elsewhere) {
(Some((_, Row { layer, kind: RowKind::Prop { uid }, .. })), _) => {
Some((layer.0, *uid, dim_hits.iter().find(|(u, _)| u == uid).and_then(|(_, rs)| dim(rs))))
}
(None, Some((_, l, u, fields))) => Some((l, u, dim(&fields))),
_ => None,
};
match (kind, target, prop_target) {
(WHIP_PARENT, Some((_, row)), _) if row.layer.0 != src_layer => {
actions.push(("layer.setParent".into(), json!({"layers": linked(row.layer.0), "parent": row.layer.0})));
}
// Each layer keeps its own matte mode.
(WHIP_MATTE, Some((_, row)), _) if row.layer.0 != src_layer && comp.layer(row.layer).is_some_and(|l| l.source.is_av()) => {
actions.push(("layer.setTrackMatte".into(), json!({"layers": linked(row.layer.0), "matte": row.layer.0, "kind": "preserve"})));
}
// The property pick whip replaces the expression with the reference (or adds one),
// as the expression pick whip does when its expression isn't being edited.
(WHIP_EXPR | WHIP_PROP, _, Some((tl, tu, d))) if tu != src_prop => {
let mut params = json!({"layer": src_layer, "prop": src_prop, "target": {"layer": tl, "prop": tu}});
if let Some(d) = d {
params["target"]["dimension"] = json!(d);
}
// Alt held as it drops: an effect's properties by their index (#1072).
if ctx.input(|i| i.modifiers.alt) {
params["byIndex"] = json!(true);
}
if let Some((text, [a, b])) = into {
// Into the expression being edited, which goes on being edited.
params["expression"] = json!(text);
params["range"] = json!([a, b]);
let after = text.chars().count().saturating_sub(b);
focus_expression(&ctx, src_prop, [after, after]);
}
actions.push(("prop.pickWhip".into(), params));
}
_ => {}
}
}
}
// Alt-drag key group scaling (follows the pointer until release).
}
fn alt_scale_and_graph(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, ectx: &EvalCtx<'_>) {
let graph_x0 = g.tl.graph_x0;
let tm = g.tl.tm;
let rows_rect = g.tl.rows_rect;
let ctx = g.tl.ctx.clone();
let comp = g.tl.comp.clone();
let actions = &mut g.tl.actions;
let graph_on = g.graph_on;
let gp = g.gp.clone();
if let Some(c) = app.session.active_comp_arc() {
super::graph_tools::alt_scale_update(app, &ctx, &c, tm, &mut *actions);
}
// Graph editor.
if graph_on {
super::graph::show(
app,
ui,
&gp,
super::graph::GraphView { comp: &comp, ectx, tm },
Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max),
&mut *actions,
);
}
// A plain click on empty track area (beside a layer's bar, on a property row's empty span or
// below the last row) deselects the layers and their keys and properties, as in After
}
/// Click on empty track area (its drag is the Timeline's one box, [`marquee`]).
fn graph_empty_area(ui: &mut egui::Ui, g: &mut Rw) {
let actions = &mut g.tl.actions;
let empty = g.empty.clone();
if empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
actions.push(("layer.select".into(), json!({"layers": []})));
}
}
/// Layer reorder drag: a line where the layers land.
fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) {
let cid = g.tl.cid;
let t = g.tl.t;
let rect = g.tl.rect;
let graph_x0 = g.tl.graph_x0;
let rows_rect = g.tl.rows_rect;
let ctx = g.tl.ctx.clone();
let comp = g.tl.comp.clone();
let p = g.tl.p.clone();
let actions = &mut g.tl.actions;
let hit_rows = &mut g.hit_rows;
// The origin row can scroll out of view, so finish the gesture here, independently of
// its Response. Disabled UI, Escape or lost ownership cancels; only a primary release drops layers.
if let Some(drag) = ctx.data(|d| d.get_temp::<LayerDrag>(layer_drag_id())) {
let (down, released, escape) =
ctx.input(|i| (i.pointer.primary_down(), i.pointer.button_released(egui::PointerButton::Primary), i.key_pressed(egui::Key::Escape)));
let dropped = ctx.drag_stopped_id() == Some(drag.owner) && released;
let valid = ui.is_enabled()
&& drag.comp == cid
&& drag.layers.iter().all(|id| comp.layer(LayerId(*id)).is_some())
&& !escape
&& (dropped || (down && ctx.dragged_id() == Some(drag.owner)));
if dropped || !valid {
ctx.data_mut(|d| d.remove::<LayerDrag>(layer_drag_id()));
}
let moving = &drag.layers;
let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect();
if let Some(py) = ui.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())).map(|p| p.y)
&& valid
&& !layer_rows.is_empty()
{
let (at, line_y) = insertion(&layer_rows, py, rows_rect.min.y);
let target = layer_drop_target(&g.tl.rows, &layer_rows, at, moving);
if dropped {
// Only when the order changes (no empty undo step for a drop in place).
let mut order: Vec<u64> = comp.layers.iter().map(|l| l.id.0).filter(|id| !moving.contains(id)).collect();
let at = target.and_then(|t| order.iter().position(|id| *id == t.0)).unwrap_or(order.len());
order.splice(at..at, moving.iter().copied());
if order != comp.layers.iter().map(|l| l.id.0).collect::<Vec<_>>() {
let params = match target {
Some(t) => json!({"layers": moving, "above": t.0}),
None => json!({"layers": moving, "to": "back"}),
};
actions.push(("layer.arrange".into(), params));
}
} else {
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
p.with_clip_rect(rows_rect).line_segment([pos2(rect.min.x, line_y), pos2(graph_x0, line_y)], Stroke::new(2.0, t.accent));
}
}
}
// Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer
}
/// Group reorder drag (#2210): a line where the dragged effect, mask or shape item lands among
/// its siblings; the drop moves it there (`prop.moveGroup`). Escape cancels.
fn group_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) {
let ctx = g.tl.ctx.clone();
let comp = g.tl.comp.clone();
let Some(drag) = ctx.data(|d| d.get_temp::<GroupDrag>(group_drag_id())) else { return };
let (down, released, escape) =
ctx.input(|i| (i.pointer.primary_down(), i.pointer.button_released(egui::PointerButton::Primary), i.key_pressed(egui::Key::Escape)));
let dropped = ctx.drag_stopped_id() == Some(drag.owner) && released;
let parent = comp.layer(drag.layer).and_then(|l| l.props.parent_of(drag.uid));
let valid = ui.is_enabled() && drag.comp == g.tl.cid && parent.is_some() && !escape && (dropped || (down && ctx.dragged_id() == Some(drag.owner)));
if dropped || !valid {
ctx.data_mut(|d| d.remove::<GroupDrag>(group_drag_id()));
}
let Some(parent) = parent.filter(|_| valid) else { return };
let siblings: Vec<u64> = parent.children.iter().map(Node::uid).collect();
let rows: Vec<(Rect, u64)> = g
.hit_rows
.iter()
.filter_map(|(r, row)| match &row.kind {
RowKind::Group { uid, .. } if row.layer == drag.layer && siblings.contains(uid) => Some((*r, *uid)),
_ => None,
})
.collect();
let Some(py) = ui.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())).map(|p| p.y) else { return };
if rows.is_empty() {
return;
}
let (at, line_y) = insertion(&rows, py, g.tl.rows_rect.min.y);
if !dropped {
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
g.tl.p.with_clip_rect(g.tl.rows_rect).line_segment([pos2(g.tl.rect.min.x, line_y), pos2(g.tl.graph_x0, line_y)], Stroke::new(2.0, g.tl.t.accent));
return;
}
// The sibling the drop lands before (or after, below the last visible one).
let Some(&(_, anchor)) = at.map_or(rows.last(), |i| rows.get(i)) else { return };
let from = siblings.iter().position(|u| *u == drag.uid);
let rest: Vec<u64> = siblings.iter().copied().filter(|u| *u != drag.uid).collect();
let to = if anchor == drag.uid { from } else { rest.iter().position(|u| *u == anchor).map(|i| i + usize::from(at.is_none())) };
if let Some(to) = to.filter(|to| Some(*to) != from) {
g.tl.actions.push(("prop.moveGroup".into(), json!({"layer": drag.layer.0, "prop": drag.uid, "index": to + 1})));
}
}
/// Quick Offset of layer bars follows the pointer.
fn quick_offset_follow(g: &mut Rw) {
let t = g.tl.t;
let pps = g.tl.pps;
let fd = g.tl.fd;
let ctx = g.tl.ctx.clone();
let actions = &mut g.tl.actions;
let ui_actions = &mut g.ui_actions;
if let Some(mut q) = ctx.data(|d| d.get_temp::<QuickBar>(quick_bar_id())) {
let (down, ptr) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos()));
match ptr.filter(|_| down) {
Some(ptr) => {
let frames = (((ptr.x - q.origin_x) as f64 / pps) / fd).round() as i64;
if frames != q.applied {
q.applied = frames;
ctx.data_mut(|d| d.insert_temp(quick_bar_id(), q));
let merge = format!("bar-quick-{}", q.layer.0);
actions.push(("layer.quickOffset".into(), json!({"delta": frames as f64 * fd, "merge": merge, "fromStart": true})));
}
drag_tip(&ctx, &t, ptr, quick_offset_note(q.applied, q.layers));
}
None => {
ctx.data_mut(|d| d.remove::<QuickBar>(quick_bar_id()));
ui_actions.push(UiAct::EndMerge);
}
}
}
// Keyframe drag: the selected keys move by whole frames with the pointer until it is
// released (one undo step); Shift snaps the grabbed key to the current time, the work area
}
/// The key drag moves the selected keys by whole frames.
fn key_drag_follow(app: &mut EffectcraftApp, g: &mut Rw) {
let t = g.tl.t;
let time = g.tl.time;
let pps = g.tl.pps;
let fd = g.tl.fd;
let ctx = g.tl.ctx.clone();
let comp = g.tl.comp.clone();
let rows = &g.tl.rows;
let actions = &mut g.tl.actions;
let ui_actions = &mut g.ui_actions;
if let Some(mut kd) = ctx.data(|d| d.get_temp::<KeyDrag>(key_drag_id())) {
let (down, ptr, shift) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.modifiers.shift));
match ptr.filter(|_| down) {
Some(ptr) => {
let mut target = kd.anchor + (ptr.x - kd.origin_x) as f64 / pps;
if shift {
// The current time and whatever it snaps to (keys, layer ends, markers), but
// not the moving keys themselves.
let moving: Vec<f64> =
app.session.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time).seconds())).collect();
let mut cands = snap_candidates(&comp, rows);
cands.retain(|c| !moving.iter().any(|m| (m - c).abs() < 1e-9));
cands.push(time.seconds());
target = snap_time(&cands, target, 8.0 / pps);
}
let total = ((target - kd.anchor) / fd).round() as i64;
if total != kd.applied {
actions.push(match kd.quick {
Some(_) => ("keys.quickOffset".into(), json!({"delta": total as f64 * fd, "merge": "key-quick-offset", "fromStart": true})),
None => ("keys.move".into(), json!({"delta": (total - kd.applied) as f64 * fd, "merge": "key-drag"})),
});
kd.applied = total;
ctx.data_mut(|d| d.insert_temp(key_drag_id(), kd));
}
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
// Where the grabbed key is and how far the keys moved (After Effects shows this
// in the Info panel).
let at = Tick::from_seconds_f64(kd.anchor + kd.applied as f64 * fd);
let note = match kd.quick {
Some(n) => quick_offset_note(kd.applied, n),
None => format!("{} {:+} f", super::timecode(&app.session, &comp, at), kd.applied),
};
drag_tip(&ctx, &t, ptr, note);
app.ui.status = format!("Keyframe at {}", super::timecode(&app.session, &comp, at));
}
None => {
ctx.data_mut(|d| d.remove::<KeyDrag>(key_drag_id()));
ui_actions.push(UiAct::EndMerge);
}
}
}
}
/// The keys whose markers touch box `br`, in row order (the comp's). `top` is the y of the first
/// row, scrolled.
fn keys_in_box(comp: &Comp, rows: &[Row], tm: &TMap, top: f32, rh: f32, br: Rect) -> Vec<effectcraft_engine::KeyRef> {
let mut keys = vec![];
let mut yy = top;
for row in rows {
let cy = yy + rh / 2.0;
yy += row_height(row, rh);
// No marker reaches a whole glyph from its row's center.
if let RowKind::Prop { uid } = row.kind
&& cy >= br.min.y - KEY_GLYPH_SIZE
&& cy <= br.max.y + KEY_GLYPH_SIZE
&& let Some(l) = comp.layer(row.layer)
&& let Some(pr) = l.props.find(uid)
{
for k in &pr.keys {
let x = tm.x(l.comp_time(k.time).seconds());
if key_glyph_rect(k, pos2(x, cy)).intersects(br) {
keys.push(effectcraft_engine::KeyRef { layer: l.id, prop: uid, time: k.time });
}
}
}
}
keys
}
fn current_time_indicator(app: &mut EffectcraftApp, g: &Rw) {
let t = g.tl.t;
let time = g.tl.time;
let rect = g.tl.rect;
let graph_x0 = g.tl.graph_x0;
let tm = g.tl.tm;
let rows_rect = g.tl.rows_rect;
let ruler = g.tl.ruler;
let p = g.tl.p.clone();
let cx = tm.x(time.seconds());
if cx >= graph_x0 && cx <= rect.max.x {
p.line_segment([pos2(cx, ruler.min.y + 12.0), pos2(cx, rows_rect.max.y)], Stroke::new(1.0, t.cti));
let head = vec![
pos2(cx - 6.0, ruler.min.y + 12.0),
pos2(cx + 6.0, ruler.min.y + 12.0),
pos2(cx + 6.0, ruler.max.y - 8.0),
pos2(cx, ruler.max.y - 2.0),
pos2(cx - 6.0, ruler.max.y - 8.0),
];
p.add(egui::Shape::convex_polygon(head, t.cti, Stroke::NONE));
app.auto.add("timeline.cti", Rect::from_center_size(pos2(cx, ruler.center().y), vec2(12.0, 20.0)), "Current time indicator");
}
}
/// The footer: scroll bars, the switches / modes toggle and render time.
fn footer_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) -> Rect {
let t = g.tl.t;
let rect = g.tl.rect;
let footer_h = g.tl.footer_h;
let overflow = g.tl.overflow;
let oscroll = g.tl.oscroll;
let graph_x0 = g.tl.graph_x0;
let rows_rect = g.tl.rows_rect;
let max_scroll = g.tl.max_scroll;
let p = g.tl.p.clone();
let footer = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max);
p.rect_filled(footer, 0.0, t.panel_bg);
p.line_segment([footer.left_top(), footer.right_top()], Stroke::new(1.0, t.separator));
if overflow > 0.0 {
// Outline scroll bar along the top of the footer, under the columns.
let track = Rect::from_min_max(pos2(rect.min.x + 2.0, footer.min.y + 1.0), pos2(graph_x0 - 3.0, footer.min.y + 5.0));
app.ui.timeline.outline_scroll = widgets::scroll_bar(ui, &mut app.auto, "timeline.outlineScroll", track, oscroll, overflow, &t);
}
if max_scroll > 0.0 {
// The layer rows' scroll bar, on the right edge beside the time graph.
let track = Rect::from_min_max(pos2(rect.max.x - 6.0, rows_rect.min.y + 1.0), pos2(rect.max.x - 2.0, rows_rect.max.y - 1.0));
app.ui.timeline.scroll_y = widgets::scroll_bar(ui, &mut app.auto, "timeline.scroll", track, app.ui.timeline.scroll_y, max_scroll, &t);
}
let tgl = Rect::from_min_size(pos2(footer.min.x + 10.0, footer.min.y + 3.0), vec2(150.0, 18.0));
let tresp = ui.interact(tgl, egui::Id::unique("tl-toggle-modes"), Sense::click());
p.text(
tgl.left_center(),
Align2::LEFT_CENTER,
crate::i18n::tr("Toggle Switches / Modes"),
Tokens::ui(11.0),
if tresp.hovered() { t.text } else { t.text_dim },
);
app.auto.add("timeline.toggleSwitchesModes", tgl, "Toggle Switches / Modes");
if tresp.clicked() {
app.ui.timeline.show_modes = !app.ui.timeline.show_modes;
}
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
p.text(
pos2(graph_x0 - 12.0, footer.center().y),
Align2::RIGHT_CENTER,
crate::i18n::tr_args("Frame Render Time {}ms", &[&format!("{ms:.0}")]),
Tokens::ui(11.0),
t.text_faint,
);
// Zoom slider.
footer
}
/// The zoom buttons and slider.
fn footer_zoom(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw, footer: Rect) {
let t = g.tl.t;
let time = g.tl.time;
let rect = g.tl.rect;
let graph_x0 = g.tl.graph_x0;
let fit_pps = g.tl.fit_pps;
let pps = g.tl.pps;
let tm = g.tl.tm;
let comp = g.tl.comp.clone();
let p = g.tl.p.clone();
let zs = Rect::from_min_max(pos2(graph_x0 + 30.0, footer.center().y - 2.0), pos2((graph_x0 + 230.0).min(rect.max.x - 30.0), footer.center().y + 2.0));
// Zoom out / zoom in buttons either side of the slider: the `-` / `=` steps. Everything here
// zooms around the CTI and sets the one time zoom the layer bars and the Graph Editor share.
let cti = time.seconds();
let cti_px = (cti - tm.start) * pps;
for (icon, size, cx, id, tip, k) in [
(Icon::MountainSmall, 14.0, zs.min.x - 14.0, "timeline.zoomOutButton", "Zoom Out (Time)", 1.0 / ZOOM_STEP),
(Icon::MountainLarge, 16.0, zs.max.x + 14.0, "timeline.zoomInButton", "Zoom In (Time)", ZOOM_STEP),
] {
let r = Rect::from_center_size(pos2(cx, footer.center().y), vec2(20.0, 20.0));
let resp = ui.interact(r, egui::Id::unique(id), Sense::click()).on_hover_text(crate::i18n::tr(tip));
icons::paint(&p, Rect::from_center_size(r.center(), vec2(size, size)), icon, if resp.hovered() { t.text } else { t.text_dim });
app.auto.add(id, r, tip);
if resp.clicked() {
set_zoom(&mut app.ui.timeline, &comp, pps * k, fit_pps, cti, cti_px);
}
}
p.rect_filled(zs, 2.0, t.field_bg);
let maxpps = MAX_PPS.max(fit_pps * 1.001);
let frac = ((pps / fit_pps).ln() / (maxpps / fit_pps).ln()).clamp(0.0, 1.0) as f32;
let knob = pos2(zs.min.x + zs.width() * frac, zs.center().y);
p.circle_filled(knob, 6.0, t.text);
// Drag the knob, or click the track to jump there.
let zresp = ui.interact(zs.expand2(vec2(6.0, 8.0)), egui::Id::unique("tl-zoom"), Sense::click_and_drag());
app.auto.add("timeline.zoomSlider", zs, "Zoom");
if (zresp.dragged() || zresp.clicked())
&& let Some(pt) = zresp.interact_pointer_pos()
{
let f = ((pt.x - zs.min.x) / zs.width().max(1.0)).clamp(0.0, 1.0) as f64;
set_zoom(&mut app.ui.timeline, &comp, fit_pps * (maxpps / fit_pps).powf(f), fit_pps, cti, cti_px);
}
}
fn comp_markers_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) {
let graph_x0 = g.tl.graph_x0;
let tm = g.tl.tm;
let rows_rect = g.tl.rows_rect;
let ruler = g.tl.ruler;
let bin = g.tl.bin;
let comp = g.tl.comp.clone();
// Composition markers in the ruler; protected regions shade the layer rows.
let mstrip = Rect::from_min_max(pos2(graph_x0, ruler.max.y - 12.0), pos2(bin.min.x, ruler.max.y));
let mrows = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
super::markers_ui::comp_markers(app, ui, &comp, tm, mstrip, mrows);
super::markers_ui::marker_bin(app, ui, &comp, tm, bin, mstrip);
}
/// Spacebar held: the Hand tool. A middle-drag begun in the rows, layer list included, pans too.
fn hand_tool_pan(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) {
let rect = g.tl.rect;
let graph_x0 = g.tl.graph_x0;
let graph_x1 = g.tl.graph_x1;
let pps = g.tl.pps;
let tm = g.tl.tm;
let rows_rect = g.tl.rows_rect;
let ruler = g.tl.ruler;
let max_scroll = g.tl.max_scroll;
let ctx = g.tl.ctx.clone();
let comp = g.tl.comp.clone();
let mut pan = ui.input(|i| i.pointer.middle_down() && !i.pointer.primary_down() && i.pointer.press_origin().is_some_and(|p| rows_rect.contains(p)));
if super::space_hand(&ctx) {
let area = Rect::from_min_max(pos2(graph_x0, ruler.min.y), pos2(rect.max.x, rows_rect.max.y));
let hand = ui.interact(area, egui::Id::unique("tl-hand"), Sense::drag());
app.auto.add("timeline.hand", area, "Hand (hold Spacebar)");
if hand.hovered() || hand.dragged() {
ctx.set_cursor_icon(if hand.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
}
pan |= hand.dragged();
}
if pan {
let delta = ui.input(|i| i.pointer.delta());
app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - delta.y).clamp(0.0, max_scroll);
if app.ui.timeline.pps.is_some() {
let start = app.ui.timeline.start;
let last = (comp.duration.seconds() - (tm.t(graph_x1) - tm.start)).max(start).max(0.0);
app.ui.timeline.start = (start - delta.x as f64 / pps).clamp(0.0, last);
}
}
}
/// Alt-drag of an item over a layer: replace its source.
fn drop_replace_source(app: &mut EffectcraftApp, g: &mut Rw, payload: &std::sync::Arc<crate::panels::DragPayload>, ptr: Pos2, released: bool) {
let t = g.tl.t;
let cid = g.tl.cid;
let rows_rect = g.tl.rows_rect;
let comp = g.tl.comp.clone();
let p = g.tl.p.clone();
let actions = &mut g.tl.actions;
let hit_rows = &mut g.hit_rows;
if let crate::panels::DragPayload::Item(id) = payload.as_ref() {
let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
if let Some(l) = target
&& replace_source_target(app, cid, ItemId(*id), l, &comp)
&& let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b))
{
let lit = span.intersect(rows_rect);
let rp = p.with_clip_rect(rows_rect);
rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18));
rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
app.auto.add("timeline.replaceSourceTarget", lit, "Replace the layer's source");
if released {
actions.push(("layer.replaceSource".into(), json!({"layers": [l.0], "item": *id, "fixExpressions": false})));
}
}
}
}
/// Effects and presets drop onto the layer under the pointer.
fn drop_on_layer(app: &mut EffectcraftApp, g: &mut Rw, payload: &std::sync::Arc<crate::panels::DragPayload>, ptr: Pos2, released: bool) {
let t = g.tl.t;
let rows_rect = g.tl.rows_rect;
let p = g.tl.p.clone();
let actions = &mut g.tl.actions;
let hit_rows = &mut g.hit_rows;
let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
// The layer the effect would land on (its row and its open properties) is the one
// that lights up, not the whole timeline.
if let Some(l) = target
&& let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b))
{
let lit = span.intersect(rows_rect);
let rp = p.with_clip_rect(rows_rect);
rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18));
rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
app.auto.add("timeline.dropTarget", lit, "Drop target layer");
}
if released && let Some(l) = target {
match payload.as_ref() {
crate::panels::DragPayload::Effect(e) => actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))),
crate::panels::DragPayload::Preset { preset, .. } => {
let (id, params) = preset.apply_to(l.0);
actions.push((id.into(), params));
}
_ => {}
}
}
}
/// Footage and files drop into the stack: a line shows the place.
fn drop_insert_layers(app: &mut EffectcraftApp, g: &mut Rw, payload: &std::sync::Arc<crate::panels::DragPayload>, ptr: Pos2, released: bool) {
let t = g.tl.t;
let time = g.tl.time;
let rect = g.tl.rect;
let graph_x0 = g.tl.graph_x0;
let tm = g.tl.tm;
let rows_rect = g.tl.rows_rect;
let ctx = g.tl.ctx.clone();
let comp = g.tl.comp.clone();
let p = g.tl.p.clone();
let actions = &mut g.tl.actions;
let graph_on = g.graph_on;
let hit_rows = &mut g.hit_rows;
let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect();
let (at, line_y) = insertion(&layer_rows, ptr.y, rows_rect.min.y);
let index = match at.and_then(|i| layer_rows.get(i)) {
Some((_, l)) => comp.index_of(*l).unwrap_or(1),
None => layer_rows.last().and_then(|(_, l)| comp.index_of(*l)).unwrap_or(comp.layers.len()) + 1,
};
let start = (ptr.x >= graph_x0 && !graph_on).then(|| {
let t = if ctx.input(|i| i.modifiers.shift) { time } else { Tick::from_seconds_f64(tm.t(ptr.x)) };
comp.frame_rate.snap_nearest(t).clamp(Tick::ZERO, comp.duration - comp.frame_duration())
});
let rp = p.with_clip_rect(rows_rect);
rp.line_segment([pos2(rect.min.x, line_y), pos2(rows_rect.max.x, line_y)], Stroke::new(2.0, t.accent));
if let Some(s) = start {
let x = tm.x(s.seconds());
rp.line_segment([pos2(x, rows_rect.min.y), pos2(x, rows_rect.max.y)], Stroke::new(1.0, t.accent));
// The In point's time in a tag beside the marker.
let g = rp.layout_no_wrap(super::timecode(&app.session, &comp, s), Tokens::ui(11.0), Color32::WHITE);
let tag = Align2::LEFT_BOTTOM.anchor_size(pos2(x + 4.0, line_y - 3.0), g.size());
rp.rect_filled(tag.expand(2.0), 2.0, t.accent);
rp.galley(tag.min, g, Color32::WHITE);
}
if released {
let time = json!(start.map(Tick::seconds));
match payload.as_ref() {
crate::panels::DragPayload::Item(id) => {
// Insert each selected item at the drop point, in selection order.
for (offset, item) in crate::panels::project_drop_items(app, *id, Some(g.tl.cid)).into_iter().enumerate() {
actions.push(("layer.addItem".into(), json!({"item": item, "index": index.saturating_add(offset), "time": time.clone()})));
}
}
crate::panels::DragPayload::Files(paths) => {
actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time})))
}
crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. } | crate::panels::DragPayload::Property { .. } => {}
}
}
}
/// The optional per-layer cells: Comment (double-click to edit), In / Out / Duration (drag to
/// change by frames), Stretch (click: Time Stretch dialog).
fn layer_cells(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
lp: &egui::Painter,
cw: &Cols,
comp: &Comp,
rc: &RowCtx<'_>,
actions: &mut Vec<(String, serde_json::Value)>,
) {
let (layer, r, cy) = (rc.layer, rc.r, rc.cy);
let t = app.tokens;
let ctx = ui.ctx().clone();
let lid = layer.id.0;
if cw.vis.comment {
let cr = Rect::from_min_max(pos2(cw.comment + 4.0, r.min.y + 1.0), pos2(cw.comment + COMMENT_W - 4.0, r.max.y - 1.0));
let edit_id = egui::Id::unique("tl-comment-edit");
let editing: Option<(u64, String)> = ctx.data(|d| d.get_temp(edit_id));
app.auto.add(&format!("timeline.layer.{lid}.comment"), cr, &layer.comment);
if let Some((el, mut buf)) = editing.filter(|(el, _)| *el == lid) {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(cr));
let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(11.5)).desired_width(cr.width()));
if er.lost_focus() {
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && buf != layer.comment {
actions.push(("layer.setComment".into(), json!({"layers": [el], "comment": buf})));
}
ctx.data_mut(|d| d.remove::<(u64, String)>(edit_id));
} else {
widgets::keep_focus(&ctx, &er, &buf);
ctx.data_mut(|d| d.insert_temp(edit_id, (el, buf)));
}
} else {
let resp =
ui.interact(cr, egui::Id::unique(("tl-comment", lid)), Sense::click()).on_hover_text(crate::i18n::tr("Double-click to edit the comment"));
lp.with_clip_rect(cr.intersect(lp.clip_rect())).text(pos2(cr.min.x + 2.0, cy), Align2::LEFT_CENTER, &layer.comment, Tokens::ui(11.5), t.text_dim);
if resp.double_clicked() {
ctx.data_mut(|d| d.insert_temp(edit_id, (lid, layer.comment.clone())));
}
}
}
let fd = comp.frame_duration().seconds();
let cells = [
(cw.vis.in_, cw.in_, "in", layer.in_point.seconds()),
(cw.vis.out, cw.out, "out", layer.out_point.seconds()),
(cw.vis.duration, cw.duration, "duration", (layer.out_point - layer.in_point).seconds()),
];
for (on, x, kind, secs) in cells {
if !on {
continue;
}
let cr = Rect::from_min_max(pos2(x + 2.0, r.min.y + 1.0), pos2(x + TIME_W - 2.0, r.max.y - 1.0));
let tc = if kind == "duration" {
crate::panels::duration(&app.session, comp, Tick::from_seconds_f64(secs))
} else {
crate::panels::timecode(&app.session, comp, Tick::from_seconds_f64(secs))
};
let resp = ui.interact(cr, egui::Id::unique(("tl-cell", kind, lid)), Sense::drag()).on_hover_text(crate::i18n::tr("Drag to change"));
lp.text(pos2(cr.min.x + 4.0, cy), Align2::LEFT_CENTER, &tc, Tokens::mono(11.0), if resp.hovered() || resp.dragged() { t.hot_text } else { t.timecode });
app.auto.add(&format!("timeline.layer.{lid}.{kind}"), cr, &tc);
if resp.hovered() || resp.dragged() {
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
let acc_id = egui::Id::unique(("tl-cell-acc", kind, lid));
if resp.dragged() {
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
acc += resp.drag_delta().x;
let frames = (acc / 4.0).trunc() as i64;
if frames != 0 {
acc -= frames as f32 * 4.0;
let d = frames as f64 * fd;
let merge = format!("cell-{kind}-{lid}");
let params = match kind {
"in" => json!({"layers": [lid], "in": (layer.in_point.seconds() + d).max(0.0), "merge": merge}),
_ => json!({"layers": [lid], "out": (layer.out_point.seconds() + d).max(layer.in_point.seconds() + fd), "merge": merge}),
};
actions.push(("layer.timing".into(), params));
}
ctx.data_mut(|d| d.insert_temp(acc_id, acc));
}
if resp.drag_stopped() {
ctx.data_mut(|d| d.remove::<f32>(acc_id));
actions.push(("__endMerge".into(), json!({})));
}
}
if cw.vis.stretch {
let cr = Rect::from_min_max(pos2(cw.stretch + 2.0, r.min.y + 1.0), pos2(cw.stretch + STRETCH_W - 2.0, r.max.y - 1.0));
let label = format!("{:.1}%", layer.stretch * 100.0);
let resp = ui.interact(cr, egui::Id::unique(("tl-stretch", lid)), Sense::click()).on_hover_text(crate::i18n::tr("Time Stretch…"));
lp.text(pos2(cr.min.x + 4.0, cy), Align2::LEFT_CENTER, &label, Tokens::ui(11.5), if resp.hovered() { t.hot_text } else { t.text });
app.auto.add(&format!("timeline.layer.{lid}.stretch"), cr, &label);
if resp.clicked() {
actions.push(("layer.select".into(), json!({"layers": [lid]})));
actions.push(("layer.timeStretch".into(), json!({})));
}
}
}
enum UiAct {
ToggleLayer(u64, bool),
/// Layer, group uid, and whether the whole tree under the group follows.
ToggleGroup(u64, u64, bool),
SetTime(Tick),
EndMerge,
/// Composition Settings on its 3D Renderer tab.
ChangeRenderer,
}
fn walk_groups(g: &PropGroup, out: &mut Vec<u64>) {
// Audio's Waveform row is not in the property tree; it twirls along.
if g.match_id == "audio" {
out.push(WAVE_BIT | g.uid);
}
for sg in g.groups() {
out.push(sg.uid);
walk_groups(sg, out);
}
}
/// The group `uid` anywhere under `g`.
fn find_group(g: &PropGroup, uid: u64) -> Option<&PropGroup> {
g.groups().find_map(|sg| if sg.uid == uid { Some(sg) } else { find_group(sg, uid) })
}
/// A stroke's Dashes "+" / "−" buttons (add or remove a Dash/Gap pair).
fn dash_buttons(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
uid: u64,
at: Pos2,
actions: &mut Vec<(String, serde_json::Value)>,
) {
let (x, cy) = (at.x, at.y);
if !matches!(layer.source, LayerSource::Shape) || layer.props.find_group(uid).is_none_or(|g| g.match_id != "dashes") {
return;
}
let Some(stroke) = layer.props.parent_of(uid).map(|g| g.uid) else { return };
let t = app.tokens;
for (i, (sym, id, tip)) in
[("+", "shape.dashes.add", "Add a Dash or Gap"), ("\u{2212}", "shape.dashes.remove", "Remove a Dash or Gap")].into_iter().enumerate()
{
let r = Rect::from_center_size(pos2(x + 12.0 + i as f32 * 20.0, cy), vec2(16.0, 16.0));
let resp = ui.interact(r, egui::Id::unique(("tl-dash", uid, i)), Sense::click()).on_hover_text(tip);
p.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
p.text(r.center(), Align2::CENTER_CENTER, sym, Tokens::ui(12.0), if resp.hovered() { t.text } else { t.text_dim });
app.auto.add(&format!("timeline.group.{uid}.{}", if i == 0 { "addDash" } else { "removeDash" }), r, tip);
if resp.clicked() {
actions.push((id.into(), json!({"layer": layer.id.0, "prop": stroke})));
}
}
}
/// The shape items the Contents row's "Add:" menu offers, as `layer.addShapeItem` kinds ("-" is
/// a separator), in After Effects' order.
const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[
("group", "Group (empty)"),
("-", ""),
("rect", "Rectangle"),
("ellipse", "Ellipse"),
("star", "Polystar"),
("path", "Path"),
("-", ""),
("fill", "Fill"),
("stroke", "Stroke"),
("gfill", "Gradient Fill"),
("gstroke", "Gradient Stroke"),
("-", ""),
("frontColor", "Front: Color"),
("bevelColor", "Bevel: Color"),
("sideColor", "Side: Color"),
("backColor", "Back: Color"),
("-", ""),
("merge", "Merge Paths"),
("offset", "Offset Paths"),
("pucker", "Pucker & Bloat"),
("repeater", "Repeater"),
("round", "Round Corners"),
("trim", "Trim Paths"),
("twist", "Twist"),
("wiggle", "Wiggle Paths"),
("wiggler", "Wiggle Transform"),
("zigzag", "Zig Zag"),
];
/// A shape layer's Contents "Add:" pop-up menu: adds a shape, paint or path operation to the
/// selected shape group, else to the layer's contents.
fn shape_add_popup(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
uid: u64,
at: Pos2,
actions: &mut Vec<(String, serde_json::Value)>,
) {
let (x, cy) = (at.x, at.y);
if !matches!(layer.source, LayerSource::Shape) || layer.props.sub("contents").is_none_or(|c| c.uid != uid) {
return;
}
let t = app.tokens;
p.text(pos2(x + 6.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Add:"), Tokens::ui(11.5), t.text_dim);
let br = Rect::from_center_size(pos2(x + 40.0, cy), vec2(16.0, 16.0));
let resp =
ui.interact(br, egui::Id::unique(("tl-shapeadd", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Add a shape, fill, stroke or path operation"));
icons::paint(p, br.shrink(3.0), Icon::ChevronRight, if resp.hovered() { t.text } else { t.text_dim });
app.auto.add(&format!("timeline.group.{uid}.add"), br, "Add:");
let pop = egui::Id::unique(("tl-shapeadd-pop", uid));
if resp.clicked() {
widgets::open_popup(ui, pop);
}
let opts: Vec<String> = SHAPE_ADD_ITEMS.iter().map(|(k, l)| if *k == "-" { "-".to_string() } else { l.to_string() }).collect();
if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &opts, None)
&& let Some((kind, _)) = SHAPE_ADD_ITEMS.get(i).filter(|(k, _)| *k != "-")
{
let mut params = json!({"layer": layer.id.0, "kind": kind});
// Into the selected shape group, as After Effects does.
let group = app.session.state.selected_props.iter().filter(|(l, _)| *l == layer.id).find_map(|(_, u)| {
let g = layer.props.find_group(*u)?;
(g.match_id == "group" && g.sub("contents").is_some()).then_some(g.uid)
});
if let Some(g) = group {
params["group"] = json!(g);
}
actions.push(("layer.addShapeItem".into(), params));
}
}
/// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus).
fn text_anim_popups(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
uid: u64,
at: Pos2,
actions: &mut Vec<(String, serde_json::Value)>,
) {
let (x, cy) = (at.x, at.y);
use effectcraft_engine::project::build::TEXT_ANIMATOR_KINDS;
if !matches!(layer.source, LayerSource::Text) {
return;
}
let Some(g) = layer.props.find_group(uid) else { return };
let t = app.tokens;
let (label, key) = match g.match_id.as_str() {
"text" if layer.props.sub("text").is_some_and(|tg| tg.uid == uid) => ("Animate:", "animate"),
"animator" => ("Add:", "add"),
_ => return,
};
// The label is the English source; the drawn text is the catalog's, so the row reads
// アニメーター: as Animation ▸ Animate Text does. The button follows the width the label
// actually takes, since a translation is rarely as wide as the English it replaces.
let galley = p.layout_no_wrap(tr(label).to_string(), Tokens::ui(11.5), t.text_dim);
let text_w = galley.size().x;
p.galley(pos2(x + 6.0, cy - galley.size().y / 2.0), galley, t.text_dim);
let br = Rect::from_center_size(pos2(x + 6.0 + text_w + 10.0, cy), vec2(16.0, 16.0));
let resp = ui.interact(br, egui::Id::unique(("tl-textanim", uid)), Sense::click()).on_hover_text(tr(if key == "add" {
"Add property or selector"
} else {
"Animate text"
}));
icons::paint(p, br.shrink(3.0), Icon::ChevronRight, if resp.hovered() { t.text } else { t.text_dim });
app.auto.add(&format!("timeline.group.{uid}.{key}"), br, label);
let pop = egui::Id::unique(("tl-textanim-pop", uid));
if resp.clicked() {
widgets::open_popup(ui, pop);
}
let per_char = layer.props.prop("text/perChar3d").is_some_and(|q| q.value.as_bool());
let mut opts: Vec<String> = Vec::new();
let mut acts: Vec<(String, serde_json::Value)> = Vec::new();
if key == "animate" {
opts.push(tr(if per_char { "Disable Per-character 3D" } else { "Enable Per-character 3D" }).to_string());
acts.push(("layer.enablePerChar3D".into(), json!({"layer": layer.id.0, "enabled": !per_char})));
opts.push("-".into());
acts.push((String::new(), json!(null)));
for (k, l) in TEXT_ANIMATOR_KINDS {
// The engine names these. `command_label` asks the menu for the row Animation ▸ Animate
// Text uses and falls back to the panel catalog, which holds the split names the menu
// does not list (`Fill Color: RGB` and the other colour rows), so no item is left in
// English. A separator is not a label.
opts.push(if *k == "-" { "-".into() } else { crate::i18n::command_label("layer.addTextAnimator", l).to_string() });
acts.push(if *k == "-" { (String::new(), json!(null)) } else { ("layer.addTextAnimator".into(), json!({"layer": layer.id.0, "property": k})) });
}
// Variable Font Axes of the layer's font.
if let Some(effectcraft_engine::keyframe::Value::Text(doc)) = layer.props.prop("text/sourceText").map(|p| p.value.clone()) {
let face = effectcraft_engine::text::resolve(&doc.font, &doc.style).face;
let axes = effectcraft_engine::text::variable::font_axes(face);
if !axes.is_empty() {
opts.push("-".into());
acts.push((String::new(), json!(null)));
}
for a in axes {
opts.push(tr_args("Variable Font Axes: {}", &[&a.name]));
acts.push(("text.animatorFontAxes".into(), json!({"layer": layer.id.0, "axis": a.tag})));
}
}
} else {
for (kind, l) in [("range", "Selector: Range"), ("wiggly", "Selector: Wiggly"), ("expression", "Selector: Expression")] {
opts.push(tr(l).to_string());
acts.push(("layer.addTextSelector".into(), json!({"layer": layer.id.0, "animator": uid, "kind": kind})));
}
opts.push("-".into());
acts.push((String::new(), json!(null)));
for (k, l) in TEXT_ANIMATOR_KINDS {
opts.push(if *k == "-" { "-".into() } else { tr_args("Property: {}", &[&crate::i18n::command_label("layer.addTextAnimator", l)]) });
acts.push(if *k == "-" {
(String::new(), json!(null))
} else {
("layer.addTextAnimatorProperty".into(), json!({"layer": layer.id.0, "animator": uid, "property": k}))
});
}
}
if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &opts, None)
&& let Some((id, params)) = acts.get(i).filter(|a| !a.0.is_empty())
{
actions.push((id.clone(), params.clone()));
}
}
/// The command for the Track Matte menu item `i` (`choice`) of a row, setting `layers`' mattes,
/// whose menu lists "No Track Matte", a separator, the matte `candidates`, a separator and the
/// modes. A new matte layer keeps each layer's mode (the matte itself, if among them, is left
/// out); a mode without a matte layer takes the layer above, like Layer ▸ Track Matte (#864: it
/// used to do nothing).
fn track_matte_pick(layers: &[u64], candidates: &[LayerId], i: usize, choice: &str) -> Option<(String, serde_json::Value)> {
if i == 0 {
return Some(("layer.setTrackMatte".into(), json!({"layers": layers, "matte": null})));
}
if let Some(m) = i.checked_sub(2).and_then(|j| candidates.get(j)) {
let layers: Vec<u64> = layers.iter().copied().filter(|l| *l != m.0).collect();
return (!layers.is_empty()).then(|| ("layer.setTrackMatte".into(), json!({"layers": layers, "matte": m.0, "kind": "preserve"})));
}
let k = MatteKind::ALL.into_iter().find(|k| k.label() == choice)?;
Some(("layer.trackMatte".into(), json!({"layers": layers, "op": kind_name(k)})))
}
fn kind_name(k: MatteKind) -> &'static str {
match k {
MatteKind::Alpha => "alpha",
MatteKind::AlphaInverted => "alphaInverted",
MatteKind::Luma => "luma",
MatteKind::LumaInverted => "lumaInverted",
}
}
/// Is property `uid` the layer's Position (or, separated, its X, Y or Z Position)?
pub(crate) fn is_position(layer: &Layer, uid: u64) -> bool {
layer.transform().is_some_and(|tr| ["position", "positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.uid == uid)))
}
/// A property's context menu (right-click its name in the Timeline), as in After Effects.
fn prop_menu(
ui: &mut egui::Ui,
auto: &mut crate::automation::Registry,
comp: ItemId,
layer: &Layer,
prop: &Property,
actions: &mut Vec<(String, serde_json::Value)>,
) {
let (l, uid) = (layer.id.0, prop.uid);
let mut item = |ui: &mut egui::Ui, key: &str, label: &str, selected: bool, cmd: &str, params: serde_json::Value| {
let resp = ui.add(egui::Button::new(crate::i18n::context_item(cmd, &params, label)).selected(selected));
auto.add(&format!("timeline.prop.{uid}.menu.{key}"), resp.rect, label);
if resp.clicked() {
actions.push((cmd.into(), params));
ui.close();
}
};
item(ui, "reset", "Reset", false, "prop.reset", json!({"layer": l, "prop": uid}));
if matches!(prop.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
item(ui, "editValue", "Edit Value…", false, "prop.set", json!({"layer": l, "prop": uid}));
}
if is_position(layer, uid) {
let separated = layer.transform().is_some_and(|tr| tr.get("positionX").is_some());
item(ui, "separateDimensions", "Separate Dimensions", separated, "prop.separateDimensions", json!({"layer": l, "value": !separated}));
}
ui.separator();
let (key, label) = if prop.expr.is_some() { ("removeExpression", "Remove Expression") } else { ("addExpression", "Add Expression") };
item(ui, key, label, false, "prop.setExpression", json!({"layer": l, "prop": uid}));
item(ui, "essentialGraphics", "Add Property to Essential Graphics", false, "essential.addProperty", json!({"comp": comp.0, "layer": l, "prop": uid}));
}
/// Whether `layer` shows vector footage (SVG, PDF, AI, EPS).
pub(crate) fn vector_footage_layer(project: &effectcraft_engine::project::Project, layer: &Layer) -> bool {
layer
.source
.item()
.and_then(|item| project.item(item))
.is_some_and(|item| matches!(&item.kind, effectcraft_engine::project::ItemKind::Footage(footage) if effectcraft_engine::vector::is_vector(footage)))
}
/// Whether the Collapse Transformations / Continuously Rasterize switch applies: precomps, shape and text
/// layers, and vector footage (SVG, PDF, AI, EPS), which the renderer re-rasterizes at the layer's scale.
fn collapse_applies(layer: &Layer, project: &effectcraft_engine::project::Project) -> bool {
match &layer.source {
LayerSource::Comp { .. } | LayerSource::Shape | LayerSource::Text => true,
LayerSource::Footage { item } => {
let kind = project.item(*item).map(|i| &i.kind);
matches!(kind, Some(effectcraft_engine::project::ItemKind::Footage(f)) if effectcraft_engine::render::is_vector_footage(f))
}
_ => false,
}
}
/// Which Create ▸ conversions and Reveal ▸ actions the layer context menu offers, from the
/// targeted layer's kind.
#[derive(Clone, Copy, Default)]
pub(crate) struct LayerMenuCaps {
/// SVG / PDF / AI / EPS footage: Create Shapes from Vector Layer.
pub vector: bool,
/// A text layer: Create Shapes from Text / Create Masks from Text.
pub text: bool,
/// The layer has a source item to reveal in the Project panel.
pub source: bool,
}
impl LayerMenuCaps {
pub(crate) fn of(project: &effectcraft_engine::project::Project, layer: &Layer) -> Self {
Self {
vector: vector_footage_layer(project, layer),
text: matches!(layer.source, LayerSource::Text),
source: layer.source.item().is_some_and(|item| project.item(item).is_some()),
}
}
}
/// A layer row's or bar's context menu. As in After Effects, right-clicking a layer that is part
/// of the selection keeps the selection and the menu acts on all of it; right-clicking an
/// unselected layer selects only that layer.
fn layer_context_menu(
resp: &egui::Response,
comp: &Comp,
layer: &Layer,
selected: &[LayerId],
caps: LayerMenuCaps,
auto: &mut crate::automation::Registry,
actions: &mut Vec<(String, serde_json::Value)>,
) {
let in_selection = selected.contains(&layer.id);
if resp.secondary_clicked() && !in_selection && !layer.switches.locked {
actions.push(("layer.select".into(), json!({"layers": [layer.id.0]})));
}
let targets: Vec<u64> = if in_selection { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] };
// The menu acts on the whole selection: offer Reveal when any target has a source, even if
// the layer under the pointer is a text, shape or null layer.
let any_source = targets.iter().any(|id| comp.layer(LayerId(*id)).is_some_and(|l| l.source.item().is_some()));
let caps = LayerMenuCaps { source: caps.source || any_source, ..caps };
crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, (auto, "timeline.layerMenu"), actions));
}
/// The layer switches the layer context menus toggle (After Effects lists them after Time):
/// `layer.setSwitch` name and label.
const MENU_SWITCHES: [(&str, &str); 1] = [("guide", "Guide Layer")];
/// Shared layer actions for Timeline rows and Composition context menus. `auto` is the automation
/// registry and the id prefix of the menu's items.
pub(crate) fn layer_menu(
ui: &mut egui::Ui,
layers: &[u64],
caps: LayerMenuCaps,
auto: (&mut crate::automation::Registry, &str),
actions: &mut Vec<(String, serde_json::Value)>,
) {
let Some(&id) = layers.first() else { return };
let (auto, prefix) = auto;
let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| {
if ui.button(crate::i18n::context_item(cmd, &params, label)).clicked() {
actions.push(("layer.select".into(), json!({"layers": layers})));
actions.push((cmd.into(), params));
ui.close();
}
};
ui.menu_button(crate::i18n::tr("New"), |ui| {
item(ui, "Text", "layer.newText", json!({}));
item(ui, "Solid…", "layer.newSolid", json!({}));
item(ui, "Light…", "layer.newLight", json!({}));
item(ui, "Camera…", "layer.newCamera", json!({}));
item(ui, "Null Object", "layer.newNull", json!({}));
item(ui, "Shape Layer", "layer.newShape", json!({"kind": "none"}));
item(ui, "Adjustment Layer", "layer.newAdjustment", json!({}));
});
item(ui, "Layer Settings…", "layer.settings", json!({}));
if caps.source {
item(ui, "Reveal Layer Source in Project", "layer.revealSource", json!({"layers": layers}));
}
if caps.vector {
item(ui, "Create Shapes from Vector Layer", "layer.create", json!({"op": "shapesFromVector", "layers": layers}));
}
if caps.text {
item(ui, "Create Shapes from Text", "layer.create", json!({"op": "shapesFromText", "layers": layers}));
item(ui, "Create Masks from Text", "layer.create", json!({"op": "masksFromText", "layers": layers}));
}
ui.separator();
ui.menu_button(crate::i18n::tr("Masks"), |ui| {
item(ui, "New Mask", "layer.addMask", json!({"layer": id}));
});
ui.menu_button(crate::i18n::tr("Transform"), |ui| {
item(ui, "Reset", "layer.transform", json!({"layers": layers, "op": "reset"}));
item(ui, "Center In View", "layer.transform", json!({"layers": layers, "op": "center"}));
item(ui, "Fit to Comp", "layer.transform", json!({"layers": layers, "op": "fit"}));
item(ui, "Flip Horizontal", "layer.transform", json!({"layers": layers, "op": "flipH"}));
item(ui, "Flip Vertical", "layer.transform", json!({"layers": layers, "op": "flipV"}));
});
ui.menu_button(crate::i18n::tr("Time"), |ui| {
item(ui, "Enable Time Remapping", "layer.enableTimeRemap", json!({"layers": layers}));
item(ui, "Time-Reverse Layer", "layer.timeReverse", json!({"layers": layers}));
item(ui, "Time Stretch…", "layer.timeStretch", json!({}));
item(ui, "Freeze Frame", "layer.freezeFrame", json!({"layers": layers}));
item(ui, "Freeze On Last Frame", "layer.freezeOnLastFrame", json!({"layers": layers}));
});
ui.menu_button(crate::i18n::tr("Markers"), |ui| {
item(ui, "Add Marker", "layer.addMarker", json!({"layers": layers}));
});
// A click toggles the switch on the target layers, as Layer ▸ Guide Layer does.
// (Pushed after the other items: `item` holds `actions` until its last use.)
let mut switch_actions: Vec<(String, serde_json::Value)> = Vec::new();
for (switch, label) in MENU_SWITCHES {
let response = ui.button(crate::i18n::tr(label));
auto.add(&format!("{prefix}.switch.{switch}"), response.rect, label);
if response.clicked() {
switch_actions.push(("layer.select".into(), json!({"layers": layers})));
switch_actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": switch})));
ui.close();
}
}
ui.menu_button(crate::i18n::tr("Blending Mode"), |ui| {
widgets::menu_scroll(ui, false, |ui| {
for m in BlendMode::ALL {
item(ui, m.label(), "layer.setBlendMode", json!({"layers": layers, "mode": m.label()}));
if m.ends_group() {
ui.separator();
}
}
})
});
ui.menu_button(crate::i18n::tr("Layer Styles"), |ui| layer_styles_submenu(ui, layers, &mut item));
ui.menu_button(crate::i18n::tr("Effects"), |ui| effects_submenu(ui, layers, &mut item));
ui.menu_button(crate::i18n::tr("Arrange"), |ui| {
item(ui, "Bring Layer to Front", "layer.arrange", json!({"layers": layers, "to": "front"}));
item(ui, "Bring Layer Forward", "layer.arrange", json!({"layers": layers, "to": "forward"}));
item(ui, "Send Layer Backward", "layer.arrange", json!({"layers": layers, "to": "backward"}));
item(ui, "Send Layer to Back", "layer.arrange", json!({"layers": layers, "to": "back"}));
});
ui.separator();
item(ui, "Pre-compose…", "layer.precompose", json!({"layers": layers}));
item(ui, "Duplicate", "edit.duplicate", json!({"layers": layers}));
item(ui, "Split Layer", "edit.splitLayer", json!({"layers": layers}));
item(ui, "Delete", "edit.clear", json!({"layers": layers}));
actions.extend(switch_actions);
}
/// The layer context menu's Layer Styles submenu, matching the Layer ▸ Layer Styles application menu.
fn layer_styles_submenu(ui: &mut egui::Ui, layers: &[u64], item: &mut dyn FnMut(&mut egui::Ui, &str, &str, serde_json::Value)) {
item(ui, "Layer Style Options...", "layer.style.options", json!({}));
ui.separator();
item(ui, "Convert to Editable Styles", "layer.style.convertToEditable", json!({"layers": layers}));
item(ui, "Show All", "layer.style.showAll", json!({"layers": layers}));
item(ui, "Remove All", "layer.style.removeAll", json!({"layers": layers}));
ui.separator();
for (id, label) in effectcraft_engine::project::styles::STYLES {
let cmd = match *id {
"dropShadow" => "layer.style.dropShadow",
"innerShadow" => "layer.style.innerShadow",
"outerGlow" => "layer.style.outerGlow",
"innerGlow" => "layer.style.innerGlow",
"bevelEmboss" => "layer.style.bevelEmboss",
"satin" => "layer.style.satin",
"colorOverlay" => "layer.style.colorOverlay",
"gradientOverlay" => "layer.style.gradientOverlay",
"stroke" => "layer.style.stroke",
_ => continue,
};
item(ui, label, cmd, json!({}));
}
}
/// The layer context menu's Effects submenu: every effect from the registry grouped by category,
/// in the same order as the Effect menu's palette, so the two stay in sync automatically.
fn effects_submenu(ui: &mut egui::Ui, layers: &[u64], item: &mut dyn FnMut(&mut egui::Ui, &str, &str, serde_json::Value)) {
let mut cats: Vec<(&str, Vec<&'static effectcraft_engine::effects::EffectSpec>)> = Vec::new();
for e in effectcraft_engine::effects::all() {
match cats.iter_mut().find(|(c, _)| *c == e.category) {
Some((_, v)) => v.push(e),
None => cats.push((e.category, vec![e])),
}
}
cats.sort_by_key(|(c, _)| c.to_ascii_lowercase());
widgets::menu_scroll(ui, false, |ui| {
for (cat, mut fx) in cats {
fx.sort_by_key(|e| e.name.to_ascii_lowercase());
ui.menu_button(crate::i18n::effects::category(cat), |ui| {
widgets::menu_scroll(ui, false, |ui| {
for e in fx {
item(ui, e.name, "effect.apply", json!({"effect": e.id, "layers": layers}));
}
});
});
}
});
}
fn fmt_num(v: f64, d: usize) -> String {
format!("{v:.d$}")
}
/// What the property value editors share: where to draw, what they edit, and how a change is
/// worded as a `prop.set` command.
struct ValueEdit<'a> {
p: &'a egui::Painter,
layer: &'a Layer,
prop: &'a Property,
at: Pos2,
uid: u64,
merge: String,
scrub: bool,
proportional: bool,
}
impl<'a> ValueEdit<'a> {
/// The editor of `prop` on `layer` at `at`; `ui` says whether a scrub is under way.
fn new(ui: &egui::Ui, p: &'a egui::Painter, layer: &'a Layer, prop: &'a Property, at: Pos2) -> Self {
let uid = prop.uid;
// With several layers selected the edit applies to all of them (#491): a scrub by the same
// change, a typed value as it is.
let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging());
// Proportional Scrubbing (After Effects 26.2): Ctrl+Alt (Cmd+Opt) spreads the change over the
// selected layers by selection order, the first unchanged and the last by all of it.
let proportional = scrub && ui.input(|i| widgets::proportional_scrub(i.modifiers));
Self { p, layer, prop, at, uid, merge: format!("scrub-{uid}"), scrub, proportional }
}
/// Queues the `prop.set` of one edit; consecutive edits of the property (a scrub) are one undo step.
fn set(&self, actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value) {
self.push(actions, v, Some(&self.merge));
}
/// Queues the `prop.set` of a discrete choice (a drop-down pick, a check box): its own undo
/// step, never merged with the property's previous edit (#2152).
fn set_step(&self, actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value) {
self.push(actions, v, None);
}
fn push(&self, actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value, merge: Option<&str>) {
actions.push((
"prop.set".into(),
json!({"layer": self.layer.id.0, "prop": self.uid, "value": v, "merge": merge, "selected": true, "offset": self.scrub, "proportional": self.proportional}),
))
}
}
/// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a
/// multi-dimension value's fields, one per dimension (none for other values).
fn value_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, value: &Value, actions: &mut Vec<(String, serde_json::Value)>) -> Vec<Rect> {
match value {
Value::Scalar(_) => scalar_editor(app, ui, ve, actions, value),
Value::Vec2(_) | Value::Vec3(_) => return vector_editor(app, ui, ve, actions, value),
Value::Color(_) => color_editor(app, ui, ve, actions, value),
Value::Bool(_) => bool_editor(app, ui, ve, actions, value),
Value::Enum(_) => enum_editor(app, ui, ve, actions, value),
Value::Layer(_) => layer_editor(app, ui, ve, actions, value),
Value::Gradient(_) => gradient_editor(app, ui, ve, actions),
Value::Path(_) => {
ve.p.text(pos2(ve.at.x, ve.at.y), Align2::LEFT_CENTER, crate::i18n::tr("Shape…"), Tokens::ui(12.0), app.tokens.hot_text);
}
Value::Text(_) => {}
Value::Str(s) => {
ve.p.text(pos2(ve.at.x, ve.at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::<String>(), Tokens::ui(12.0), app.tokens.text_dim);
}
}
Vec::new()
}
/// A number: scrubbable hot text, or the angle field.
fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
let Value::Scalar(v) = value else { return };
let (layer, prop, at, uid) = (ve.layer, ve.prop, ve.at, ve.uid);
// The values themselves: red while an expression drives the property (#1306).
let vt = app.tokens.for_value(prop.has_expression());
let (x, y) = (at.x, at.y - 9.0);
let (min, max, dec, speed) = match &prop.ui {
ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, widgets::slider_scrub_speed(&prop.ui)),
ParamUi::Percent => (-1e6, 1e6, 1, 0.5),
_ => (-1e9, 1e9, 1, 1.0),
};
if matches!(prop.ui, ParamUi::Angle) {
let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &vt);
app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name);
app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name);
if let Some(nv) = nv {
ve.set(actions, json!(nv));
}
} else {
// A Range Selector's Start, End and Offset count characters with Units = Index (#1814).
let pct = (matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity") && !effectcraft_engine::render::text::in_index_units(layer, uid);
let suffix = if pct { "%" } else { "" };
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(x, y),
egui::Id::unique(("v", uid, 0)),
*v,
&widgets::HotSpec { speed, range: (min, max), decimals: dec.max(1), suffix },
&vt,
);
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
if let Some(nv) = nv {
ve.set(actions, json!(nv));
}
}
}
/// A multi-dimension value: one hot-text field per dimension, with the link chain. Returns their rects.
fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) -> Vec<Rect> {
let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid);
let t = app.tokens;
let vt = t.for_value(prop.has_expression());
let (mut x, y) = (at.x, at.y - 9.0);
let is_3d = layer.is_3d();
let mut dims = vec![];
let c = value.components();
let n = if prop.shown_dims > 0 && !(is_3d && c.len() == 3) { prop.shown_dims as usize } else { c.len() };
let pct = matches!(prop.ui, ParamUi::Percent);
// Mask Feather: two linked values, never negative (as in After Effects, #203).
let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. }));
let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) };
// Audio Levels link their two channels too, off by default, by the same dB change (#2485).
let levels = prop.match_id == "levels" && layer.props.parent_of(uid).is_some_and(|g| g.match_id == "audio");
// Scale and Mask Feather: the chain link (Constrain Proportions, on by default).
// Rectangle and Ellipse Path Size have it too, off by default (#2489).
let size = prop.match_id == "size" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.match_id.as_str(), "rect" | "ellipse"));
let linkable = feather || size || levels || (pct && prop.match_id == "scale");
let linked = linkable && app.ui.timeline.is_linked(uid, !(size || levels));
if linkable {
let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0));
let label = if levels { "Link Channels" } else { "Constrain Proportions" };
let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr(label));
icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
app.auto.add(&format!("timeline.prop.{uid}.link"), lr, label);
if resp.clicked() {
app.ui.timeline.toggle_link(uid, !(size || levels));
}
x += 18.0;
}
for d in 0..n.min(c.len()) {
let suffix = if pct && d + 1 == n { "%" } else { "" };
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(x, y),
egui::Id::unique(("v", uid, d)),
c[d],
&widgets::HotSpec { speed: if pct { 0.5 } else { 1.0 }, range, decimals: 1, suffix },
&vt,
);
app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name);
dims.push(r);
if let Some(nv) = nv {
let mut nc = c.clone();
// Alt edits one value of a linked pair (Ctrl+Alt is a proportional scrub).
let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 };
let from = if shown == 1 { d } else { 0 };
let link = if levels { widgets::shifted } else { widgets::constrained };
for (e, v) in link(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() {
if let Some(slot) = nc.get_mut(from + e) {
*slot = v;
}
}
ve.set(actions, json!(nc));
// Typed into these components only: other selected layers keep the rest of
// their own value (#1434).
// Linked Audio Levels: the typed channel, the other following by that layer's own
// change, so each keeps its balance (#2485).
if let Some((_, a)) = actions.last_mut() {
if levels {
a["dims"] = json!([d]);
a["follow"] = json!((from..from + shown).filter(|&e| e != d).collect::<Vec<_>>());
} else {
a["dims"] = json!((from..from + shown).collect::<Vec<_>>());
}
}
}
x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 };
if d + 1 < n {
p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), vt.hot_text);
x += 6.0;
}
}
dims
}
/// A colour: swatch, eyedropper and picker.
fn color_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
let Value::Color(c) = value else { return };
let (layer, prop, at, uid) = (ve.layer, ve.prop, ve.at, ve.uid);
let t = app.tokens;
let x = at.x;
let r = Rect::from_min_size(pos2(x, at.y - 7.0), vec2(26.0, 14.0));
let resp = widgets::swatch(ui, r, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("sw", uid)), &t);
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
let pop = egui::Id::unique(("cpop", uid));
if resp.clicked() {
widgets::open_popup(ui, pop);
}
// The eyedropper after the swatch samples the Composition panel (shape Fill and
// Stroke colours, text and effect colours), as in After Effects' Timeline (#633).
let er = Rect::from_center_size(pos2(r.max.x + 12.0, at.y), vec2(16.0, 16.0));
let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color");
if super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::unique(("tl-eye", uid)), &t)
.on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel"))
.clicked()
{
super::effect_controls::toggle_pick(app, "color", layer, prop);
}
app.auto.add(&format!("timeline.prop.{uid}.eyedropper"), er, &prop.name);
let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32];
if crate::widgets::color_popup(ui, pop, r.left_bottom(), &mut rgb) {
ve.set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));
}
}
/// A checkbox.
fn bool_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
let Value::Bool(b) = value else { return };
let (prop, at, uid) = (ve.prop, ve.at, ve.uid);
let t = app.tokens;
let x = at.x;
let r = Rect::from_min_size(pos2(x, at.y - 8.0), vec2(16.0, 16.0));
if widgets::checkbox(ui, r, *b, &t, egui::Id::unique(("cb", uid))).changed() {
ve.set_step(actions, json!(!b));
}
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
}
/// A popup (or the number when the property has no options).
fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
let Value::Enum(i) = value else { return };
let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid);
let t = app.tokens;
let x = at.x;
// Cryptomatte's Layer lists the footage's Cryptomatte layers (value k is the choice's own).
let crypto = crate::panels::fx_editors::is_crypto_layer_of(layer, prop).then(|| {
let aux = app.session.layer_channels(layer.id);
effectcraft_engine::effects::crypto_layer_menu(aux.as_deref(), *i as usize)
});
let options = match &crypto {
Some((choices, _)) => Some(std::borrow::Cow::Owned(choices.iter().map(|c| c.1.clone()).collect::<Vec<_>>())),
None => layer.popup_options(prop),
};
if let Some(options) = options {
let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
let label = match &crypto {
Some((_, shown)) => shown.clone().unwrap_or_default(),
None => options.get(*i as usize).cloned().unwrap_or_default(),
};
let cur = if crypto.is_some() { options.iter().position(|o| *o == label) } else { Some(*i as usize) };
let pop = egui::Id::unique(("epop", uid));
if widgets::dropdown(ui, r, &label, &t, egui::Id::unique(("en", uid))).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), &options, cur) {
let value = crypto.as_ref().and_then(|(choices, _)| choices.get(ni)).map_or(ni, |c| c.0);
ve.set_step(actions, json!(value));
}
} else {
p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.for_value(prop.has_expression()).hot_text);
}
}
/// A layer reference picker.
fn layer_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
let Value::Layer(l) = value else { return };
let (prop, at, uid) = (ve.prop, ve.at, ve.uid);
let t = app.tokens;
let x = at.x;
// Layer references (a light's Source, a Displacement Map's layer…) pick from the
// comp's layers, as in Effect Controls; the choice is written through the same
// undoable prop.set as the other value arms.
let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
if let Some(comp) = app.session.active_comp_arc()
&& let Some(chosen) = widgets::layer_picker(ui, r, &comp.layers, *l, &t, egui::Id::unique(("tl-lay", uid)))
{
ve.set_step(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
}
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
}
/// Opens the Gradient Editor at the current time (#612).
fn gradient_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>) {
let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid);
let t = app.tokens;
let x = at.x;
// Opens the Gradient Editor at the current time (#612).
let r = p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text);
let resp = ui.interact(r.expand(2.0), egui::Id::unique(("gradient-edit", uid)), Sense::click());
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
if resp.clicked() {
actions.push(("prop.editGradient".into(), json!({"layer": layer.id.0, "prop": uid})));
}
}
#[cfg(test)]
mod tests;