mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-03 06:52:21 -06:00
Resting the pointer in an 18 pt band at a menu's bottom edge crawls the list after a short delay and speeds up the longer it stays there, so long menus stay reachable without letting go of the press-and-hold gesture. Context menus do not crawl.
4551 lines
237 KiB
Rust
4551 lines
237 KiB
Rust
//! 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, GroupKind, ItemId, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property};
|
||
use effectcraft_engine::render::EvalCtx;
|
||
use effectcraft_engine::time::Tick;
|
||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||
use serde_json::json;
|
||
|
||
use crate::i18n::tr;
|
||
use crate::icons::{self, Icon};
|
||
use crate::theme::Tokens;
|
||
use crate::{EffectcraftApp, widgets};
|
||
|
||
#[derive(Clone, Debug)]
|
||
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 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::new(("expr-buf", prop))
|
||
}
|
||
|
||
/// Insert an expression editor row after every property that has an expression: as many lines
|
||
/// as its text (the text being edited, so it grows while typing), up to 8, or the height its
|
||
/// bottom edge was dragged to.
|
||
fn with_expr_rows(rows: Vec<Row>, comp: &Comp, tl: &crate::state::TimelineState, ctx: &egui::Context) -> Vec<Row> {
|
||
let mut out = Vec::with_capacity(rows.len());
|
||
// Rows come grouped by layer: look each layer up once (a linear search per property row
|
||
// made twirling open many layers of a big comp quadratic).
|
||
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 and a property's (Parent & Link column).
|
||
type PickWhip = (u8, u64, u64, Pos2);
|
||
const WHIP_PARENT: u8 = 0;
|
||
const WHIP_EXPR: u8 = 1;
|
||
const WHIP_PROP: u8 = 2;
|
||
|
||
fn pick_whip_id() -> egui::Id {
|
||
egui::Id::new("tl-pickwhip")
|
||
}
|
||
|
||
/// 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`].
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn pick_whip(
|
||
app: &mut EffectcraftApp,
|
||
ui: &egui::Ui,
|
||
p: &egui::Painter,
|
||
rect: Rect,
|
||
(kind, layer, prop): (u8, u64, u64),
|
||
auto: &str,
|
||
label: &str,
|
||
hint: &str,
|
||
) -> egui::Response {
|
||
let resp = ui.interact(rect, egui::Id::new(("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());
|
||
ui.ctx().data_mut(|d| d.insert_temp(pick_whip_id(), state));
|
||
}
|
||
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>)>);
|
||
|
||
fn whip_targets_id() -> egui::Id {
|
||
egui::Id::new("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]);
|
||
|
||
fn whip_into_id(prop: u64) -> egui::Id {
|
||
egui::Id::new(("tl-pickwhip-into", prop))
|
||
}
|
||
|
||
/// After a pick whip into the expression being edited: its editor takes the focus back, the
|
||
/// cursor this many characters before the end (just after the reference).
|
||
fn expr_refocus_id(prop: u64) -> egui::Id {
|
||
egui::Id::new(("tl-expr-refocus", prop))
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
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)
|
||
}
|
||
|
||
/// 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> {
|
||
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 {
|
||
v.push(l.in_point.seconds());
|
||
v.push(l.out_point.seconds());
|
||
v.extend(l.markers.iter().map(|m| l.comp_time(m.time).seconds()));
|
||
}
|
||
for r in rows {
|
||
if let RowKind::Prop { uid } = r.kind
|
||
&& 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
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|
||
|
||
fn tl_map_id() -> egui::Id {
|
||
egui::Id::new("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::new("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::new("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::new("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::new("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::new("tl-group-rename")
|
||
}
|
||
|
||
fn rename_focus_id() -> egui::Id {
|
||
egui::Id::new("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> {
|
||
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 =
|
||
(parent.match_id == effectcraft_engine::project::styles::GROUP && g.match_id != effectcraft_engine::project::styles::BLENDING).then_some(g.enabled);
|
||
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 } }
|
||
}
|
||
fn all(l: &Layer, g: &PropGroup, depth: usize, out: &mut Vec<Row>) {
|
||
for c in &g.children {
|
||
match c {
|
||
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 } }),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
fn rec(l: &Layer, g: &PropGroup, depth: usize, q: &str, out: &mut Vec<Row>) {
|
||
for c in &g.children {
|
||
match c {
|
||
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()
|
||
}
|
||
|
||
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 {
|
||
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(_) => {}
|
||
}
|
||
}
|
||
}
|
||
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) => {
|
||
let mut found = vec![];
|
||
collect_props(g, &mut found, &wanted);
|
||
rows.extend(found.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } }));
|
||
}
|
||
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),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
Node::Group(sg) => {
|
||
let o = open.contains(&sg.uid);
|
||
let fx = matches!(sg.kind, GroupKind::Effect { .. }).then_some(sg.enabled);
|
||
// Layer style groups have eye switches (not Blending Options).
|
||
let eye = (g.match_id == effectcraft_engine::project::styles::GROUP && sg.match_id != effectcraft_engine::project::styles::BLENDING)
|
||
.then_some(sg.enabled);
|
||
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).
|
||
fn layer_drag_id() -> egui::Id {
|
||
egui::Id::new("tl-layer-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>,
|
||
}
|
||
|
||
fn key_drag_id() -> egui::Id {
|
||
egui::Id::new("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,
|
||
}
|
||
|
||
fn quick_bar_id() -> egui::Id {
|
||
egui::Id::new("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::new("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,
|
||
}
|
||
|
||
fn switch_drag_id() -> egui::Id {
|
||
egui::Id::new("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))
|
||
}
|
||
|
||
/// A layer's switch (A/V Features and Switches columns) in `row`, with the
|
||
/// [`switch_gesture`].
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn layer_switch(
|
||
ui: &mut egui::Ui,
|
||
t: &Tokens,
|
||
row: Rect,
|
||
br: Rect,
|
||
icon: Icon,
|
||
on: bool,
|
||
id: egui::Id,
|
||
layer: u64,
|
||
name: &'static str,
|
||
others: bool,
|
||
actions: &mut Vec<(String, serde_json::Value)>,
|
||
) {
|
||
let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag());
|
||
// 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.
|
||
if others && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed() && i.modifiers.alt) {
|
||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "others": true})));
|
||
return;
|
||
}
|
||
if let Some((value, merge)) = switch_gesture(ui, &resp, row, name, on) {
|
||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "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).
|
||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name})));
|
||
}
|
||
}
|
||
|
||
fn anchor_id(comp: ItemId) -> egui::Id {
|
||
egui::Id::new(("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.get(a.min(b)..=a.max(b))?;
|
||
Some(span.iter().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(layer_rows: &[(Rect, LayerId)], 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)
|
||
}
|
||
|
||
fn layer_drop_id() -> egui::Id {
|
||
egui::Id::new("tl-layer-drop")
|
||
}
|
||
|
||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||
let t = app.tokens;
|
||
let ctx = ui.ctx().clone();
|
||
let Some(cid) = app.session.active_comp_id() else {
|
||
ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(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)
|
||
.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);
|
||
}
|
||
}
|
||
}
|
||
return;
|
||
};
|
||
let Some(comp) = app.session.project.comp_arc(cid) else { return };
|
||
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::new("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));
|
||
let graph_rect = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h + colhdr_h), 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 mut actions: Vec<(String, serde_json::Value)> = Vec::new();
|
||
if !ui.input(|i| i.pointer.primary_down()) {
|
||
ctx.data_mut(|d| d.remove::<SwitchDrag>(switch_drag_id()));
|
||
}
|
||
// ---- header (left): time display, search, switches.
|
||
let tc = crate::panels::timecode(&app.session, &comp, time);
|
||
let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0));
|
||
let tc_resp = ui.interact(tc_rect, egui::Id::new("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::new("tl-tc-edit"), tc.clone()));
|
||
}
|
||
if let Some(mut buf) = ctx.data(|d| d.get_temp::<String>(egui::Id::new("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::new("tl-tc-edit")));
|
||
} else {
|
||
widgets::keep_focus(&ctx, &r, &buf);
|
||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-tc-edit"), buf));
|
||
}
|
||
}
|
||
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, "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::new(("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;
|
||
}
|
||
|
||
// ---- ruler (right).
|
||
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 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::new("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::new(("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);
|
||
}
|
||
}
|
||
p.rect_filled(ruler, 0.0, t.tl_ruler_bg);
|
||
app.auto.add("timeline.ruler", ruler, &format!("{},{}", tm.start, tm.pps));
|
||
// 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 wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
|
||
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::new("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::new("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::new(("wa", set)), Sense::drag());
|
||
app.auto.add(&format!("timeline.workArea.{set}"), hr, set);
|
||
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"}));
|
||
}
|
||
}
|
||
// 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 fd = comp.frame_duration().seconds();
|
||
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 disk = app.frames.disk_frames(&app.render_source(), &series, frames, &opts);
|
||
let draw_blue = |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_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);
|
||
}
|
||
}
|
||
// 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;
|
||
}
|
||
// 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::new("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()
|
||
{
|
||
let now = ui.input(|i| i.time);
|
||
let t = app.session.time();
|
||
app.scrub_audio(cid, t, now);
|
||
}
|
||
}
|
||
|
||
// ---- column headers (right-click: show/hide columns; click the name header: Source/Layer Name).
|
||
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, 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);
|
||
}
|
||
}
|
||
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::new("tl-colhdr"), Sense::click());
|
||
app.auto.add("timeline.header", hdr_rect, "Columns (right-click)");
|
||
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::new("tl-colhdr-under"), under.unwrap_or("")));
|
||
}
|
||
crate::widgets::context_menu(&hresp, |ui| {
|
||
let under: &str = ctx.data(|d| d.get_temp(egui::Id::new("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(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;
|
||
|
||
// ---- rows.
|
||
let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline, &ctx);
|
||
let rh = t.row_h;
|
||
let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum();
|
||
let max_scroll = (total_h - rows_rect.height() + rh).max(0.0);
|
||
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();
|
||
}
|
||
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 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);
|
||
let snap_project = app.session.project.clone();
|
||
let snap_expr = app.session.expr.clone();
|
||
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
|
||
let lp = p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y))));
|
||
let gp = p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)));
|
||
let mut y = rows_rect.min.y - app.ui.timeline.scroll_y;
|
||
let selected = app.session.state.selected_layers.clone();
|
||
let sel_keys = app.session.state.selected_keys.clone();
|
||
let mut ui_actions: Vec<UiAct> = Vec::new();
|
||
let idx_of = |id: LayerId| comp.index_of(id).unwrap_or(0);
|
||
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(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
|
||
// 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::new("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::new("tl-layer-bg"), Sense::click_and_drag());
|
||
app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers");
|
||
let mut hit_rows: Vec<(Rect, Row)> = vec![];
|
||
// The value fields of multi-dimension properties (per dimension), for the pick whip.
|
||
let mut dim_hits: Vec<(u64, Vec<Rect>)> = vec![];
|
||
// 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(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;
|
||
}
|
||
hit_rows.push((r, row.clone()));
|
||
let Some(layer) = comp.layer(row.layer) else { continue };
|
||
let is_sel = selected.contains(&layer.id);
|
||
let left = Rect::from_min_max(r.min, pos2(graph_x0 - 1.0, r.max.y));
|
||
let cy = r.min.y + rh / 2.0;
|
||
match &row.kind {
|
||
RowKind::Layer => {
|
||
// After Effects draws layer rows as one flat tone separated by dark hairlines
|
||
// (outline and time graph alike), not as alternating stripes.
|
||
let _ = ri;
|
||
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));
|
||
// A/V features.
|
||
let sw = &layer.switches;
|
||
let av_items: [(Icon, bool, &str, bool); 4] = [
|
||
(Icon::Eye, sw.video, "video", layer.source.is_av()),
|
||
(Icon::Speaker, sw.audio, "audio", layer.props.sub("audio").is_some()),
|
||
(Icon::Solo, sw.solo, "solo", layer.source.is_av()),
|
||
(Icon::Lock, sw.locked, "lock", true),
|
||
];
|
||
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, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, true, &mut actions);
|
||
app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name);
|
||
}
|
||
// 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::new(("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::new(("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": [layer.id.0], "label": lab.name()})));
|
||
}
|
||
}
|
||
if vis.num {
|
||
lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", idx_of(layer.id)), Tokens::ui(11.5), t.text_dim);
|
||
}
|
||
layer_cells(app, ui, &lp, &cw, &comp, layer, r, cy, &mut actions);
|
||
// 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::new(("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::new(("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);
|
||
let name_rect = Rect::from_min_max(pos2(cw.name + 34.0, r.min.y), pos2(cw.name_end - 4.0, r.max.y));
|
||
let rename_id = egui::Id::new("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(cw.name_end - 4.0).min(cw.name_end - 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);
|
||
}
|
||
// Locked layers can't be selected or moved (unlock them with the lock switch).
|
||
let locked = layer.switches.locked;
|
||
if row_resp.drag_started() && !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(), moving));
|
||
}
|
||
if row_resp.drag_stopped() {
|
||
ctx.data_mut(|d| d.insert_temp(layer_drop_id(), true));
|
||
}
|
||
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 actions);
|
||
// Switches.
|
||
let sws: [(Icon, bool, &str, bool); 8] = [
|
||
(Icon::Shy, sw.shy, "shy", true),
|
||
(Icon::Collapse, sw.collapse, "collapse", collapse_applies(layer, &app.session.project)),
|
||
(Icon::Quality, sw.quality == effectcraft_engine::project::Quality::Best, "quality", layer.source.is_av()),
|
||
(Icon::Fx, sw.effects, "fx", layer.effects().is_some_and(|f| !f.children.is_empty())),
|
||
(
|
||
Icon::FrameBlend,
|
||
sw.frame_blend != effectcraft_engine::project::FrameBlend::Off,
|
||
"frameBlend",
|
||
matches!(layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }),
|
||
),
|
||
(Icon::MotionBlur, sw.motion_blur, "motionBlur", layer.source.is_av()),
|
||
(Icon::Adjustment, sw.adjustment, "adjustment", layer.source.is_av()),
|
||
(Icon::Cube, layer.is_3d(), "threeD", layer.source.is_av() || matches!(layer.source, LayerSource::Null)),
|
||
];
|
||
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, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, false, &mut actions);
|
||
app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name);
|
||
}
|
||
// Modes.
|
||
if app.ui.timeline.show_modes && layer.source.is_av() {
|
||
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::new(("mode", layer.id.0)));
|
||
app.auto.add(&format!("timeline.layer.{}.mode", layer.id.0), mr, "Mode");
|
||
let pop = egui::Id::new(("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": [layer.id.0], "mode": opts[i]})));
|
||
}
|
||
let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0));
|
||
if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).clicked() {
|
||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": "preserveTransparency"})));
|
||
}
|
||
// Track matte: layer picker + kind.
|
||
let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0));
|
||
let label = match layer.track_matte {
|
||
Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", idx_of(l.id), 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::new(("trkmat", layer.id.0)));
|
||
app.auto.add(&format!("timeline.layer.{}.trackMatte", layer.id.0), mr, "Track Matte");
|
||
let pop = egui::Id::new(("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(layer, &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;
|
||
}
|
||
}
|
||
// Parent.
|
||
if vis.parent {
|
||
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),
|
||
&format!("timeline.layer.{}.pickWhip", layer.id.0),
|
||
"Parent pick whip",
|
||
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!("{}. {}", idx_of(l.id), l.name)).unwrap_or_else(|| "None".into());
|
||
let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::new(("parent", layer.id.0)));
|
||
app.auto.add(&format!("timeline.layer.{}.parent", layer.id.0), pr_rect, "Parent");
|
||
let pop = egui::Id::new(("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 = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) };
|
||
actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par})));
|
||
}
|
||
}
|
||
// Layer bar.
|
||
ui.set_clip_rect(right_clip);
|
||
if !graph_on {
|
||
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);
|
||
}
|
||
// Source extent (e.g. trimmed footage) shown faint.
|
||
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");
|
||
}
|
||
}
|
||
}
|
||
// 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::new(("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::new(("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);
|
||
}
|
||
}
|
||
}
|
||
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::new(("bar", layer.id.0)), Sense::click_and_drag());
|
||
let lin = ui.interact(in_r, egui::Id::new(("bar-in", layer.id.0)), Sense::drag());
|
||
let lout = ui.interact(out_r, egui::Id::new(("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 actions);
|
||
let drag_key = format!("bar-{}", layer.id.0);
|
||
// Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the
|
||
// drag starts and followed below the rows until the button is released.
|
||
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 }));
|
||
}
|
||
let quick = ctx.data(|d| d.get_temp::<QuickBar>(quick_bar_id())).is_some_and(|q| q.layer == layer.id);
|
||
for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] {
|
||
if kind == "move" && quick {
|
||
continue;
|
||
}
|
||
if resp.dragged() {
|
||
let acc_id = egui::Id::new(("bar-acc", layer.id.0, kind));
|
||
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
|
||
acc += resp.drag_delta().x;
|
||
let frames = ((acc as f64 / pps) / fd).trunc() as i64;
|
||
if frames != 0 {
|
||
acc -= (frames as f64 * fd * pps) as f32;
|
||
let d = frames as f64 * fd;
|
||
// Moving or trimming a selected layer acts on the whole
|
||
// selection; each layer trims from its own in/out point.
|
||
let targets = if is_sel { json!(selected.iter().map(|l| l.0).collect::<Vec<_>>()) } else { json!([layer.id.0]) };
|
||
let params = match kind {
|
||
"move" => json!({"layers": targets, "delta": d, "merge": drag_key}),
|
||
"in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}),
|
||
_ => json!({"layers": targets, "outDelta": d, "merge": drag_key}),
|
||
};
|
||
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>(egui::Id::new(("bar-acc", layer.id.0, kind))));
|
||
ui_actions.push(UiAct::EndMerge);
|
||
}
|
||
}
|
||
// Markers on the layer (drag, Alt-drag duration, Cmd-click delete, dialog).
|
||
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);
|
||
}
|
||
}
|
||
RowKind::Group { uid, name, open, has_children, fx, eye } => {
|
||
// The row draws an effect's name in the interface language (#821); `name` stays
|
||
// the property tree's, which the automation labels below keep. Any other group
|
||
// (a mask, a shape group, a layer style) draws its own name.
|
||
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 % 2 == 0 {
|
||
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);
|
||
let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
|
||
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::new(("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::new(("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 {
|
||
// Layer style 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::new(("geye", uid)), Sense::click()).clicked() {
|
||
actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})));
|
||
}
|
||
app.auto.add(&format!("timeline.group.{uid}.eye"), er, name);
|
||
}
|
||
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);
|
||
}
|
||
text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
|
||
shape_add_popup(app, ui, &lp, layer, *uid, 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::new(("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, cw.switches, cy, &mut actions);
|
||
// 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::new(("maskmode", uid));
|
||
if widgets::dropdown(ui, mr, mode.label(), &t, egui::Id::new(("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::new(("minv", uid))).clicked() {
|
||
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);
|
||
}
|
||
let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
|
||
let gr = ui.interact(gr_rect, egui::Id::new(("grow", uid)), Sense::click());
|
||
app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
|
||
if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
|
||
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
|
||
}
|
||
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));
|
||
}
|
||
}
|
||
}
|
||
RowKind::Waveform { item } => {
|
||
lp.rect_filled(left, 0.0, if ri % 2 == 0 { 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, "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,
|
||
"Building waveform…",
|
||
Tokens::ui(11.0),
|
||
t.text_faint,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
RowKind::Expr { uid, lines } => {
|
||
let Some(prop) = layer.props.find(*uid) else { continue };
|
||
let Some(ex) = prop.expr.clone() else { continue };
|
||
let (buf_id, edit_id) = (expr_buf_id(*uid), egui::Id::new(("expr-edit", uid)));
|
||
// The same background as its property's row, selection included.
|
||
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 % 2 == 0 {
|
||
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::new(("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),
|
||
&format!("timeline.prop.{uid}.pickWhip"),
|
||
"Expression pick whip",
|
||
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::new(("expr-err", uid)), Sense::hover()).on_hover_text(e);
|
||
}
|
||
// ⓕ: 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::new(("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 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(after) = ctx.data_mut(|d| d.remove_temp::<usize>(expr_refocus_id(*uid))) {
|
||
// A pick whip just put a reference in: editing goes on in the new text,
|
||
// the cursor after the reference.
|
||
buf = ex.text.clone();
|
||
let mut st = egui::text_edit::TextEditState::load(&ctx, edit_id).unwrap_or_default();
|
||
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(buf.chars().count().saturating_sub(after)))));
|
||
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, &sp, if ex.enabled { expr_col } else { t.text_dim }, *lines, &t);
|
||
app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name);
|
||
// Its bottom edge drags to set its height in whole lines, as in After Effects.
|
||
let edge = Rect::from_min_max(pos2(er.min.x, r.max.y - 4.0), pos2(er.max.x, r.max.y));
|
||
let eresp = ui.interact(edge, egui::Id::new(("expr-edge", uid)), Sense::drag());
|
||
app.auto.add(&format!("timeline.prop.{uid}.expressionHeight"), edge, "Expression field height");
|
||
if eresp.hovered() || eresp.dragged() {
|
||
ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical);
|
||
}
|
||
if let Some(py) = eresp.interact_pointer_pos().filter(|_| eresp.dragged()).map(|p| p.y) {
|
||
let n = ((py - r.min.y) / 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));
|
||
}
|
||
}
|
||
RowKind::Prop { uid } => {
|
||
let Some(prop) = layer.props.find(*uid) else { continue };
|
||
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 % 2 == 0 {
|
||
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);
|
||
let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
|
||
// 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::new(("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::new(("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::new(("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");
|
||
}
|
||
// 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::new(("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})));
|
||
}
|
||
} 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})));
|
||
}
|
||
}
|
||
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::new(("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::new("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));
|
||
// Value editors.
|
||
let value = ectx.value(layer, prop);
|
||
let vx = (cw.switches + 4.0).max(name_x + 120.0);
|
||
let dims = value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &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),
|
||
&format!("timeline.prop.{uid}.propertyPickWhip"),
|
||
"Property pick whip",
|
||
crate::i18n::tr("Property pick whip: drag onto a property"),
|
||
);
|
||
}
|
||
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,
|
||
);
|
||
}
|
||
// Keyframes.
|
||
if !graph_on {
|
||
for k in &prop.keys {
|
||
let ct = layer.comp_time(k.time);
|
||
let x = tm.x(ct.seconds());
|
||
if x < graph_x0 - 8.0 || x > rect.max.x + 8.0 {
|
||
continue;
|
||
}
|
||
let kref = effectcraft_engine::KeyRef { layer: layer.id, prop: *uid, time: k.time };
|
||
let ks = sel_keys.contains(&kref);
|
||
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,
|
||
};
|
||
if k.roving {
|
||
// Roving keys: a small dot (their time follows their neighbours).
|
||
gp.circle_filled(c, 2.5, kcol);
|
||
} else {
|
||
icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol);
|
||
}
|
||
let kr = Rect::from_center_size(c, vec2(12.0, 14.0));
|
||
let kresp = ui.interact(kr, egui::Id::new(("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));
|
||
let mods = ui.input(|i| i.modifiers);
|
||
let this_key = json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}], "selectProperties": true});
|
||
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));
|
||
}
|
||
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));
|
||
}
|
||
}
|
||
crate::widgets::context_menu(&kresp, |ui| {
|
||
for (lbl, cmd, params) in [
|
||
("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})),
|
||
("-", "", 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!({})),
|
||
("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.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 !ks {
|
||
actions.push((
|
||
"keys.select".into(),
|
||
json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}),
|
||
));
|
||
}
|
||
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;
|
||
}
|
||
if ui.button(lbl).clicked() {
|
||
if !ks {
|
||
actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
|
||
}
|
||
actions.push((cmd.into(), params));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
// 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 }));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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(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();
|
||
}
|
||
});
|
||
}
|
||
if outline_empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
|
||
actions.push(("layer.select".into(), json!({"layers": []})));
|
||
}
|
||
if let (Some(start), Some(end)) = (ctx.input(|i| i.pointer.press_origin()), outline_empty.interact_pointer_pos())
|
||
&& outline_empty.dragged()
|
||
{
|
||
let box_rect = Rect::from_two_pos(start, end).intersect(outline_rect);
|
||
lp.rect_filled(box_rect, 0.0, t.accent.gamma_multiply(0.12));
|
||
lp.rect_stroke(box_rect, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
|
||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-layer-box"), box_rect));
|
||
}
|
||
if outline_empty.drag_stopped()
|
||
&& let Some(box_rect) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("tl-layer-box")))
|
||
{
|
||
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::new("tl-layer-box")));
|
||
let layers: Vec<u64> = hit_rows
|
||
.iter()
|
||
.filter(|(r, row)| matches!(row.kind, RowKind::Layer) && r.intersects(box_rect))
|
||
.filter(|(_, row)| comp.layer(row.layer).is_some_and(|l| !l.switches.locked))
|
||
.map(|(_, row)| row.layer.0)
|
||
.collect();
|
||
actions.push(("layer.select".into(), json!({"layers": layers, "add": ui.input(|i| i.modifiers.shift || i.modifiers.command)})));
|
||
}
|
||
// Pick whips: a line follows the pointer; releasing over a row links to it (an expression's
|
||
// 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::new("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() && kind != WHIP_PARENT)
|
||
.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()) {
|
||
ctx.data_mut(|d| {
|
||
d.remove::<PickWhip>(pick_whip_id());
|
||
d.remove::<WhipTargets>(whip_targets_id());
|
||
});
|
||
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": [src_layer], "parent": row.layer.0})));
|
||
}
|
||
// 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);
|
||
}
|
||
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);
|
||
ctx.data_mut(|d| d.insert_temp(expr_refocus_id(src_prop), after));
|
||
}
|
||
actions.push(("prop.pickWhip".into(), params));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
// Alt-drag key group scaling (follows the pointer until release).
|
||
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, &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
|
||
// Effects; Shift / Cmd keep the selection. Drag: box-select keys.
|
||
if empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
|
||
actions.push(("layer.select".into(), json!({"layers": []})));
|
||
}
|
||
if let (true, Some(origin), Some(cur)) =
|
||
(empty.dragged(), empty.interact_pointer_pos().and_then(|_| ctx.input(|i| i.pointer.press_origin())), empty.interact_pointer_pos())
|
||
{
|
||
let br = Rect::from_two_pos(origin, cur);
|
||
gp.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12));
|
||
gp.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
|
||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-box"), br));
|
||
}
|
||
// Layer reorder drag: a line where the layers will land; the release moves them there.
|
||
if let Some(moving) = ctx.data(|d| d.get_temp::<Vec<u64>>(layer_drag_id())) {
|
||
let dropped = ctx.data_mut(|d| d.remove_temp::<bool>(layer_drop_id())).unwrap_or(false);
|
||
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)
|
||
&& !layer_rows.is_empty()
|
||
{
|
||
let (at, line_y) = insertion(&layer_rows, py, rows_rect.min.y);
|
||
let target = at.and_then(|i| layer_rows[i..].iter().find(|(_, l)| !moving.contains(&l.0)).map(|(_, l)| *l));
|
||
if dropped {
|
||
ctx.data_mut(|d| d.remove::<Vec<u64>>(layer_drag_id()));
|
||
// 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));
|
||
}
|
||
} else if dropped {
|
||
ctx.data_mut(|d| d.remove::<Vec<u64>>(layer_drag_id()));
|
||
}
|
||
}
|
||
// Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer
|
||
// until the button is released, wherever the bar's row has scrolled to.
|
||
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
|
||
// and composition markers.
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
if empty.drag_stopped()
|
||
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("tl-box")))
|
||
{
|
||
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::new("tl-box")));
|
||
let mut keys = vec![];
|
||
let mut yy = rows_rect.min.y - app.ui.timeline.scroll_y;
|
||
for row in &rows {
|
||
let cy = yy + rh / 2.0;
|
||
yy += row_height(row, rh);
|
||
if let RowKind::Prop { uid } = row.kind
|
||
&& cy >= br.min.y
|
||
&& cy <= br.max.y
|
||
&& 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 x >= br.min.x && x <= br.max.x {
|
||
keys.push(json!({"layer": l.id.0, "prop": uid, "time": k.time.seconds()}));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mods = ui.input(|i| i.modifiers);
|
||
if keys.is_empty() && !(mods.shift || mods.command) {
|
||
// A marquee that catches nothing leaves nothing selected (layers included).
|
||
actions.push(("layer.select".into(), json!({"layers": []})));
|
||
} else {
|
||
actions.push(("keys.select".into(), json!({"keys": keys, "add": mods.shift, "selectProperties": true})));
|
||
}
|
||
}
|
||
|
||
// CTI.
|
||
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");
|
||
}
|
||
let _ = graph_rect;
|
||
|
||
// Footer.
|
||
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::new("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.
|
||
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::new(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::new("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);
|
||
}
|
||
|
||
// 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. Dragging the ruler or the time graph scrolls it in time when
|
||
// zoomed in (registered last, so it takes the drag from the bars, keys and markers there).
|
||
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::new("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 });
|
||
}
|
||
if hand.dragged() && 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 - hand.drag_delta().x as f64 / pps).clamp(0.0, last);
|
||
}
|
||
}
|
||
|
||
// Drop targets: effects from Effects & Presets go on the layer under the pointer. Footage and
|
||
// comps from the Project panel and files from the Media Browser go where they are dropped: a
|
||
// line shows the place in the stack, and over the time graph a marker shows the In point too
|
||
// (Shift: at the current time).
|
||
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());
|
||
if let crate::panels::DragPayload::Effect(e) = payload.as_ref() {
|
||
p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||
let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
|
||
if released && let Some(l) = target {
|
||
actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]})));
|
||
}
|
||
} else {
|
||
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).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::Property { .. } => {}
|
||
}
|
||
}
|
||
}
|
||
if released {
|
||
egui::DragAndDrop::clear_payload(&ctx);
|
||
}
|
||
}
|
||
|
||
// Apply.
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The optional per-layer cells: Comment (double-click to edit), In / Out / Duration (drag to
|
||
/// change by frames), Stretch (click: Time Stretch dialog).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn layer_cells(
|
||
app: &mut EffectcraftApp,
|
||
ui: &mut egui::Ui,
|
||
lp: &egui::Painter,
|
||
cw: &Cols,
|
||
comp: &Comp,
|
||
layer: &Layer,
|
||
r: Rect,
|
||
cy: f32,
|
||
actions: &mut Vec<(String, serde_json::Value)>,
|
||
) {
|
||
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::new("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::new(("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 = crate::panels::timecode(&app.session, comp, Tick::from_seconds_f64(secs) - if kind == "duration" { comp.display_start } else { Tick(0) });
|
||
let resp = ui.interact(cr, egui::Id::new(("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::new(("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::new(("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).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn dash_buttons(
|
||
app: &mut EffectcraftApp,
|
||
ui: &mut egui::Ui,
|
||
p: &egui::Painter,
|
||
layer: &Layer,
|
||
uid: u64,
|
||
x: f32,
|
||
cy: f32,
|
||
actions: &mut Vec<(String, serde_json::Value)>,
|
||
) {
|
||
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::new(("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"),
|
||
("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.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn shape_add_popup(
|
||
app: &mut EffectcraftApp,
|
||
ui: &mut egui::Ui,
|
||
p: &egui::Painter,
|
||
layer: &Layer,
|
||
uid: u64,
|
||
x: f32,
|
||
cy: f32,
|
||
actions: &mut Vec<(String, serde_json::Value)>,
|
||
) {
|
||
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::new(("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::new(("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).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn text_anim_popups(
|
||
app: &mut EffectcraftApp,
|
||
ui: &mut egui::Ui,
|
||
p: &egui::Painter,
|
||
layer: &Layer,
|
||
uid: u64,
|
||
x: f32,
|
||
cy: f32,
|
||
actions: &mut Vec<(String, serde_json::Value)>,
|
||
) {
|
||
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,
|
||
};
|
||
p.text(pos2(x + 6.0, cy), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
|
||
let br = Rect::from_center_size(pos2(x + 6.0 + if key == "add" { 34.0 } else { 62.0 }, cy), vec2(16.0, 16.0));
|
||
let resp = ui.interact(br, egui::Id::new(("tl-textanim", uid)), Sense::click()).on_hover_text(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::new(("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 {
|
||
opts.push(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(format!("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(l.into());
|
||
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 { format!("Property: {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 `layer`, whose menu lists "No Track
|
||
/// Matte", a separator, the matte `candidates`, a separator and the modes. A mode without a matte
|
||
/// layer takes the layer above, like Layer ▸ Track Matte (#864: it used to do nothing).
|
||
fn track_matte_pick(layer: &Layer, candidates: &[LayerId], i: usize, choice: &str) -> Option<(String, serde_json::Value)> {
|
||
if i == 0 {
|
||
return Some(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": null})));
|
||
}
|
||
if let Some(m) = i.checked_sub(2).and_then(|j| candidates.get(j)) {
|
||
let kind = layer.track_matte.map(|m| m.kind).unwrap_or_default();
|
||
return Some(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": m.0, "kind": kind_name(kind)})));
|
||
}
|
||
let k = MatteKind::ALL.into_iter().find(|k| k.label() == choice)?;
|
||
Some(("layer.trackMatte".into(), json!({"layers": [layer.id.0], "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(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 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,
|
||
}
|
||
|
||
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) }
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
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] };
|
||
crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, actions));
|
||
}
|
||
|
||
/// Shared layer actions for Timeline rows and Composition context menus.
|
||
pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, actions: &mut Vec<(String, serde_json::Value)>) {
|
||
let Some(&id) = layers.first() else { return };
|
||
let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| {
|
||
if ui.button(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…", "app.solidSettings", 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.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}));
|
||
});
|
||
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("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}));
|
||
}
|
||
|
||
/// 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(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$}")
|
||
}
|
||
|
||
/// 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,
|
||
p: &egui::Painter,
|
||
layer: &Layer,
|
||
prop: &Property,
|
||
value: &Value,
|
||
at: Pos2,
|
||
actions: &mut Vec<(String, serde_json::Value)>,
|
||
) -> Vec<Rect> {
|
||
let t = app.tokens;
|
||
let uid = prop.uid;
|
||
let mut dims = vec![];
|
||
let merge = format!("scrub-{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));
|
||
let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| {
|
||
actions.push((
|
||
"prop.set".into(),
|
||
json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub, "proportional": proportional}),
|
||
))
|
||
};
|
||
let is_3d = layer.is_3d();
|
||
let mut x = at.x;
|
||
let y = at.y - 9.0;
|
||
match value {
|
||
Value::Scalar(v) => {
|
||
let (min, max, dec, speed) = match &prop.ui {
|
||
ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, ((max - min) / 400.0).clamp(0.01, 10.0)),
|
||
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::new(("v", uid, 0)), *v, 1, &t);
|
||
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 {
|
||
set(actions, json!(nv));
|
||
}
|
||
} else {
|
||
let suffix = if matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity" { "%" } else { "" };
|
||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, speed, (min, max), dec.max(1), suffix, &t);
|
||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||
if let Some(nv) = nv {
|
||
set(actions, json!(nv));
|
||
}
|
||
}
|
||
}
|
||
Value::Vec2(_) | Value::Vec3(_) => {
|
||
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) };
|
||
// Scale and Mask Feather: the chain link (Constrain Proportions, on by default).
|
||
let linkable = feather || (pct && prop.match_id == "scale");
|
||
let linked = linkable && !app.ui.timeline.unlinked.contains(&uid);
|
||
if linkable {
|
||
let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0));
|
||
let resp = ui.interact(lr, egui::Id::new(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions"));
|
||
icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
|
||
app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions");
|
||
if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) {
|
||
app.ui.timeline.unlinked.insert(uid);
|
||
}
|
||
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::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t);
|
||
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 };
|
||
for (e, v) in widgets::constrained(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;
|
||
}
|
||
}
|
||
set(actions, json!(nc));
|
||
}
|
||
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), t.hot_text);
|
||
x += 6.0;
|
||
}
|
||
}
|
||
}
|
||
Value::Color(c) => {
|
||
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::new(("sw", uid)), &t);
|
||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||
let pop = egui::Id::new(("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::new(("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) {
|
||
set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));
|
||
}
|
||
}
|
||
Value::Bool(b) => {
|
||
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::new(("cb", uid))).clicked() {
|
||
set(actions, json!(!b));
|
||
}
|
||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||
}
|
||
Value::Enum(i) => {
|
||
// Text Path Options ▸ Path lists the layer's masks.
|
||
let mask_opts: Option<Vec<String>> = layer
|
||
.props
|
||
.prop("text/pathOptions/path")
|
||
.filter(|q| q.uid == uid)
|
||
.map(|_| std::iter::once("None".to_string()).chain(layer.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone()))).collect());
|
||
let ui_opts = mask_opts.map(|options| ParamUi::Popup { options });
|
||
if let ParamUi::Popup { options } = ui_opts.as_ref().unwrap_or(&prop.ui) {
|
||
let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
|
||
let label = options.get(*i as usize).cloned().unwrap_or_default();
|
||
let pop = egui::Id::new(("epop", uid));
|
||
if widgets::dropdown(ui, r, &label, &t, egui::Id::new(("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, Some(*i as usize)) {
|
||
set(actions, json!(ni));
|
||
}
|
||
} else {
|
||
p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.hot_text);
|
||
}
|
||
}
|
||
Value::Path(_) => {
|
||
p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Shape…"), Tokens::ui(12.0), t.hot_text);
|
||
}
|
||
Value::Text(_) => {}
|
||
Value::Layer(l) => {
|
||
p.text(
|
||
pos2(x, at.y),
|
||
Align2::LEFT_CENTER,
|
||
l.map(|l| crate::i18n::tr_args("Layer {}", &[&l])).unwrap_or(crate::i18n::tr("None").into()),
|
||
Tokens::ui(12.0),
|
||
t.hot_text,
|
||
);
|
||
}
|
||
Value::Gradient(_) => {
|
||
// 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::new(("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})));
|
||
}
|
||
}
|
||
Value::Str(s) => {
|
||
p.text(pos2(x, at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::<String>(), Tokens::ui(12.0), t.text_dim);
|
||
}
|
||
}
|
||
dims
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Alt/Cmd-click twirls Audio's Waveform row (not in the property tree) with the rest.
|
||
#[test]
|
||
fn the_waveform_row_twirls_with_its_tree() {
|
||
let mut root = PropGroup::new(1, "", "");
|
||
let mut audio = PropGroup::new(2, "audio", "Audio");
|
||
audio.children.push(PropGroup::new(3, "x", "X").into());
|
||
root.children.push(audio.into());
|
||
let mut uids = vec![];
|
||
walk_groups(&root, &mut uids);
|
||
assert_eq!(uids, vec![2, WAVE_BIT | 2, 3]);
|
||
let mut uids = vec![];
|
||
walk_groups(find_group(&root, 2).unwrap(), &mut uids);
|
||
assert_eq!(uids, vec![WAVE_BIT | 2, 3]);
|
||
}
|
||
|
||
/// #710: with Time Display set to Frames the ruler labels are frame numbers (as under the
|
||
/// CTI, from Start Numbering Frames At); otherwise seconds:frames.
|
||
#[test]
|
||
fn ruler_labels_follow_the_time_display_style() {
|
||
use effectcraft_engine::project::{ProjectSettings, TimeDisplayStyle};
|
||
let mut st = ProjectSettings { time_display: TimeDisplayStyle::Timecode, frame_start: 0, ..ProjectSettings::default() };
|
||
assert_eq!(ruler_label(15, 30, &st), "00:15f");
|
||
assert_eq!(ruler_label(45, 30, &st), "01:15f");
|
||
assert_eq!(ruler_label(30 * 3600 + 31, 30, &st), "60:01:01f");
|
||
st.time_display = TimeDisplayStyle::Frames;
|
||
assert_eq!(ruler_label(0, 30, &st), "00000");
|
||
assert_eq!(ruler_label(45, 30, &st), "00045");
|
||
st.frame_start = 1;
|
||
assert_eq!(ruler_label(45, 30, &st), "00046");
|
||
assert_eq!(ruler_label(i64::MAX, 30, &st), format!("{:05}", i64::MAX));
|
||
}
|
||
|
||
/// #468: playback pages the view to the CTI; a scrub past either end scrolls that way.
|
||
#[test]
|
||
fn the_view_pages_to_the_cti() {
|
||
assert_eq!(page_to_cti(10.0, 5.0, 60.0, 12.0), 10.0, "in view: unchanged");
|
||
assert_eq!(page_to_cti(10.0, 5.0, 60.0, 16.0), 16.0, "past the right end: the page from the CTI");
|
||
assert_eq!(page_to_cti(10.0, 5.0, 60.0, 3.0), 3.0, "before the start");
|
||
assert_eq!(page_to_cti(10.0, 5.0, 60.0, 58.0), 55.0, "not past the comp's end");
|
||
assert_eq!(page_to_cti(10.0, 80.0, 60.0, 95.0), 0.0);
|
||
assert_eq!(page_to_cti(10.0, f64::NAN, 60.0, 95.0), 10.0);
|
||
assert_eq!(edge_scroll_speed_x(100.0, 500.0, 300.0), 0.0);
|
||
assert!(edge_scroll_speed_x(100.0, 500.0, 501.0) > 0.0 && edge_scroll_speed_x(100.0, 500.0, 99.0) < 0.0);
|
||
assert!(edge_scroll_speed_x(100.0, 500.0, 600.0) > edge_scroll_speed_x(100.0, 500.0, 520.0));
|
||
}
|
||
|
||
/// #864: picking only a mode in a layer's Track Matte menu sets a matte (the layer above);
|
||
/// it used to close the menu and change nothing.
|
||
#[test]
|
||
fn a_track_matte_mode_without_a_matte_takes_the_layer_above() {
|
||
let mut s = effectcraft_engine::Session::default();
|
||
s.execute("comp.new", json!({"name": "T", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||
for name in ["Chain Bottom", "Matte", "Chain Top"] {
|
||
s.execute("layer.newSolid", json!({"name": name, "color": "#ffffff", "width": 64, "height": 64})).unwrap();
|
||
}
|
||
let find = |s: &effectcraft_engine::Session, n: &str| s.active_comp().unwrap().layers.iter().find(|l| l.name == n).unwrap().clone();
|
||
let (bottom, matte, top) = (find(&s, "Chain Bottom"), find(&s, "Matte"), find(&s, "Chain Top"));
|
||
let candidates = [top.id, matte.id];
|
||
let pick = |s: &mut effectcraft_engine::Session, name: &str, i: usize, choice: &str| {
|
||
let (id, params) = track_matte_pick(&find(s, name), &candidates, i, choice).expect("a menu item is an action");
|
||
s.execute(&id, params)
|
||
};
|
||
pick(&mut s, "Chain Bottom", 5, "Alpha Inverted Matte").unwrap();
|
||
let m = find(&s, "Chain Bottom").track_matte.expect("a matte");
|
||
assert_eq!((m.layer, m.kind), (matte.id, MatteKind::AlphaInverted));
|
||
assert!(!find(&s, "Matte").switches.video, "the matte layer is hidden");
|
||
// A mode change keeps the matte layer; a matte change keeps the mode.
|
||
pick(&mut s, "Chain Bottom", 6, "Luma Matte").unwrap();
|
||
assert_eq!(find(&s, "Chain Bottom").track_matte.map(|m| (m.layer, m.kind)), Some((matte.id, MatteKind::Luma)));
|
||
pick(&mut s, "Chain Bottom", 2, "1. Chain Top").unwrap();
|
||
assert_eq!(find(&s, "Chain Bottom").track_matte.map(|m| (m.layer, m.kind)), Some((top.id, MatteKind::Luma)));
|
||
assert!(s.undo());
|
||
assert_eq!(find(&s, "Chain Bottom").track_matte.map(|m| m.layer), Some(matte.id), "one undo step per pick");
|
||
// The top layer has nothing above it: an error for the status bar, not a silent no-op.
|
||
assert!(pick(&mut s, "Chain Top", 5, "Alpha Matte").is_err());
|
||
pick(&mut s, "Chain Bottom", 0, "No Track Matte").unwrap();
|
||
assert!(find(&s, "Chain Bottom").track_matte.is_none());
|
||
assert!(track_matte_pick(&bottom, &candidates, 1, "-").is_none(), "a separator does nothing");
|
||
}
|
||
|
||
/// #957: vector footage gets the Continuously Rasterize switch; raster footage does not.
|
||
#[test]
|
||
fn collapse_switch_shows_for_vector_footage_only() {
|
||
use effectcraft_engine::color::Label;
|
||
use effectcraft_engine::project::{Footage, ItemKind, Project};
|
||
let mut p = Project::default();
|
||
let mut layer_for = |name: &str, codec: &str| {
|
||
let footage = Footage { path: name.into(), codec: codec.into(), ..Default::default() };
|
||
let item = p.add_item(name, Label::SeaFoam, None, ItemKind::Footage(footage));
|
||
let comp = Comp::new(64, 36, effectcraft_engine::time::FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
|
||
effectcraft_engine::project::build::layer(&mut p, &comp, name, LayerSource::Footage { item }, (0, 0), None)
|
||
};
|
||
let svg = layer_for("logo.svg", "SVG");
|
||
let png = layer_for("photo.png", "PNG");
|
||
assert!(collapse_applies(&svg, &p));
|
||
assert!(!collapse_applies(&png, &p));
|
||
}
|
||
|
||
/// A comp with two solids; "Box" has an animated Position and a Gaussian Blur.
|
||
fn app() -> EffectcraftApp {
|
||
let mut s = effectcraft_engine::Session::default();
|
||
s.execute("comp.new", json!({"name": "T", "width": 320, "height": 180, "duration": 4})).unwrap();
|
||
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 320, "height": 180})).unwrap();
|
||
s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 40, "height": 40})).unwrap();
|
||
let comp = s.active_comp().unwrap();
|
||
let bx = comp.layers.iter().find(|l| l.name == "Box").unwrap().id.0;
|
||
s.execute("layer.select", json!({"layers": [bx]})).unwrap();
|
||
s.execute("effect.apply", json!({"effect": "ec.blur.gaussian"})).unwrap();
|
||
let pos = s.active_comp().unwrap().layer(LayerId(bx)).unwrap().transform().unwrap().get("position").unwrap().uid;
|
||
s.execute("prop.toggleAnimation", json!({"layer": bx, "prop": pos})).unwrap();
|
||
EffectcraftApp::new(s)
|
||
}
|
||
|
||
fn labels(app: &EffectcraftApp) -> Vec<String> {
|
||
let comp = app.session.active_comp().unwrap().clone();
|
||
build_rows(app, &comp)
|
||
.into_iter()
|
||
.map(|r| {
|
||
let l = comp.layer(r.layer).unwrap();
|
||
match r.kind {
|
||
RowKind::Layer => l.name.clone(),
|
||
RowKind::Group { name, .. } => format!("{}>{name}", " ".repeat(r.depth)),
|
||
RowKind::Prop { uid } => format!("{}{}", " ".repeat(r.depth), l.props.find(uid).unwrap().name),
|
||
_ => "?".into(),
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn search_filters_layers_and_reveals_matching_properties() {
|
||
let mut app = app();
|
||
app.ui.timeline.search = "opac".into();
|
||
let rows = labels(&app);
|
||
// Both layers have Transform > Opacity; nothing else shows.
|
||
assert_eq!(rows.iter().filter(|r| r.trim() == "Opacity").count(), 2, "{rows:?}");
|
||
assert!(rows.iter().any(|r| r.trim() == ">Transform"), "{rows:?}");
|
||
assert!(!rows.iter().any(|r| r.trim() == "Position"), "{rows:?}");
|
||
// A layer name match shows the layer alone; a group match brings its contents.
|
||
app.ui.timeline.search = "plate".into();
|
||
assert_eq!(labels(&app), vec!["Plate".to_string()]);
|
||
app.ui.timeline.search = "GAUSSIAN".into();
|
||
let rows = labels(&app);
|
||
assert_eq!(rows[0], "Box");
|
||
assert!(rows.iter().any(|r| r.trim() == ">Effects") && rows.iter().any(|r| r.trim() == "Blurriness"), "{rows:?}");
|
||
assert!(!rows.contains(&"Plate".to_string()));
|
||
// Nothing matches → no rows.
|
||
app.ui.timeline.search = "zzz".into();
|
||
assert!(labels(&app).is_empty());
|
||
// Through the command.
|
||
let ctx = egui::Context::default();
|
||
crate::menus::invoke(&mut app, &ctx, "timeline.search", json!({"query": "scale"})).unwrap();
|
||
assert!(labels(&app).iter().any(|r| r.trim() == "Scale"));
|
||
}
|
||
|
||
#[test]
|
||
fn columns_toggle_and_layout() {
|
||
let mut app = app();
|
||
let tl = &mut app.ui.timeline;
|
||
assert!(column_visible(tl, "switches") && !column_visible(tl, "comment") && !column_visible(tl, "modes"));
|
||
set_column(tl, "comment", true).unwrap();
|
||
set_column(tl, "stretch", true).unwrap();
|
||
set_column(tl, "keys", false).unwrap();
|
||
set_column(tl, "label", false).unwrap();
|
||
set_column(tl, "modes", true).unwrap();
|
||
assert!(tl.show_modes && column_visible(tl, "comment") && !column_visible(tl, "label"));
|
||
assert!(set_column(tl, "name", false).is_err());
|
||
assert!(set_column(tl, "bogus", true).is_err());
|
||
let vis = Vis::of(tl);
|
||
let cw = cols(0.0, 1400.0, vis);
|
||
// Hidden columns take no room; visible ones are in After Effects' order.
|
||
assert_eq!(cw.keys, cw.label);
|
||
assert_eq!(cw.label, cw.num);
|
||
assert!(cw.name < cw.comment && cw.comment < cw.switches && cw.switches < cw.mode && cw.parent < cw.stretch);
|
||
assert_eq!(column_at(&cw, cw.comment + 5.0), Some("comment"));
|
||
assert_eq!(column_at(&cw, cw.name + 5.0), Some("name"));
|
||
assert_eq!(column_at(&cw, cw.stretch + 5.0), Some("stretch"));
|
||
// Through the command (as agents do).
|
||
let ctx = egui::Context::default();
|
||
let r = crate::menus::invoke(&mut app, &ctx, "timeline.column", json!({"column": "duration"})).unwrap();
|
||
assert!(r["columns"].as_array().unwrap().iter().any(|c| c == "duration"));
|
||
crate::menus::invoke(&mut app, &ctx, "timeline.column", json!({"column": "duration", "visible": false})).unwrap();
|
||
assert!(!column_visible(&app.ui.timeline, "duration"));
|
||
crate::menus::invoke(&mut app, &ctx, "timeline.sourceName", json!({})).unwrap();
|
||
assert!(app.ui.timeline.source_name);
|
||
}
|
||
|
||
#[test]
|
||
fn reveal_keys_add_with_shift_and_uu_reveals_modified() {
|
||
let mut app = app();
|
||
let ctx = egui::Context::default();
|
||
crate::menus::invoke(&mut app, &ctx, "timeline.reveal.opacity", json!({})).unwrap();
|
||
assert_eq!(app.ui.timeline.reveal, vec!["opacity"]);
|
||
crate::menus::invoke(&mut app, &ctx, "timeline.revealAdd.scale", json!({})).unwrap();
|
||
crate::menus::invoke(&mut app, &ctx, "timeline.revealAdd.effects", json!({})).unwrap();
|
||
assert_eq!(app.ui.timeline.reveal, vec!["opacity", "scale", "effects"]);
|
||
let rows = labels(&app);
|
||
assert!(rows.iter().any(|r| r.trim() == "Scale") && rows.iter().any(|r| r.trim() == "Opacity"), "{rows:?}");
|
||
assert!(rows.iter().any(|r| r.trim() == ">Gaussian Blur"), "{rows:?}");
|
||
// Tree order: Scale before Opacity.
|
||
let si = rows.iter().position(|r| r.trim() == "Scale").unwrap();
|
||
let oi = rows.iter().position(|r| r.trim() == "Opacity").unwrap();
|
||
assert!(si < oi);
|
||
// Shift+S again removes Scale.
|
||
crate::menus::invoke(&mut app, &ctx, "timeline.revealAdd.scale", json!({})).unwrap();
|
||
assert_eq!(app.ui.timeline.reveal, vec!["opacity", "effects"]);
|
||
// U: keyframed properties (Position); UU (twice quickly): every modified property.
|
||
crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap();
|
||
let n_keys = app.ui.timeline.reveal_props.len();
|
||
assert_eq!(app.ui.timeline.reveal, vec!["props"]);
|
||
assert!(n_keys >= 1);
|
||
let bx = app.session.state.selected_layers[0].0;
|
||
app.session.execute("prop.set", json!({"layer": bx, "path": "effects/#1/blurriness", "value": 12})).unwrap();
|
||
crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap();
|
||
assert!(app.ui.timeline.reveal_props.len() > n_keys, "UU reveals modified properties too");
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_to_time_centres_the_current_time_inside_the_comp() {
|
||
assert_eq!(centered_on(4.0, 10.0, 5.0), 3.0);
|
||
// Near the ends the view stops at the comp's start and end.
|
||
assert_eq!(centered_on(4.0, 10.0, 1.0), 0.0);
|
||
assert_eq!(centered_on(4.0, 10.0, 9.5), 6.0);
|
||
// A span longer than the comp, and bad numbers, start at zero.
|
||
assert_eq!(centered_on(20.0, 10.0, 5.0), 0.0);
|
||
assert_eq!(centered_on(f64::NAN, 10.0, 5.0), 0.0);
|
||
}
|
||
|
||
/// U and the other reveal shortcuts act on the selected layers only: other layers keep their
|
||
/// twirl state and their own reveal (#160).
|
||
#[test]
|
||
fn reveal_shortcuts_leave_unselected_layers_alone() {
|
||
let mut app = app();
|
||
let ctx = egui::Context::default();
|
||
let comp = app.session.active_comp().unwrap().clone();
|
||
let id = |n: &str| comp.layers.iter().find(|l| l.name == n).unwrap().id.0;
|
||
let (plate, bx) = (id("Plate"), id("Box"));
|
||
let select = |app: &mut EffectcraftApp, l: u64| app.session.execute("layer.select", json!({"layers": [l]})).unwrap();
|
||
// Plate shows Scale (S); then U on Box.
|
||
select(&mut app, plate);
|
||
crate::menus::invoke(&mut app, &ctx, "timeline.reveal.scale", json!({})).unwrap();
|
||
select(&mut app, bx);
|
||
crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap();
|
||
let tl = &app.ui.timeline;
|
||
assert!(tl.open_layers.contains(&plate) && tl.open_layers.contains(&bx), "{:?}", tl.open_layers);
|
||
assert_eq!(tl.layer_reveal.get(&plate), Some(&vec!["scale".to_string()]), "Plate keeps its reveal");
|
||
assert_eq!(tl.layer_reveal.get(&bx), Some(&vec!["props".to_string()]));
|
||
let rows = labels(&app);
|
||
let at = |n: &str| rows.iter().position(|r| r == n).unwrap();
|
||
assert_eq!(rows[at("Plate") + 1].trim(), "Scale", "{rows:?}");
|
||
assert_eq!(rows[at("Box") + 1].trim(), "Position", "{rows:?}");
|
||
// UU (modified): the effect's changed parameter only, under the effect twirled open.
|
||
// Then U later: Box shows its keyframed properties only again.
|
||
app.session.execute("prop.set", json!({"layer": bx, "path": "effects/#1/blurriness", "value": 12})).unwrap();
|
||
crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap();
|
||
let rows = labels(&app);
|
||
let fx = rows.iter().position(|r| r.trim().ends_with("Gaussian Blur")).unwrap_or_else(|| panic!("UU shows the effect: {rows:?}"));
|
||
assert_eq!(rows[fx + 1].trim(), "Blurriness", "{rows:?}");
|
||
assert!(!rows.iter().any(|r| r.trim() == "Blur Dimensions"), "unchanged parameters stay hidden: {rows:?}");
|
||
// Collapsed and expanded by hand, the effect shows all its parameters; UU filters again.
|
||
let fx_uid = app.session.active_comp().unwrap().layer(LayerId(bx)).unwrap().effects().unwrap().groups().next().unwrap().uid;
|
||
app.ui.timeline.toggle_group(bx, fx_uid);
|
||
assert!(!labels(&app).iter().any(|r| r.trim() == "Blurriness"), "collapsed");
|
||
app.ui.timeline.toggle_group(bx, fx_uid);
|
||
let rows = labels(&app);
|
||
assert!(rows.iter().any(|r| r.trim() == "Blurriness") && rows.iter().any(|r| r.trim() == "Blur Dimensions"), "{rows:?}");
|
||
app.last_reveal = Some(("keyframes".into(), f64::MAX));
|
||
crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap();
|
||
assert!(!labels(&app).iter().any(|r| r.trim() == "Blur Dimensions"), "UU again shows the changed ones only");
|
||
app.last_reveal = Some(("keyframes".into(), -10.0));
|
||
crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap();
|
||
app.last_reveal = Some(("keyframes".into(), -10.0));
|
||
crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap();
|
||
let rows = labels(&app);
|
||
assert!(!rows.iter().any(|r| r.trim() == ">Gaussian Blur") && rows.iter().any(|r| r.trim() == "Position"), "{rows:?}");
|
||
// U again on Box (later than a double press) hides only Box's.
|
||
app.last_reveal = Some(("keyframes".into(), -10.0));
|
||
crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap();
|
||
let tl = &app.ui.timeline;
|
||
assert!(!tl.open_layers.contains(&bx) && tl.open_layers.contains(&plate), "{:?}", tl.open_layers);
|
||
// A layer twirled open by hand shows its whole tree, whatever was revealed elsewhere.
|
||
app.session.execute("layer.select", json!({"layers": []})).unwrap();
|
||
app.ui.timeline.open_layers.insert(bx);
|
||
let rows = labels(&app);
|
||
assert!(rows.iter().any(|r| r.trim() == ">Transform"), "{rows:?}");
|
||
}
|
||
|
||
/// U and UU show Time Remap on a layer with time remapping enabled.
|
||
#[test]
|
||
fn u_and_uu_reveal_time_remap() {
|
||
let mut s = effectcraft_engine::Session::default();
|
||
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 32, "height": 32, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
|
||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "duration": 4})).unwrap();
|
||
let nested = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap();
|
||
let mut app = EffectcraftApp::new(s);
|
||
let ctx = egui::Context::default();
|
||
crate::menus::invoke(&mut app, &ctx, "layer.enableTimeRemap", json!({"layers": [nested]})).unwrap();
|
||
app.session.execute("layer.select", json!({"layers": [nested]})).unwrap();
|
||
for uu in [false, true] {
|
||
app.ui.timeline.layer_reveal.clear();
|
||
app.last_reveal = uu.then(|| ("keyframes".into(), f64::MAX));
|
||
crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap();
|
||
assert_eq!(app.ui.timeline.layer_reveal.get(&nested), Some(&vec!["props".to_string()]), "UU: {uu}");
|
||
let rows = labels(&app);
|
||
let at = rows.iter().position(|r| r == "Inner").unwrap();
|
||
assert_eq!(rows[at + 1].trim(), "Time Remap", "UU: {uu}: {rows:?}");
|
||
}
|
||
}
|
||
|
||
/// U shows Time Remap while remapping is enabled even after its keys are deleted (#990):
|
||
/// enabling remapping is itself the modification.
|
||
#[test]
|
||
fn u_reveals_keyless_time_remap() {
|
||
let mut s = effectcraft_engine::Session::default();
|
||
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 32, "height": 32, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
|
||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "duration": 4})).unwrap();
|
||
let nested = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap();
|
||
let mut app = EffectcraftApp::new(s);
|
||
let ctx = egui::Context::default();
|
||
crate::menus::invoke(&mut app, &ctx, "layer.enableTimeRemap", json!({"layers": [nested]})).unwrap();
|
||
let uid = app.session.active_comp().unwrap().layer(LayerId(nested)).unwrap().props.prop("timeRemap").unwrap().uid;
|
||
for _ in 0..2 {
|
||
let t = app.session.active_comp().unwrap().layer(LayerId(nested)).unwrap().props.prop("timeRemap").unwrap().keys[0].time.seconds();
|
||
app.session.execute("keys.select", json!({"keys": [{"layer": nested, "prop": uid, "time": t}]})).unwrap();
|
||
app.session.execute("edit.clear", json!({})).unwrap();
|
||
}
|
||
assert!(app.session.active_comp().unwrap().layer(LayerId(nested)).unwrap().props.prop("timeRemap").unwrap().keys.is_empty());
|
||
app.session.execute("layer.select", json!({"layers": [nested]})).unwrap();
|
||
crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap();
|
||
assert_eq!(app.ui.timeline.layer_reveal.get(&nested), Some(&vec!["props".to_string()]));
|
||
let rows = labels(&app);
|
||
let at = rows.iter().position(|r| r == "Inner").unwrap();
|
||
assert_eq!(rows[at + 1].trim(), "Time Remap", "{rows:?}");
|
||
}
|
||
|
||
/// Enabling time remapping twirls the layer open on Time Remap (#157); disabling it reveals
|
||
/// nothing.
|
||
#[test]
|
||
fn enabling_time_remapping_reveals_time_remap() {
|
||
let mut s = effectcraft_engine::Session::default();
|
||
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 32, "height": 32, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
|
||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "duration": 4})).unwrap();
|
||
s.execute("layer.newSolid", json!({"name": "Other"})).unwrap();
|
||
let nested = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap();
|
||
let mut app = EffectcraftApp::new(s);
|
||
let ctx = egui::Context::default();
|
||
crate::menus::invoke(&mut app, &ctx, "layer.enableTimeRemap", json!({"layers": [nested]})).unwrap();
|
||
assert_eq!(app.ui.timeline.layer_reveal.get(&nested), Some(&vec!["timeRemap".to_string()]));
|
||
let rows = labels(&app);
|
||
let at = rows.iter().position(|r| r == "Inner").unwrap();
|
||
assert_eq!(rows[at + 1].trim(), "Time Remap", "{rows:?}");
|
||
// Through the selection (the Layer menu), turning it off again: nothing new is revealed.
|
||
app.session.execute("layer.select", json!({"layers": [nested]})).unwrap();
|
||
app.ui.timeline.layer_reveal.clear();
|
||
crate::menus::invoke(&mut app, &ctx, "layer.enableTimeRemap", json!({})).unwrap();
|
||
assert!(app.ui.timeline.layer_reveal.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn cti_snaps_to_the_nearest_candidate() {
|
||
assert_eq!(snap_time(&[0.0, 1.0, 2.0], 1.04, 0.1), 1.0);
|
||
assert_eq!(snap_time(&[0.0, 1.0, 2.0], 1.5, 0.1), 1.5);
|
||
assert_eq!(snap_time(&[1.0, 1.1], 1.06, 0.1), 1.1);
|
||
let app = app();
|
||
let comp = app.session.active_comp().unwrap().clone();
|
||
let c = snap_candidates(&comp, &build_rows(&app, &comp));
|
||
assert!(c.contains(&comp.duration.seconds()) && c.contains(&0.0));
|
||
}
|
||
|
||
/// UI fidelity (M13.5): compact After Effects-like rows and muted label-coloured bars.
|
||
#[test]
|
||
fn rows_and_bars_follow_after_effects_proportions() {
|
||
let t = crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark);
|
||
assert_eq!(t.row_h, 19.0);
|
||
let red = t.label(effectcraft_engine::color::Label::Red);
|
||
let bar = bar_color(red, false);
|
||
// Muted: darker and less saturated than the label, still reddish.
|
||
assert!(bar.r() < red.r() && bar.r() > bar.g() + 30 && bar.g() >= red.g().min(0x3a) - 1);
|
||
let sel = bar_color(red, true);
|
||
assert!(sel.r() > bar.r());
|
||
}
|
||
}
|