Merge pull request #1054 from pravdomil/claude/shortcuts-keyboard-commands
# Conflicts: # crates/engine/src/commands/time.rs # crates/ui-egui/src/menus.rs
This commit is contained in:
commit
dc3131b339
|
|
@ -945,6 +945,14 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let d = t - l.out_point;
|
||||
shift(l, d);
|
||||
}
|
||||
"moveInToStart" => {
|
||||
let d = Tick::ZERO - l.in_point;
|
||||
shift(l, d);
|
||||
}
|
||||
"moveOutToEnd" => {
|
||||
let d = comp_dur - l.out_point;
|
||||
shift(l, d);
|
||||
}
|
||||
"trimInToTime" => l.in_point = t.min(l.out_point - fd),
|
||||
"trimOutToTime" => l.out_point = t.max(l.in_point + fd),
|
||||
_ => {
|
||||
|
|
@ -977,6 +985,20 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// `p` with `key` set.
|
||||
fn with(p: &Value, key: &str, v: Value) -> Value {
|
||||
let mut p = if p.is_object() { p.clone() } else { json!({}) };
|
||||
p[key] = v;
|
||||
p
|
||||
}
|
||||
|
||||
/// Move the layers `frames` frames in time (Alt+Page Up / Page Down, with Shift ten frames).
|
||||
fn move_frames(s: &mut Session, p: &Value, frames: i64) -> Result<Value> {
|
||||
let (cid, _) = layers_p(s, p)?;
|
||||
let delta = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate.tick_of(frames).seconds();
|
||||
timing(s, &with(p, "delta", json!(delta)))
|
||||
}
|
||||
|
||||
/// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like
|
||||
/// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive
|
||||
/// moves the source later. Clamped so the source still covers the in–out span. Keyframes and
|
||||
|
|
@ -1606,6 +1628,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
|
||||
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
|
||||
cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev),
|
||||
cmd!("layer.extendSelectionNext", "Extend Selection to Next Layer", [], Some("Cmd+Shift+ArrowDown"), "{}", has_comp, |s, _| select_step(
|
||||
s,
|
||||
&json!({"add": true}),
|
||||
1
|
||||
)),
|
||||
cmd!("layer.extendSelectionPrevious", "Extend Selection to Previous Layer", [], Some("Cmd+Shift+ArrowUp"), "{}", has_comp, |s, _| select_step(
|
||||
s,
|
||||
&json!({"add": true}),
|
||||
-1
|
||||
)),
|
||||
cmd!(
|
||||
"layer.setSwitch",
|
||||
"Layer Switch",
|
||||
|
|
@ -1633,17 +1665,29 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Layer Timing",
|
||||
[],
|
||||
None,
|
||||
"{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}",
|
||||
"{layers?, op?: moveInToTime|moveOutToTime|moveInToStart|moveOutToEnd|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}",
|
||||
has_layers,
|
||||
timing
|
||||
),
|
||||
cmd!("layer.moveInToStart", "Move Layer In Point to Beginning of Comp", [], Some("Alt+Home"), "{layers?}", has_layers, |s, p| timing(
|
||||
s,
|
||||
&with(p, "op", json!("moveInToStart"))
|
||||
)),
|
||||
cmd!("layer.moveOutToEnd", "Move Layer Out Point to End of Comp", [], Some("Alt+End"), "{layers?}", has_layers, |s, p| timing(
|
||||
s,
|
||||
&with(p, "op", json!("moveOutToEnd"))
|
||||
)),
|
||||
cmd!("layer.moveEarlier", "Move Layers 1 Frame Earlier", [], Some("Alt+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -1)),
|
||||
cmd!("layer.moveLater", "Move Layers 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 1)),
|
||||
cmd!("layer.moveEarlier10", "Move Layers 10 Frames Earlier", [], Some("Alt+Shift+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -10)),
|
||||
cmd!("layer.moveLater10", "Move Layers 10 Frames Later", [], Some("Alt+Shift+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 10)),
|
||||
cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip),
|
||||
cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], Some("Alt+PageUp"), "{layers?}", has_layers, |s, p| {
|
||||
cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], None, "{layers?}", has_layers, |s, p| {
|
||||
let mut p = p.clone();
|
||||
p["frames"] = json!(-1);
|
||||
slip(s, &p)
|
||||
}),
|
||||
cmd!("layer.slipForward", "Slip Edit 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| {
|
||||
cmd!("layer.slipForward", "Slip Edit 1 Frame Later", [], None, "{layers?}", has_layers, |s, p| {
|
||||
let mut p = p.clone();
|
||||
p["frames"] = json!(1);
|
||||
slip(s, &p)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let pct = f_p(p, "percent");
|
||||
let dur = f_p(p, "duration");
|
||||
let hold = str_p(p, "hold").unwrap_or("in").to_string();
|
||||
// `to`: stretch so the In (or Out) point lands on the current time, the other point held.
|
||||
let to = str_p(p, "to").map(str::to_string);
|
||||
if to.as_deref().is_some_and(|t| t != "in" && t != "out") {
|
||||
return Err(bad("layer.timeStretch", "to: in|out"));
|
||||
}
|
||||
let cti = s.time();
|
||||
let comp_rate = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate;
|
||||
let fd = comp_rate.frame_duration();
|
||||
|
|
@ -39,6 +44,16 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
continue;
|
||||
}
|
||||
let span = (l.out_point - l.in_point).seconds();
|
||||
let (dur, hold) = match to.as_deref() {
|
||||
Some("in") => (Some((l.out_point - cti).seconds()), "out"),
|
||||
Some(_) => (Some((cti - l.in_point).seconds()), "in"),
|
||||
None => (dur, hold.as_str()),
|
||||
};
|
||||
// The current time is on the wrong side of the held point: nothing to stretch to.
|
||||
if to.is_some() && dur.is_some_and(|d| d < fd.seconds()) {
|
||||
continue;
|
||||
}
|
||||
let pct = if to.is_some() { None } else { pct };
|
||||
let new = match (pct, dur) {
|
||||
(Some(v), _) => v,
|
||||
(None, Some(d)) if span > 0.0 => old * d / span,
|
||||
|
|
@ -48,7 +63,7 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return Err(bad("layer.timeStretch", "the stretch factor must be at least 1 %"));
|
||||
}
|
||||
// The hold point keeps showing the same layer time.
|
||||
let h = match hold.as_str() {
|
||||
let h = match hold {
|
||||
"current" => cti,
|
||||
"out" => l.out_point,
|
||||
_ => l.in_point,
|
||||
|
|
@ -220,6 +235,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_layers,
|
||||
time_stretch
|
||||
),
|
||||
cmd!("layer.stretchInToTime", "Stretch Layer In Point to Current Time", [], Some("Cmd+Shift+,"), "{layers?}", has_layers, |s, p| {
|
||||
let mut p = if p.is_object() { p.clone() } else { json!({}) };
|
||||
p["to"] = json!("in");
|
||||
time_stretch(s, &p)
|
||||
}),
|
||||
cmd!("layer.stretchOutToTime", "Stretch Layer Out Point to Current Time", [], Some("Cmd+Alt+,"), "{layers?}", has_layers, |s, p| {
|
||||
let mut p = if p.is_object() { p.clone() } else { json!({}) };
|
||||
p["to"] = json!("out");
|
||||
time_stretch(s, &p)
|
||||
}),
|
||||
cmd!("layer.timeReverse", "Time-Reverse Layer", ["Layer", "Time"], Some("Cmd+Alt+R"), "{layers?}", has_layers, |s, p| {
|
||||
let mut p = p.clone();
|
||||
p["op"] = json!("reverse");
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ pub mod markers;
|
|||
mod mask;
|
||||
pub mod mask_interp;
|
||||
pub(crate) mod model3d;
|
||||
mod nav_more;
|
||||
pub mod paint;
|
||||
pub mod panels_cmds;
|
||||
pub(crate) mod path_nulls;
|
||||
|
|
@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(paths::specs());
|
||||
v.extend(effect::specs());
|
||||
v.extend(time::specs());
|
||||
v.extend(nav_more::specs());
|
||||
v.extend(render_queue::specs());
|
||||
v.extend(help::specs());
|
||||
v.extend(query::specs());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
//! Keyboard-driven commands that have no menu entry.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{ItemId, LayerSource, Project};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, comp_id, has_comp, has_layers, layers_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// Ctrl+Alt+B: the work area spans the selected layers (their earliest In to their latest Out
|
||||
/// point), or the whole composition with none selected.
|
||||
fn work_area_to_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Work Area", None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let fd = c.frame_duration();
|
||||
let spans: Vec<(Tick, Tick)> = c.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.in_point, l.out_point)).collect();
|
||||
let (start, end) = match (spans.iter().map(|s| s.0).min(), spans.iter().map(|s| s.1).max()) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
_ => (Tick::ZERO, c.duration),
|
||||
};
|
||||
// min/max, not clamp: the bounds cross on a comp shorter than a frame.
|
||||
let start = start.min(c.duration - fd).max(Tick::ZERO);
|
||||
c.work_area = (start, end.min(c.duration).max(start + fd));
|
||||
Ok(())
|
||||
})?;
|
||||
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
Ok(json!({"start": c.work_area.0.seconds(), "end": c.work_area.1.seconds()}))
|
||||
}
|
||||
|
||||
/// Shift+F2: no keyframe, property or property group is selected; the layers stay selected.
|
||||
fn deselect_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.state.selected_keys.clear();
|
||||
s.state.selected_props.clear();
|
||||
s.state.selected_vertices.clear();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Alt+` / Alt+Shift+`: select the next (previous) mask of the layer, wrapping around; the first
|
||||
/// (last) one when none is selected.
|
||||
fn step_mask(s: &mut Session, p: &Value, dir: i64) -> Result<Value> {
|
||||
let cmd = if dir > 0 { "layer.mask.selectNext" } else { "layer.mask.selectPrevious" };
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let (lid, masks) = ids
|
||||
.iter()
|
||||
.filter_map(|id| comp.layer(*id))
|
||||
.find_map(|l| l.masks().filter(|m| m.groups().next().is_some()).map(|m| (l.id, m)))
|
||||
.ok_or_else(|| bad(cmd, "the selected layers have no masks"))?;
|
||||
let groups: Vec<_> = masks.groups().collect();
|
||||
let n = groups.len() as i64;
|
||||
// The selected mask: its group, or a property inside it.
|
||||
let selected = |g: &effectcraft_project::PropGroup| {
|
||||
s.state.selected_props.iter().any(|(l, uid)| *l == lid && (g.find_group(*uid).is_some() || g.find(*uid).is_some()))
|
||||
};
|
||||
let i = match groups.iter().position(|g| selected(g)) {
|
||||
Some(i) => (i as i64 + dir).rem_euclid(n.max(1)),
|
||||
None if dir > 0 => 0,
|
||||
None => n - 1,
|
||||
};
|
||||
let uid = groups.get(i as usize).map(|g| g.uid).ok_or_else(|| bad(cmd, "the selected layers have no masks"))?;
|
||||
s.state.selected_props = vec![(lid, uid)];
|
||||
s.state.selected_keys.clear();
|
||||
s.state.selected_vertices.clear();
|
||||
Ok(json!({"layer": lid.0, "mask": uid, "index": i + 1}))
|
||||
}
|
||||
|
||||
/// Alt+Shift+C: Casts Shadows on or off for the selected 3D layers (all follow the first one).
|
||||
fn casts_shadows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const PATH: &str = "materialOptions/castsShadows";
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let with: Vec<_> = ids.iter().filter_map(|id| comp.layer(*id)).filter(|l| l.switches.three_d && l.props.prop(PATH).is_some()).collect();
|
||||
let first = with.first().ok_or_else(|| bad("layer.castsShadows", "select a 3D layer"))?;
|
||||
let on = match p.get("value").and_then(Value::as_bool) {
|
||||
Some(v) => v,
|
||||
None => first.props.prop(PATH).is_none_or(|pr| pr.value == KV::Enum(0)),
|
||||
};
|
||||
let layers: Vec<u64> = with.iter().map(|l| l.id.0).collect();
|
||||
let set = super::find("material.set").ok_or_else(|| bad("layer.castsShadows", "Material Options are not available"))?;
|
||||
(set.run)(s, &json!({"comp": cid.0, "layers": layers, "castsShadows": if on { "on" } else { "off" }}))?;
|
||||
Ok(json!({"castsShadows": on, "layers": layers}))
|
||||
}
|
||||
|
||||
/// The comps connected to `from` by nesting, in either direction (its network of nested
|
||||
/// compositions), `from` included.
|
||||
fn hierarchy(proj: &Project, from: ItemId) -> BTreeSet<ItemId> {
|
||||
let mut edges: Vec<(ItemId, ItemId)> = Vec::new();
|
||||
for item in proj.items.values() {
|
||||
let Some(c) = item.as_comp() else { continue };
|
||||
for l in &c.layers {
|
||||
if let LayerSource::Comp { item: nested } = &l.source {
|
||||
edges.push((item.id, *nested));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seen = BTreeSet::from([from]);
|
||||
let mut todo = vec![from];
|
||||
while let Some(c) = todo.pop() {
|
||||
for (a, b) in &edges {
|
||||
let other = if *a == c {
|
||||
*b
|
||||
} else if *b == c {
|
||||
*a
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if seen.insert(other) {
|
||||
todo.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// Shift+Esc: activate the composition that was active most recently among those in the active
|
||||
/// composition's nesting hierarchy.
|
||||
fn activate_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let related = hierarchy(&s.project, cid);
|
||||
let next = s.state.recent_comps.iter().copied().find(|c| *c != cid && related.contains(c) && s.project.comp(*c).is_some());
|
||||
let Some(next) = next else { return Ok(Value::Null) };
|
||||
s.open_comp(next);
|
||||
Ok(json!({"comp": next.0}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"comp.workAreaToSelection",
|
||||
"Set Work Area to Selected Layers",
|
||||
[],
|
||||
Some("Cmd+Alt+B"),
|
||||
"{comp?, layers? (default the selected layers; the whole composition with none)}",
|
||||
has_comp,
|
||||
work_area_to_selection
|
||||
),
|
||||
cmd!("comp.activateRecent", "Activate Most Recent Composition in Hierarchy", [], Some("Shift+Escape"), "{comp?}", has_comp, activate_recent),
|
||||
cmd!("keys.deselectAll", "Deselect All Keyframes and Properties", [], ["Shift+F2", "Cmd+Alt+Shift+A"], "{}", always, deselect_keys),
|
||||
cmd!("layer.mask.selectNext", "Select Next Mask", [], Some("Alt+`"), "{layer?}", has_layers, |s, p| step_mask(s, p, 1)),
|
||||
cmd!("layer.mask.selectPrevious", "Select Previous Mask", [], Some("Alt+Shift+`"), "{layer?}", has_layers, |s, p| step_mask(s, p, -1)),
|
||||
cmd!("layer.castsShadows", "Casts Shadows", [], Some("Alt+Shift+C"), "{layers?, value?}", has_layers, casts_shadows),
|
||||
]
|
||||
}
|
||||
|
|
@ -100,6 +100,14 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"workEnd" => comp.work_area.1 - comp.frame_duration(),
|
||||
"layerIn" => sel_layer.map(|l| l.in_point).unwrap_or(t),
|
||||
"layerOut" => sel_layer.map(|l| l.out_point - comp.frame_duration()).unwrap_or(t),
|
||||
"nextInOut" | "prevInOut" => {
|
||||
// In and Out points (its last frame) of the selected layers, of every layer with none
|
||||
// selected.
|
||||
let sel = &s.state.selected_layers;
|
||||
let times =
|
||||
comp.layers.iter().filter(|l| sel.is_empty() || sel.contains(&l.id)).flat_map(|l| [l.in_point, l.out_point - comp.frame_duration()]).collect();
|
||||
step_to(times, t, comp.frame_duration(), to == "nextInOut")
|
||||
}
|
||||
"nextKey" | "prevKey" if visible.is_some() => {
|
||||
// What the Timeline shows: keys of its revealed properties, layer and comp markers
|
||||
// and the work area (After Effects' "visible items").
|
||||
|
|
@ -148,7 +156,7 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
step_to(times, t, comp.frame_duration(), to == "nextKey")
|
||||
}
|
||||
_ => return Err(super::bad("time.go", "to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey")),
|
||||
_ => return Err(super::bad("time.go", "to: start|end|workStart|workEnd|layerIn|layerOut|nextInOut|prevInOut|nextKey|prevKey")),
|
||||
};
|
||||
s.set_time(target);
|
||||
Ok(report(s))
|
||||
|
|
@ -180,6 +188,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("time.step", "Step Frames", [], None, "{frames}", has_comp, step),
|
||||
cmd!("time.start", "Go to Start", [], ["Home", "Cmd+Alt+ArrowLeft"], "{}", has_comp, |s, _| go(s, &json!({"to": "start"}))),
|
||||
cmd!("time.end", "Go to End", [], ["End", "Cmd+Alt+ArrowRight"], "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))),
|
||||
cmd!("time.workAreaStart", "Go to Work Area Start", [], Some("Shift+Home"), "{}", has_comp, |s, _| go(s, &json!({"to": "workStart"}))),
|
||||
cmd!("time.workAreaEnd", "Go to Work Area End", [], Some("Shift+End"), "{}", has_comp, |s, _| go(s, &json!({"to": "workEnd"}))),
|
||||
cmd!("time.previousInOut", "Go to Previous In or Out Point", [], Some("Cmd+Alt+Shift+ArrowLeft"), "{}", has_comp, |s, _| go(
|
||||
s,
|
||||
&json!({"to": "prevInOut"})
|
||||
)),
|
||||
cmd!("time.nextInOut", "Go to Next In or Out Point", [], Some("Cmd+Alt+Shift+ArrowRight"), "{}", has_comp, |s, _| go(s, &json!({"to": "nextInOut"}))),
|
||||
cmd!("time.layerIn", "Go to Layer In Point", [], Some("I"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerIn"}))),
|
||||
cmd!("time.layerOut", "Go to Layer Out Point", [], Some("O"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerOut"}))),
|
||||
cmd!(
|
||||
|
|
@ -200,7 +215,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Go To",
|
||||
[],
|
||||
None,
|
||||
"{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid, visible?: [{layer, prop}]}",
|
||||
"{to: start|end|workStart|workEnd|layerIn|layerOut|nextInOut|prevInOut|nextKey|prevKey, prop?: uid, visible?: [{layer, prop}]}",
|
||||
has_comp,
|
||||
go
|
||||
),
|
||||
|
|
|
|||
|
|
@ -200,6 +200,9 @@ pub struct EditorState {
|
|||
pub active_comp: Option<ItemId>,
|
||||
/// Open compositions (viewer/timeline tabs), in tab order.
|
||||
pub open_comps: Vec<ItemId>,
|
||||
/// Compositions that were active before the current one, most recent first (Shift+Esc).
|
||||
#[serde(default)]
|
||||
pub recent_comps: Vec<ItemId>,
|
||||
/// Current time per comp (the CTI).
|
||||
pub times: std::collections::BTreeMap<ItemId, Tick>,
|
||||
pub selected_layers: Vec<LayerId>,
|
||||
|
|
@ -879,6 +882,12 @@ impl Session {
|
|||
self.state.open_comps.push(id);
|
||||
}
|
||||
if self.state.active_comp != Some(id) {
|
||||
let previous = self.state.active_comp;
|
||||
self.state.recent_comps.retain(|c| *c != id && Some(*c) != previous);
|
||||
if let Some(previous) = previous {
|
||||
self.state.recent_comps.insert(0, previous);
|
||||
self.state.recent_comps.truncate(32);
|
||||
}
|
||||
self.state.active_comp = Some(id);
|
||||
self.state.selected_layers.clear();
|
||||
self.state.selected_props.clear();
|
||||
|
|
@ -1183,6 +1192,8 @@ mod tests_sequence;
|
|||
#[cfg(test)]
|
||||
mod tests_settings;
|
||||
#[cfg(test)]
|
||||
mod tests_shortcut_cmds;
|
||||
#[cfg(test)]
|
||||
mod tests_stubs;
|
||||
#[cfg(test)]
|
||||
mod tests_styles;
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ fn slip_moves_the_source_under_fixed_in_and_out() {
|
|||
let r = s.execute("layer.slip", json!({"layers": [id], "delta": 5.0})).unwrap();
|
||||
assert_eq!(get(&s).start_time.seconds(), 1.0);
|
||||
assert_eq!(r["slipped"][0]["delta"], json!(0.5));
|
||||
// Alt+PageUp / Alt+PageDown nudge by a frame.
|
||||
// Slip by a frame.
|
||||
s.execute("layer.slipBack", json!({"layers": [id]})).unwrap();
|
||||
assert!((get(&s).start_time.seconds() - (1.0 - 1.0 / 30.0)).abs() < 1e-9);
|
||||
s.execute("layer.slipForward", json!({"layers": [id]})).unwrap();
|
||||
|
|
@ -75,8 +75,9 @@ fn slip_moves_the_source_under_fixed_in_and_out() {
|
|||
// Undo walks back through the slips.
|
||||
assert!(s.undo() && s.undo() && s.undo() && s.undo());
|
||||
assert_eq!(get(&s).start_time, Tick::ZERO);
|
||||
assert_eq!(crate::find_command("layer.slipBack").and_then(|c| c.shortcut), Some("Alt+PageUp"));
|
||||
assert_eq!(crate::find_command("layer.slipForward").and_then(|c| c.shortcut), Some("Alt+PageDown"));
|
||||
// Alt+PageUp / Alt+PageDown move the layer; the one-frame slips have no default key.
|
||||
assert_eq!(crate::find_command("layer.slipBack").and_then(|c| c.shortcut), None);
|
||||
assert_eq!(crate::find_command("layer.slipForward").and_then(|c| c.shortcut), None);
|
||||
// Layers without a source duration can't slip.
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
assert!(s.execute("layer.slip", json!({"delta": 0.5})).is_err());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
//! The keyboard-only commands: time navigation, the work area, selection and layer timing.
|
||||
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A 10 s, 30 fps comp with three solids (#1 on top), each trimmed to `spans` (seconds).
|
||||
fn session(spans: [(f64, f64); 3]) -> (Session, Vec<u64>) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 100, "height": 100, "frameRate": 30, "duration": 10})).unwrap();
|
||||
for _ in 0..3 {
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
}
|
||||
let ids: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
|
||||
for (id, (a, b)) in ids.iter().zip(spans) {
|
||||
s.execute("layer.timing", json!({"layers": [id], "in": a, "out": b})).unwrap();
|
||||
}
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn secs(s: &Session) -> f64 {
|
||||
s.time().seconds()
|
||||
}
|
||||
|
||||
fn span(s: &Session, id: u64) -> (f64, f64) {
|
||||
let c = s.active_comp().unwrap();
|
||||
let l = c.layers.iter().find(|l| l.id.0 == id).unwrap();
|
||||
(l.in_point.seconds(), l.out_point.seconds())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_goes_to_the_work_area_and_to_in_and_out_points() {
|
||||
let (mut s, ids) = session([(1.0, 3.0), (2.0, 6.0), (0.0, 10.0)]);
|
||||
s.execute("comp.workArea", json!({"start": 2.0, "end": 4.0})).unwrap();
|
||||
s.execute("time.workAreaStart", json!({})).unwrap();
|
||||
assert_eq!(secs(&s), 2.0);
|
||||
s.execute("time.workAreaEnd", json!({})).unwrap();
|
||||
let frame = 1.0 / 30.0;
|
||||
assert!((secs(&s) - (4.0 - frame)).abs() < 1e-9);
|
||||
// In and Out points of the selected layers (an Out point is the layer's last frame).
|
||||
s.execute("layer.select", json!({"layers": [ids[0], ids[1]]})).unwrap();
|
||||
s.execute("time.set", json!({"time": 0.0})).unwrap();
|
||||
let mut seen = vec![];
|
||||
for _ in 0..5 {
|
||||
s.execute("time.nextInOut", json!({})).unwrap();
|
||||
seen.push(secs(&s));
|
||||
}
|
||||
let want = [1.0, 2.0, 3.0 - frame, 6.0 - frame, 6.0 - frame];
|
||||
assert!(seen.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9), "{seen:?}");
|
||||
s.execute("time.previousInOut", json!({})).unwrap();
|
||||
assert!((secs(&s) - (3.0 - frame)).abs() < 1e-9);
|
||||
// With no layer selected: every layer's points.
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
s.execute("time.set", json!({"time": 8.0})).unwrap();
|
||||
s.execute("time.nextInOut", json!({})).unwrap();
|
||||
assert!((secs(&s) - (10.0 - frame)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_area_follows_the_selected_layers() {
|
||||
let (mut s, ids) = session([(1.0, 3.0), (2.0, 6.0), (0.0, 10.0)]);
|
||||
s.execute("layer.select", json!({"layers": [ids[0], ids[1]]})).unwrap();
|
||||
s.execute("comp.workAreaToSelection", json!({})).unwrap();
|
||||
let wa = s.active_comp().unwrap().work_area;
|
||||
assert_eq!((wa.0.seconds(), wa.1.seconds()), (1.0, 6.0));
|
||||
// With nothing selected: the whole composition.
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
s.execute("comp.workAreaToSelection", json!({})).unwrap();
|
||||
let wa = s.active_comp().unwrap().work_area;
|
||||
assert_eq!((wa.0, wa.1.seconds()), (Tick::ZERO, 10.0));
|
||||
// One undo step each.
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active_comp().unwrap().work_area.1.seconds(), 6.0);
|
||||
// Layers reaching outside the comp don't take the work area with them.
|
||||
s.execute("layer.timing", json!({"layers": [ids[0]], "delta": -5.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [ids[0]]})).unwrap();
|
||||
s.execute("comp.workAreaToSelection", json!({})).unwrap();
|
||||
let wa = s.active_comp().unwrap().work_area;
|
||||
assert!(wa.0 == Tick::ZERO && wa.1 > wa.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_most_recent_comp_of_the_hierarchy_comes_back() {
|
||||
let mut s = Session::default();
|
||||
let new = |s: &mut Session, name: &str| s.execute("comp.new", json!({"name": name, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
|
||||
let main = new(&mut s, "Main");
|
||||
let nested = new(&mut s, "Nested");
|
||||
let other = new(&mut s, "Other");
|
||||
// Main contains Nested; Other stands alone.
|
||||
s.execute("comp.open", json!({"comp": main})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": nested})).unwrap();
|
||||
s.execute("comp.open", json!({"comp": nested})).unwrap();
|
||||
s.execute("comp.open", json!({"comp": other})).unwrap();
|
||||
// Other has no related comp to go back to.
|
||||
assert!(s.execute("comp.activateRecent", json!({})).unwrap().is_null());
|
||||
s.execute("comp.open", json!({"comp": main})).unwrap();
|
||||
// From Main: Other was active last, but Nested is the one in Main's hierarchy.
|
||||
s.execute("comp.activateRecent", json!({})).unwrap();
|
||||
assert_eq!(s.state.active_comp.map(|c| c.0), Some(nested));
|
||||
s.execute("comp.activateRecent", json!({})).unwrap();
|
||||
assert_eq!(s.state.active_comp.map(|c| c.0), Some(main));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframes_and_properties_deselect_but_layers_stay() {
|
||||
let (mut s, ids) = session([(0.0, 10.0); 3]);
|
||||
s.execute("layer.select", json!({"layers": [ids[0]]})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": ids[0], "path": "transform/opacity", "time": 1.0, "value": 50.0})).unwrap();
|
||||
s.execute("keys.selectAll", json!({})).unwrap();
|
||||
assert!(!s.state.selected_keys.is_empty());
|
||||
s.execute("keys.deselectAll", json!({})).unwrap();
|
||||
assert!(s.state.selected_keys.is_empty() && s.state.selected_props.is_empty());
|
||||
assert_eq!(s.state.selected_layers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_extends_through_the_stack() {
|
||||
let (mut s, ids) = session([(0.0, 10.0); 3]);
|
||||
s.execute("layer.select", json!({"layers": [ids[0]]})).unwrap();
|
||||
s.execute("layer.extendSelectionNext", json!({})).unwrap();
|
||||
s.execute("layer.extendSelectionNext", json!({})).unwrap();
|
||||
let mut sel: Vec<u64> = s.state.selected_layers.iter().map(|l| l.0).collect();
|
||||
sel.sort();
|
||||
let mut all = ids.clone();
|
||||
all.sort();
|
||||
assert_eq!(sel, all);
|
||||
// At the end of the stack the selection stays.
|
||||
s.execute("layer.extendSelectionNext", json!({})).unwrap();
|
||||
assert_eq!(s.state.selected_layers.len(), 3);
|
||||
s.execute("layer.select", json!({"layers": [ids[2]]})).unwrap();
|
||||
s.execute("layer.extendSelectionPrevious", json!({})).unwrap();
|
||||
assert_eq!(s.state.selected_layers.len(), 2);
|
||||
assert!(s.state.selected_layers.iter().any(|l| l.0 == ids[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_move_to_the_comp_ends_and_by_ten_frames() {
|
||||
let (mut s, ids) = session([(1.0, 3.0), (2.0, 6.0), (0.0, 10.0)]);
|
||||
s.execute("layer.select", json!({"layers": [ids[0], ids[1]]})).unwrap();
|
||||
s.execute("layer.moveInToStart", json!({})).unwrap();
|
||||
assert_eq!((span(&s, ids[0]), span(&s, ids[1])), ((0.0, 2.0), (0.0, 4.0)));
|
||||
s.execute("layer.moveOutToEnd", json!({})).unwrap();
|
||||
assert_eq!((span(&s, ids[0]), span(&s, ids[1])), ((8.0, 10.0), (6.0, 10.0)));
|
||||
s.execute("layer.moveEarlier10", json!({})).unwrap();
|
||||
let (a, b) = span(&s, ids[0]);
|
||||
assert!((a - (8.0 - 10.0 / 30.0)).abs() < 1e-9 && (b - (10.0 - 10.0 / 30.0)).abs() < 1e-9);
|
||||
s.execute("layer.moveLater10", json!({})).unwrap();
|
||||
assert_eq!(span(&s, ids[0]), (8.0, 10.0));
|
||||
s.execute("layer.moveLater", json!({})).unwrap();
|
||||
s.execute("layer.moveEarlier", json!({})).unwrap();
|
||||
assert_eq!(span(&s, ids[0]), (8.0, 10.0));
|
||||
// The unselected layer never moved.
|
||||
assert_eq!(span(&s, ids[2]), (0.0, 10.0));
|
||||
for (id, keys) in [
|
||||
("layer.moveEarlier", "Alt+PageUp"),
|
||||
("layer.moveLater", "Alt+PageDown"),
|
||||
("layer.moveEarlier10", "Alt+Shift+PageUp"),
|
||||
("layer.moveLater10", "Alt+Shift+PageDown"),
|
||||
] {
|
||||
assert_eq!(crate::find_command(id).and_then(|c| c.shortcut), Some(keys));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_and_out_points_stretch_to_the_current_time() {
|
||||
let (mut s, ids) = session([(2.0, 4.0), (0.0, 10.0), (0.0, 10.0)]);
|
||||
let stretch = |s: &Session| s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == ids[0]).unwrap().stretch;
|
||||
s.execute("layer.select", json!({"layers": [ids[0]]})).unwrap();
|
||||
// In point to 1 s: the Out point holds, the layer is 3 s long at 150 %.
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("layer.stretchInToTime", json!({})).unwrap();
|
||||
assert_eq!(span(&s, ids[0]), (1.0, 4.0));
|
||||
assert!((stretch(&s) - 150.0).abs() < 1e-6);
|
||||
// Out point to 7 s: the In point holds, 6 s at 300 %.
|
||||
s.execute("time.set", json!({"time": 7.0})).unwrap();
|
||||
s.execute("layer.stretchOutToTime", json!({})).unwrap();
|
||||
assert_eq!(span(&s, ids[0]), (1.0, 7.0));
|
||||
assert!((stretch(&s) - 300.0).abs() < 1e-6);
|
||||
// A current time before the In point can't be the Out point: the layer is left alone.
|
||||
s.execute("time.set", json!({"time": 0.5})).unwrap();
|
||||
s.execute("layer.stretchOutToTime", json!({})).unwrap();
|
||||
assert_eq!(span(&s, ids[0]), (1.0, 7.0));
|
||||
assert!(s.execute("layer.timeStretch", json!({"to": "middle"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_are_stepped_through_and_wrap() {
|
||||
let (mut s, ids) = session([(0.0, 10.0); 3]);
|
||||
s.execute("layer.select", json!({"layers": [ids[0]]})).unwrap();
|
||||
assert!(s.execute("layer.mask.selectNext", json!({})).is_err());
|
||||
for _ in 0..3 {
|
||||
s.execute("layer.addMask", json!({"shape": "rect"})).unwrap();
|
||||
}
|
||||
s.execute("keys.deselectAll", json!({})).unwrap();
|
||||
let step = |s: &mut Session, cmd: &str| s.execute(cmd, json!({})).unwrap()["index"].as_u64().unwrap();
|
||||
assert_eq!(step(&mut s, "layer.mask.selectNext"), 1);
|
||||
assert_eq!(step(&mut s, "layer.mask.selectNext"), 2);
|
||||
assert_eq!(step(&mut s, "layer.mask.selectNext"), 3);
|
||||
assert_eq!(step(&mut s, "layer.mask.selectNext"), 1);
|
||||
assert_eq!(step(&mut s, "layer.mask.selectPrevious"), 3);
|
||||
assert_eq!(s.state.selected_props.len(), 1);
|
||||
s.execute("keys.deselectAll", json!({})).unwrap();
|
||||
assert_eq!(step(&mut s, "layer.mask.selectPrevious"), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn casts_shadows_toggles_on_3d_layers_only() {
|
||||
let (mut s, ids) = session([(0.0, 10.0); 3]);
|
||||
s.execute("layer.select", json!({"layers": [ids[0], ids[1]]})).unwrap();
|
||||
assert!(s.execute("layer.castsShadows", json!({})).is_err());
|
||||
s.execute("layer.setSwitch", json!({"layers": [ids[0]], "switch": "threeD", "value": true})).unwrap();
|
||||
let before = s.execute("layer.castsShadows", json!({})).unwrap();
|
||||
assert_eq!(before["layers"], json!([ids[0]]));
|
||||
let after = s.execute("layer.castsShadows", json!({})).unwrap();
|
||||
assert_ne!(before["castsShadows"], after["castsShadows"]);
|
||||
assert_eq!(s.execute("layer.castsShadows", json!({"value": true})).unwrap()["castsShadows"], json!(true));
|
||||
}
|
||||
|
|
@ -472,6 +472,14 @@ impl DockNode {
|
|||
self.panels(&mut v);
|
||||
v.contains(&p)
|
||||
}
|
||||
/// The tabs of the group `p` is in, in order.
|
||||
pub fn group_of(&self, p: PanelKind) -> Option<&[PanelKind]> {
|
||||
match self {
|
||||
DockNode::Split { a, b, .. } => a.group_of(p).or_else(|| b.group_of(p)),
|
||||
DockNode::Tabs { panels, .. } => panels.contains(&p).then_some(panels.as_slice()),
|
||||
DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.panels.as_slice()),
|
||||
}
|
||||
}
|
||||
/// Make `p` the active tab of its group. Returns false if not present.
|
||||
pub fn activate(&mut self, p: PanelKind) -> bool {
|
||||
if !crate::dock_shared::valid_tree(self) {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
uic!("playback.stop", "Stop", [], None),
|
||||
uic!("view.fit", "Fit", [], Some("Shift+/")),
|
||||
uic!("view.actualSize", "100%", [], Some("/")),
|
||||
uic!("view.fitUpTo100", "Fit up to 100%", [], Some("Alt+/")),
|
||||
uic!("view.nextItem", "Next Composition in Viewer", [], Some("Shift+.")),
|
||||
uic!("view.previousItem", "Previous Composition in Viewer", [], Some("Shift+,")),
|
||||
uic!("viewer.nudge.up", "Move Selected Layers Up 1 Pixel at Current Magnification", [], Some("ArrowUp")),
|
||||
uic!("viewer.nudge.down", "Move Selected Layers Down 1 Pixel at Current Magnification", [], Some("ArrowDown")),
|
||||
uic!("viewer.nudge.left", "Move Selected Layers Left 1 Pixel at Current Magnification", [], Some("ArrowLeft")),
|
||||
|
|
@ -53,6 +56,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
uic!("timeline.zoomIn", "Zoom In Time", [], Some("=")),
|
||||
uic!("timeline.zoomOut", "Zoom Out Time", [], Some("-")),
|
||||
uic!("timeline.zoomFit", "Zoom to Fit Comp", [], Some("Shift+;")),
|
||||
uic!("timeline.scrollToTime", "Scroll to Current Time", [], Some("D")),
|
||||
uic!("timeline.scrollToLayer", "Scroll Topmost Selected Layer to Top", [], Some("X")),
|
||||
uic!("timeline.parentColumn", "Show/Hide Parent Column", [], Some("Shift+F4")),
|
||||
uic!("timeline.zoomFrameToggle", "Zoom In to Frame Level / Out to the Whole Comp", [], Some(";")),
|
||||
uic!("timeline.graphEditor", "Graph Editor", [], Some("Shift+F3")),
|
||||
uic!("timeline.switchesModes", "Toggle Switches / Modes", [], Some("F4")),
|
||||
|
|
@ -108,6 +114,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
uic!("tool.orbit", "Orbit Tool", [], Some("1")),
|
||||
uic!("tool.panCamera", "Pan Camera Tool", [], Some("2")),
|
||||
uic!("tool.dolly", "Dolly Tool", [], Some("3")),
|
||||
uic!("tool.camera", "Camera Tools", [], Some("C")),
|
||||
uic!("tool.rotate", "Rotation Tool", [], Some("W")),
|
||||
uic!("tool.panBehind", "Pan Behind Tool", [], Some("Y")),
|
||||
uic!("tool.shape", "Shape Tools", [], Some("Q")),
|
||||
|
|
@ -123,6 +130,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
uic!("markers.dialog", "Marker Settings...", [], None),
|
||||
uic!("window.maximizePanel", "Maximize Panel Under Pointer", [], Some("`")),
|
||||
uic!("window.maximizeApp", "Maximize or Restore Application Window", [], Some("Cmd+\\")),
|
||||
uic!("window.toggleCompTimeline", "Switch Between Composition and Timeline Panel", [], Some("\\")),
|
||||
uic!("window.nextPanel", "Next Panel in Frame", [], Some("Alt+Shift+.")),
|
||||
uic!("window.previousPanel", "Previous Panel in Frame", [], Some("Alt+Shift+,")),
|
||||
uic!("window.closeAll", "Close Panel or All Viewers", [], Some("Cmd+Shift+W")),
|
||||
uic!("window.dockPanel", "Dock Panel", [], None),
|
||||
uic!("window.floatPanel", "Undock Panel", [], None),
|
||||
uic!("window.closePanel", "Close Panel", [], None),
|
||||
|
|
@ -374,6 +385,16 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
return frontend(app, ctx, "window.workspace", json!({"name": ws}));
|
||||
}
|
||||
if let Some(t) = id.strip_prefix("tool.") {
|
||||
// C cycles through the camera tools.
|
||||
if t == "camera" {
|
||||
let tools = [Tool::Orbit, Tool::PanCamera, Tool::Dolly];
|
||||
let next = match tools.iter().position(|x| *x == app.ui.tool) {
|
||||
Some(i) => tools[(i + 1) % tools.len()],
|
||||
None => Tool::Orbit,
|
||||
};
|
||||
app.ui.tool = next;
|
||||
return Ok(json!({"tool": next}));
|
||||
}
|
||||
// Slot shortcuts cycle through the slot's tools.
|
||||
let slot = match t {
|
||||
"shape" => Some(8),
|
||||
|
|
@ -572,6 +593,62 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
app.ui.viewer.pan = [0.0, 0.0];
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
"timeline.scrollToTime" => app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Time),
|
||||
"timeline.scrollToLayer" => app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Layer),
|
||||
"timeline.parentColumn" => {
|
||||
let on = !crate::panels::timeline::column_visible(&app.ui.timeline, "parent");
|
||||
crate::panels::timeline::set_column(&mut app.ui.timeline, "parent", on)?;
|
||||
return Ok(json!({"parent": on}));
|
||||
}
|
||||
"view.fitUpTo100" => {
|
||||
app.ui.viewer.zoom = None;
|
||||
app.ui.viewer.fit_up_to_100 = true;
|
||||
app.ui.viewer.pan = [0.0, 0.0];
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// Shift+, / Shift+.: the previous / next open composition (the viewer's tabs).
|
||||
"view.nextItem" | "view.previousItem" => {
|
||||
let open = app.session.state.open_comps.clone();
|
||||
let Some(i) = app.session.state.active_comp.and_then(|c| open.iter().position(|o| *o == c)) else { return Ok(Value::Null) };
|
||||
let step = if id == "view.nextItem" { 1 } else { open.len().saturating_sub(1) };
|
||||
let Some(next) = open.get((i + step) % open.len().max(1)).copied().filter(|n| Some(*n) != app.session.state.active_comp) else {
|
||||
return Ok(Value::Null);
|
||||
};
|
||||
return run_engine(app, ctx, "comp.open", json!({"comp": next.0}));
|
||||
}
|
||||
// \\: between the Composition viewer and the Timeline.
|
||||
"window.toggleCompTimeline" => {
|
||||
let to = if app.ui.focused == PanelKind::Timeline { crate::panels::viewers::active_panel(app) } else { PanelKind::Timeline };
|
||||
app.show_panel(to);
|
||||
return Ok(json!({"focused": to.id()}));
|
||||
}
|
||||
// Alt+Shift+, / Alt+Shift+.: the previous / next tab of the focused panel's group.
|
||||
"window.nextPanel" | "window.previousPanel" => {
|
||||
let focused = app.ui.focused;
|
||||
let group: Vec<PanelKind> = match app.ui.floating.iter().find(|f| f.panels.contains(&focused)) {
|
||||
Some(f) => f.panels.clone(),
|
||||
None => app.ui.dock.group_of(focused).map(<[PanelKind]>::to_vec).unwrap_or_default(),
|
||||
};
|
||||
let Some(i) = group.iter().position(|p| *p == focused) else { return Ok(Value::Null) };
|
||||
let step = if id == "window.nextPanel" { 1 } else { group.len().saturating_sub(1) };
|
||||
if let Some(next) = group.get((i + step) % group.len().max(1)).copied() {
|
||||
app.show_panel(next);
|
||||
}
|
||||
return Ok(json!({"focused": app.ui.focused.id()}));
|
||||
}
|
||||
// Ctrl+Shift+W: with a viewer or the Timeline active, every open composition closes;
|
||||
// any other panel closes itself.
|
||||
"window.closeAll" => {
|
||||
let focused = app.ui.focused;
|
||||
if matches!(focused, PanelKind::Composition | PanelKind::Timeline | PanelKind::Viewer(_)) {
|
||||
for c in app.session.state.open_comps.clone() {
|
||||
run_engine(app, ctx, "comp.close", json!({"comp": c.0}))?;
|
||||
}
|
||||
} else {
|
||||
app.close_panel(focused);
|
||||
}
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
"view.actualSize" if app.ui.focused == PanelKind::Layer => {
|
||||
app.ui.layer_zoom = Some(1.0 / ctx.pixels_per_point());
|
||||
app.ui.layer_pan = [0.0, 0.0];
|
||||
|
|
@ -2133,6 +2210,131 @@ mod tests {
|
|||
assert!(cmds.contains(&egui::ViewportCommand::Maximized(false)), "{cmds:?}");
|
||||
}
|
||||
|
||||
/// The default keys of the keyboard-only commands parse and reach them.
|
||||
#[test]
|
||||
fn keyboard_only_commands_are_bound() {
|
||||
let app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
for (id, keys) in [
|
||||
("time.workAreaStart", "Shift+Home"),
|
||||
("time.workAreaEnd", "Shift+End"),
|
||||
("time.previousInOut", "Cmd+Alt+Shift+ArrowLeft"),
|
||||
("time.nextInOut", "Cmd+Alt+Shift+ArrowRight"),
|
||||
("comp.workAreaToSelection", "Cmd+Alt+B"),
|
||||
("comp.activateRecent", "Shift+Escape"),
|
||||
("keys.deselectAll", "Shift+F2"),
|
||||
("keys.deselectAll", "Cmd+Alt+Shift+A"),
|
||||
("layer.extendSelectionNext", "Cmd+Shift+ArrowDown"),
|
||||
("layer.moveInToStart", "Alt+Home"),
|
||||
("layer.moveEarlier", "Alt+PageUp"),
|
||||
("layer.moveLater10", "Alt+Shift+PageDown"),
|
||||
("layer.stretchInToTime", "Cmd+Shift+,"),
|
||||
("layer.stretchOutToTime", "Cmd+Alt+,"),
|
||||
("layer.mask.selectNext", "Alt+`"),
|
||||
("layer.mask.selectPrevious", "Alt+Shift+`"),
|
||||
("layer.castsShadows", "Alt+Shift+C"),
|
||||
("timeline.scrollToTime", "D"),
|
||||
("timeline.scrollToLayer", "X"),
|
||||
("timeline.parentColumn", "Shift+F4"),
|
||||
("view.fitUpTo100", "Alt+/"),
|
||||
("view.nextItem", "Shift+."),
|
||||
("view.previousItem", "Shift+,"),
|
||||
("window.toggleCompTimeline", "\\"),
|
||||
("window.nextPanel", "Alt+Shift+."),
|
||||
("window.previousPanel", "Alt+Shift+,"),
|
||||
("window.closeAll", "Cmd+Shift+W"),
|
||||
("tool.camera", "C"),
|
||||
] {
|
||||
assert!(bindings(&app.session).into_iter().any(|x| x.2 == id && Some((x.0, x.1)) == parse_shortcut(keys)), "{id} on {keys}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_keys_scroll_and_toggle_the_parent_column() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let run = |app: &mut EffectcraftApp, id: &str| invoke(app, &ctx, id, json!({})).unwrap();
|
||||
// D and X leave a scroll for the Timeline to do when it is drawn.
|
||||
run(&mut app, "timeline.scrollToTime");
|
||||
assert_eq!(app.ui.timeline.scroll_to, Some(crate::state::ScrollTo::Time));
|
||||
run(&mut app, "timeline.scrollToLayer");
|
||||
assert_eq!(app.ui.timeline.scroll_to, Some(crate::state::ScrollTo::Layer));
|
||||
// Shift+F4 shows and hides the Parent column.
|
||||
let parent = crate::panels::timeline::column_visible(&app.ui.timeline, "parent");
|
||||
run(&mut app, "timeline.parentColumn");
|
||||
assert_eq!(crate::panels::timeline::column_visible(&app.ui.timeline, "parent"), !parent);
|
||||
run(&mut app, "timeline.parentColumn");
|
||||
assert_eq!(crate::panels::timeline::column_visible(&app.ui.timeline, "parent"), parent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_keys_fit_and_step_through_open_comps() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let run = |app: &mut EffectcraftApp, id: &str| invoke(app, &ctx, id, json!({})).unwrap();
|
||||
let new = |app: &mut EffectcraftApp, name: &str| app.session.execute("comp.new", json!({"name": name, "duration": 4})).unwrap()["comp"].as_u64();
|
||||
let active = |app: &EffectcraftApp| app.session.state.active_comp.map(|c| c.0);
|
||||
// With one composition open there is nothing to step to.
|
||||
let a = new(&mut app, "A");
|
||||
run(&mut app, "view.nextItem");
|
||||
assert_eq!(active(&app), a);
|
||||
// Shift+, / Shift+.: through the open compositions, wrapping.
|
||||
let b = new(&mut app, "B");
|
||||
assert_eq!(active(&app), b);
|
||||
run(&mut app, "view.nextItem");
|
||||
assert_eq!(active(&app), a);
|
||||
run(&mut app, "view.previousItem");
|
||||
assert_eq!(active(&app), b);
|
||||
// Alt+/ fits the viewer without magnifying past 100 %; Shift+/ fits fully again.
|
||||
app.ui.viewer.zoom = Some(3.0);
|
||||
run(&mut app, "view.fitUpTo100");
|
||||
assert!(app.ui.viewer.zoom.is_none() && app.ui.viewer.fit_up_to_100);
|
||||
run(&mut app, "view.fit");
|
||||
assert!(!app.ui.viewer.fit_up_to_100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_keys_switch_cycle_and_close() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let run = |app: &mut EffectcraftApp, id: &str| invoke(app, &ctx, id, json!({})).unwrap();
|
||||
for name in ["A", "B"] {
|
||||
app.session.execute("comp.new", json!({"name": name, "duration": 4})).unwrap();
|
||||
}
|
||||
// \\ switches between the Timeline and the Composition viewer.
|
||||
app.ui.focused = PanelKind::Timeline;
|
||||
run(&mut app, "window.toggleCompTimeline");
|
||||
assert_eq!(app.ui.focused, PanelKind::Composition);
|
||||
run(&mut app, "window.toggleCompTimeline");
|
||||
assert_eq!(app.ui.focused, PanelKind::Timeline);
|
||||
// Alt+Shift+. / Alt+Shift+,: the tabs of the focused panel's group, wrapping.
|
||||
let group = app.ui.dock.group_of(PanelKind::Timeline).unwrap().to_vec();
|
||||
assert!(group.len() > 1, "{group:?}");
|
||||
run(&mut app, "window.nextPanel");
|
||||
assert_eq!(app.ui.focused, group[(group.iter().position(|p| *p == PanelKind::Timeline).unwrap() + 1) % group.len()]);
|
||||
run(&mut app, "window.previousPanel");
|
||||
assert_eq!(app.ui.focused, PanelKind::Timeline);
|
||||
// Ctrl+Shift+W with the Timeline active closes every composition.
|
||||
run(&mut app, "window.closeAll");
|
||||
assert!(app.session.state.open_comps.is_empty());
|
||||
// With another panel active it closes that panel.
|
||||
app.show_panel(PanelKind::Project);
|
||||
run(&mut app, "window.closeAll");
|
||||
assert!(!app.ui.dock.contains(PanelKind::Project));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c_cycles_the_camera_tools() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.ui.tool = Tool::Selection;
|
||||
let mut seen = vec![];
|
||||
for _ in 0..4 {
|
||||
invoke(&mut app, &ctx, "tool.camera", json!({})).unwrap();
|
||||
seen.push(app.ui.tool);
|
||||
}
|
||||
assert_eq!(seen, [Tool::Orbit, Tool::PanCamera, Tool::Dolly, Tool::Orbit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_uses_the_active_custom_preset() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
|
|
|
|||
|
|
@ -230,6 +230,14 @@ fn page_to_cti(start: f64, span: f64, duration: f64, cti: f64) -> f64 {
|
|||
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]);
|
||||
|
|
@ -1346,6 +1354,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
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());
|
||||
}
|
||||
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;
|
||||
|
|
@ -1753,6 +1766,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
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();
|
||||
}
|
||||
}
|
||||
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
|
||||
|
|
@ -4119,6 +4140,17 @@ mod tests {
|
|||
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]
|
||||
|
|
|
|||
|
|
@ -317,6 +317,15 @@ impl TimelineState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Where a Timeline shortcut scrolls to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum ScrollTo {
|
||||
/// D: the current time, centred in the time graph.
|
||||
Time,
|
||||
/// X: the topmost selected layer, at the top of the layers.
|
||||
Layer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TimelineState {
|
||||
/// Visible span start (seconds) and pixels per second; None = fit comp.
|
||||
|
|
@ -385,6 +394,9 @@ pub struct TimelineState {
|
|||
/// The name column shows Source Name instead of Layer Name (click its header).
|
||||
#[serde(default)]
|
||||
pub source_name: bool,
|
||||
/// A scroll asked for by a shortcut (D, X), done when the Timeline is next drawn.
|
||||
#[serde(skip)]
|
||||
pub scroll_to: Option<ScrollTo>,
|
||||
}
|
||||
|
||||
pub fn default_tl_columns() -> BTreeSet<String> {
|
||||
|
|
@ -426,6 +438,7 @@ impl Default for TimelineState {
|
|||
unlinked: BTreeSet::new(),
|
||||
columns: default_tl_columns(),
|
||||
source_name: false,
|
||||
scroll_to: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue