Merge branch 'worktree-agent-a798505697e9a8db3'

# Conflicts:
#	crates/engine/src/commands/mod.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/lib.rs
This commit is contained in:
Brandon Thomas 2026-10-02 09:23:25 -04:00
commit 22346f2949
41 changed files with 4390 additions and 252 deletions

View File

@ -180,7 +180,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is
done and what comes next is in the [ROADMAP](ROADMAP.md). Not there yet: motion tracking, puppet,
paint and roto tools, Lottie, preferences and a shortcut editor, and GPU compositing. The web build
paint and roto tools, Lottie and GPU compositing. The web build
([docs/web.md](docs/web.md)) runs the full app in the browser, single-threaded for now. Projects
are saved as `.ecproj`, readable versioned JSON; After Effects `.aep` files cannot be opened.

View File

@ -19,9 +19,9 @@ once.
| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done |
| M11 | Animation tools: audio playback, meters and waveforms (done); presets, Motion Sketch, Wiggler, Lottie | In progress |
| M12 | Performance: layer cache and parallel compositing (done), GPU compositing, disk cache, motion tracking | In progress |
| M13+ | Puppet, paint, roto, motion tracking, preferences and shortcut editor, a plugin API | Planned |
| M13+ | Puppet, paint, roto, motion tracking, a plugin API | Planned |
| M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded |
| M14 | Built for agents: MCP server, command-line tool, control channel | Done |
| M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done |
## How far from full parity

View File

@ -1,4 +1,5 @@
//! Audio preview output through cpal (the default output device).
//! Audio preview output through cpal (the device and channel mapping chosen in Settings ▸ Audio;
//! the default output device when none is set or it is gone).
//!
//! cpal streams are not `Send` on every platform, so each preview runs its stream on a small
//! dedicated thread that owns it until stopped. The callback pulls interleaved stereo from the
@ -12,21 +13,41 @@ use std::thread::JoinHandle;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, SizedSample};
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed};
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed, AudioOutput};
pub struct CpalOut {
out: AudioOutput,
rate: u32,
latency: Arc<AtomicU64>,
stop: Option<mpsc::Sender<()>>,
thread: Option<JoinHandle<()>>,
}
/// Open the default output device (`None` without one).
pub fn open() -> Option<Box<dyn AudioDevice>> {
/// Names of the output devices (Settings ▸ Audio).
pub fn devices() -> Vec<String> {
let host = cpal::default_host();
let dev = host.default_output_device()?;
#[allow(deprecated)]
host.output_devices().map(|d| d.filter_map(|d| d.name().ok()).collect()).unwrap_or_default()
}
/// The named output device, or the default one.
fn device(name: &str) -> Option<cpal::Device> {
let host = cpal::default_host();
if !name.is_empty() {
#[allow(deprecated)]
let found = host.output_devices().ok().and_then(|mut d| d.find(|d| d.name().ok().as_deref() == Some(name)));
if found.is_some() {
return found;
}
}
host.default_output_device()
}
/// Open the output device (`None` without one).
pub fn open(out: &AudioOutput) -> Option<Box<dyn AudioDevice>> {
let dev = device(&out.device)?;
let cfg = dev.default_output_config().ok()?;
Some(Box::new(CpalOut { rate: cfg.sample_rate().0, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None }))
Some(Box::new(CpalOut { out: out.clone(), rate: cfg.sample_rate().0, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None }))
}
fn run<T: SizedSample + FromSample<f32>>(
@ -34,8 +55,11 @@ fn run<T: SizedSample + FromSample<f32>>(
cfg: &cpal::StreamConfig,
feed: Arc<AudioFeed>,
latency: Arc<AtomicU64>,
map: (usize, usize),
) -> Result<cpal::Stream, String> {
let ch = cfg.channels as usize;
// Output mapping: device channels (0-based) for left and right; out of range → 1 and 2.
let (left, right) = if map.0 < ch && map.1 < ch { map } else { (0, 1) };
let rate = cfg.sample_rate.0 as f64;
let mut stereo: Vec<f32> = Vec::new();
dev.build_output_stream(
@ -46,11 +70,15 @@ fn run<T: SizedSample + FromSample<f32>>(
feed.pull(&mut stereo);
for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.chunks_exact(2)) {
for (c, o) in f.iter_mut().enumerate() {
let v = match (ch, c) {
(1, _) => (s[0] + s[1]) * 0.5,
(_, 0) => s[0],
(_, 1) => s[1],
_ => 0.0,
// Mono devices, or left and right mapped to the same channel, get the mix.
let v = if ch == 1 || (c == left && c == right) {
(s[0] + s[1]) * 0.5
} else if c == left {
s[0]
} else if c == right {
s[1]
} else {
0.0
};
*o = T::from_sample(v.clamp(-1.0, 1.0));
}
@ -77,11 +105,12 @@ impl AudioDevice for CpalOut {
let (ready_tx, ready_rx) = mpsc::channel::<Result<(), String>>();
let latency = self.latency.clone();
let rate = self.rate;
let name = self.out.device.clone();
let map = ((self.out.left.max(1) - 1) as usize, (self.out.right.max(1) - 1) as usize);
let thread = std::thread::Builder::new()
.name("ec-audio-out".into())
.spawn(move || {
let host = cpal::default_host();
let Some(dev) = host.default_output_device() else {
let Some(dev) = device(&name) else {
let _ = ready_tx.send(Err("no audio output device".into()));
return;
};
@ -95,10 +124,10 @@ impl AudioDevice for CpalOut {
let mut cfg: cpal::StreamConfig = sup.config();
cfg.sample_rate = cpal::SampleRate(rate);
let stream = match sup.sample_format() {
cpal::SampleFormat::F32 => run::<f32>(&dev, &cfg, feed, latency),
cpal::SampleFormat::I16 => run::<i16>(&dev, &cfg, feed, latency),
cpal::SampleFormat::U16 => run::<u16>(&dev, &cfg, feed, latency),
cpal::SampleFormat::I32 => run::<i32>(&dev, &cfg, feed, latency),
cpal::SampleFormat::F32 => run::<f32>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::I16 => run::<i16>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::U16 => run::<u16>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::I32 => run::<i32>(&dev, &cfg, feed, latency, map),
f => Err(format!("unsupported sample format {f:?}")),
};
let stream = match stream.and_then(|s| s.play().map(|_| s).map_err(|e| e.to_string())) {

View File

@ -15,14 +15,14 @@ fn main() -> eframe::Result {
let mut control_port: Option<u16> = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut demo = true;
let mut home = false;
let mut home: Option<bool> = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--demo" => demo = true,
"--empty" => demo = false,
"--home" => home = true,
"--home" => home = Some(true),
"--version" => {
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
@ -46,6 +46,13 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
let mut session = effectcraft_host::session();
// Settings, shortcut presets and the crash-recovery sentinel live in the platform
// config directory. Agent-driven runs (`--control`) skip crash recovery.
if let Some(dir) = config_dir() {
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
}
session.load_settings();
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
let project = files.iter().find(|f| f.ends_with(".ecproj")).cloned();
if let Some(p) = project {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
@ -60,8 +67,12 @@ fn main() -> eframe::Result {
{
eprintln!("effectcraft: {e}");
}
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = home;
app.ui.start_screen = show_home;
if let Some(r) = recovery {
app.offer_recovery(r);
}
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}));
@ -71,6 +82,11 @@ fn main() -> eframe::Result {
app.hooks.pick_open_project =
Some(Box::new(|| rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).pick_file().map(|p| p.to_string_lossy().to_string())));
app.hooks.audio_device = Some(Box::new(audio_out::open));
app.hooks.audio_devices = Some(Box::new(audio_out::devices));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| {
rfd::FileDialog::new().add_filter(ext, &[ext]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
}));
if let Some(port) = control_port {
disable_app_nap();
let rx = control_server::start(port, cc.egui_ctx.clone());
@ -81,6 +97,24 @@ fn main() -> eframe::Result {
)
}
/// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides):
/// `~/Library/Application Support/EffectCraft` (macOS), `%APPDATA%\EffectCraft` (Windows),
/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others).
fn config_dir() -> Option<std::path::PathBuf> {
use std::path::PathBuf;
if let Some(d) = std::env::var_os("EFFECTCRAFT_CONFIG_DIR") {
return Some(PathBuf::from(d));
}
let home = || std::env::var_os("HOME").map(PathBuf::from);
if cfg!(target_os = "macos") {
return home().map(|h| h.join("Library/Application Support/EffectCraft"));
}
if cfg!(target_os = "windows") {
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft"));
}
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft"))
}
/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard
/// focus stays where it is (macOS; winit activates ignoring other apps by default).
fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {

View File

@ -0,0 +1,193 @@
//! Auto-save and crash recovery (PRJ-7).
//!
//! - **Auto-save**: every *n* minutes a dirty project is written to an "EffectCraft Auto-Save"
//! folder next to it (or a custom folder) as `<name> auto-save N.ecproj`, the After Effects
//! naming. Slots rotate through 1…max versions, overwriting the oldest. Writes are atomic
//! (temporary file + rename), so a crash mid-write never damages the previous auto-save.
//! - **Crash recovery**: while the app runs, a sentinel (`session.lock` in the config store)
//! records the open project and its latest auto-save. A clean exit removes it; finding one at
//! launch means the last session ended unexpectedly, and the frontend offers to open the
//! latest auto-save.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config::ConfigStore;
use crate::prefs::Prefs;
/// Folder created next to a project for its auto-saves.
pub const AUTOSAVE_FOLDER: &str = "EffectCraft Auto-Save";
/// Crash-recovery sentinel in the config store.
pub const SENTINEL: &str = "session.lock";
const EXT: &str = "ecproj";
/// Auto-save bookkeeping of a session.
#[derive(Clone, Debug, Default)]
pub struct AutoSaveState {
/// Wall-clock seconds of the last auto-save (or of the first tick).
pub last: Option<f64>,
/// Slot written last in this session.
pub last_slot: Option<u32>,
/// Revision written last (an unchanged project isn't saved again).
pub saved_revision: Option<u64>,
/// Path of the latest auto-save.
pub last_path: Option<String>,
/// This session owns the crash-recovery sentinel ([`crate::Session::begin_recovery`]).
pub sentinel: bool,
}
/// File-name stem of a project (`/a/Intro.ecproj` → `Intro`; untitled → `Untitled Project`).
pub fn project_stem(path: Option<&str>) -> String {
path.and_then(|p| Path::new(p).file_stem()).map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled Project".into())
}
/// `<stem> auto-save N.ecproj`.
pub fn file_name(stem: &str, n: u32) -> String {
format!("{stem} auto-save {n}.{EXT}")
}
/// The slot number of an auto-save file name for `stem`.
pub fn slot_of(stem: &str, name: &str) -> Option<u32> {
let rest = name.strip_prefix(stem)?.strip_prefix(" auto-save ")?;
rest.strip_suffix(&format!(".{EXT}"))?.parse().ok()
}
/// Where a project's auto-saves go: next to the project, or the custom folder; untitled projects
/// use the custom folder or `fallback` (the app's own Auto-Save folder).
pub fn folder(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
let a = &prefs.auto_save;
if a.location == "custom" && !a.folder.trim().is_empty() {
return Some(PathBuf::from(a.folder.trim()));
}
match project.and_then(|p| Path::new(p).parent()) {
Some(dir) => Some(dir.join(AUTOSAVE_FOLDER)),
None => fallback.map(|f| f.join(AUTOSAVE_FOLDER)),
}
}
/// Existing auto-saves of `stem` in `dir`: (slot, path, modified).
pub fn existing(dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
let mut v: Vec<_> = rd
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
let n = slot_of(stem, &name)?;
let m = e.metadata().ok()?.modified().ok()?;
Some((n, e.path(), m))
})
.collect();
v.sort_by_key(|x| x.0);
v
}
/// The slot to write next: after `last` (this session), else after the newest file on disk,
/// wrapping at `max`.
pub fn next_slot(existing: &[(u32, PathBuf, std::time::SystemTime)], last: Option<u32>, max: u32) -> u32 {
let max = max.max(1);
let after = last.or_else(|| existing.iter().filter(|e| e.0 <= max).max_by_key(|e| (e.2, e.0)).map(|e| e.0));
match after {
Some(n) => n % max + 1,
None => 1,
}
}
/// Write one auto-save of `json` for the project at `project` and rotate. Returns its path.
pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option<u32>, json: &str) -> std::io::Result<(PathBuf, u32)> {
let dir = folder(prefs, project, fallback).ok_or_else(|| std::io::Error::other("no auto-save folder for an untitled project"))?;
std::fs::create_dir_all(&dir)?;
let stem = project_stem(project);
let max = prefs.auto_save.max_versions.max(1);
let have = existing(&dir, &stem);
let slot = next_slot(&have, last_slot, max);
let path = dir.join(file_name(&stem, slot));
crate::config::atomic_write(&path, json.as_bytes())?;
// Max versions lowered: drop slots above it.
for (n, p, _) in have {
if n > max {
let _ = std::fs::remove_file(p);
}
}
Ok((path, slot))
}
/// The newest auto-save of a project, if any.
pub fn latest(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
let dir = folder(prefs, project, fallback)?;
existing(&dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
}
/// What the crash-recovery sentinel records.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Sentinel {
pub pid: u32,
/// The open project (None = untitled).
pub project: Option<String>,
/// Latest auto-save of this run.
pub autosave: Option<String>,
/// Whether the project had unsaved changes.
pub dirty: bool,
}
/// A previous run that didn't exit cleanly.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Recovery {
pub project: Option<String>,
/// The auto-save to offer (exists on disk).
pub autosave: Option<String>,
pub dirty: bool,
}
/// At launch: report an unclean previous exit (a leftover sentinel), then start this run's
/// sentinel.
pub fn begin(store: &dyn ConfigStore, prefs: &Prefs) -> Option<Recovery> {
let found = store.read(SENTINEL).map(|t| serde_json::from_str::<Sentinel>(&t).unwrap_or_default());
let _ = store.write(SENTINEL, &serde_json::to_string(&Sentinel { pid: pid(), ..Default::default() }).unwrap_or_default());
let s = found?;
let fallback = store.dir();
let autosave = s
.autosave
.clone()
.filter(|p| Path::new(p).is_file())
.or_else(|| latest(prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
Some(Recovery { project: s.project, autosave, dirty: s.dirty })
}
/// This process id (0 where processes don't exist, e.g. the browser).
pub fn pid() -> u32 {
#[cfg(not(target_arch = "wasm32"))]
return std::process::id();
#[cfg(target_arch = "wasm32")]
0
}
/// Record the current project / latest auto-save in the sentinel.
pub fn update(store: &dyn ConfigStore, s: &Sentinel) {
let _ = store.write(SENTINEL, &serde_json::to_string(s).unwrap_or_default());
}
/// Clean exit: remove the sentinel.
pub fn end(store: &dyn ConfigStore) {
let _ = store.remove(SENTINEL);
}
/// After Effects' Increment and Save name: `Intro.ecproj` → `Intro 2.ecproj`, `Intro 2.ecproj`
/// → `Intro 3.ecproj` (skipping names that exist).
pub fn increment_path(path: &str, exists: impl Fn(&str) -> bool) -> String {
let p = Path::new(path);
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let (base, mut n) = match stem.rsplit_once(' ') {
Some((b, n)) if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) => (b.to_string(), n.parse::<u32>().unwrap_or(1) + 1),
_ => (stem.clone(), 2),
};
loop {
let cand = p.with_file_name(format!("{base} {n}.{EXT}")).to_string_lossy().to_string();
if !exists(&cand) {
return cand;
}
n += 1;
}
}

View File

@ -93,6 +93,10 @@ fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "name").unwrap_or("Comp 1").to_string();
let rate = rate_p(p).unwrap_or(FrameRate::FPS_29_97);
let mut c = Comp::new(1920, 1080, rate, rate.snap_nearest(Tick::from_seconds_f64(10.0)));
// Settings ▸ 3D ▸ Default 3D Renderer.
if s.prefs.three_d.default_renderer == "advanced" {
c.renderer = effectcraft_project::Renderer::Advanced3D;
}
apply_settings(&mut c, p);
let id = s.edit("New Composition", None, |proj, st| {
let mut name = name.clone();

View File

@ -390,7 +390,7 @@ fn split(s: &mut Session, p: &Value) -> Result<Value> {
fn label(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "label").unwrap_or("Red");
let lab = Label::from_name(name).ok_or_else(|| super::bad("edit.label", format!("unknown label `{name}`")))?;
let lab = s.prefs.label_from_name(name).ok_or_else(|| super::bad("edit.label", format!("unknown label `{name}`")))?;
let (cid, ids) = layers_p(s, p)?;
let items = s.state.project_selection.clone();
s.edit("Label", None, |proj, _| {

View File

@ -12,7 +12,17 @@ fn has_path(s: &Session) -> std::result::Result<(), String> {
}
fn new_project(s: &mut Session, _: &Value) -> Result<Value> {
// Settings ▸ Project ▸ New Project Loads Template: open the template as an untitled project.
let tpl = s.prefs.project.template_path.trim().to_string();
if s.prefs.project.use_template && !tpl.is_empty() {
let bytes = s.services.read_file(&tpl).map_err(|e| EngineError::Other(format!("cannot read the new project template {tpl}: {e}")))?;
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("the new project template is not a project file".into()))?;
s.replace_project(Project::from_json(&text)?, None);
s.update_sentinel();
return Ok(json!({"template": tpl}));
}
s.replace_project(Project::default(), None);
s.update_sentinel();
Ok(Value::Null)
}
@ -26,12 +36,13 @@ fn demo(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({"comp": s.state.active_comp.map(|c| c.0)}))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?;
let proj = Project::from_json(&text)?;
s.replace_project(proj, Some(path.to_string()));
s.note_project_path(path);
Ok(json!({"path": path}))
}
@ -40,6 +51,7 @@ fn save_to(s: &mut Session, path: &str) -> Result<Value> {
s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.path = Some(path.to_string());
s.saved_revision = s.revision;
s.note_project_path(path);
s.toast(format!("Saved {path}"));
Ok(json!({"path": path, "bytes": json.len()}))
}
@ -56,12 +68,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
fn increment_save(s: &mut Session, _: &Value) -> Result<Value> {
let cur = s.path.clone().ok_or_else(|| bad("file.incrementAndSave", "save the project first"))?;
let stem = cur.trim_end_matches(".ecproj");
let (base, n) = match stem.rsplit_once('_') {
Some((b, n)) if n.chars().all(|c| c.is_ascii_digit()) && !n.is_empty() => (b.to_string(), n.parse::<u32>().unwrap_or(0) + 1),
_ => (stem.to_string(), 2),
};
save_to(s, &format!("{base}_{n}.ecproj"))
// After Effects: `Intro.aep` → `Intro 2.aep` → `Intro 3.aep`.
let next = crate::autosave::increment_path(&cur, |p| std::path::Path::new(p).exists());
save_to(s, &next)
}
fn revert(s: &mut Session, _: &Value) -> Result<Value> {
@ -79,9 +88,19 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let mut ids = vec![];
let mut errors = vec![];
let mut probed = vec![];
let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps);
for path in &paths {
match importer.probe(path) {
Ok(f) => probed.push((path.clone(), f)),
Ok(mut f) => {
// Settings ▸ Import ▸ Sequence Footage frames per second.
if f.kind == FootageKind::Sequence {
let r = seq_rate;
let frames = f.frame_rate.frame_at(f.duration);
f.frame_rate = r;
f.duration = r.tick_of(frames.max(1));
}
probed.push((path.clone(), f))
}
Err(e) => errors.push(format!("{path}: {e}")),
}
}

View File

@ -24,6 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{page?: general|startup|project|composition|previews|appearance|grids|labels|type|import|export|audio|disk|memory|video|3d|scripting}",
always
),
fe!("app.gpuInfo", "GPU Information...", [], None, "{}", always),
fe!("app.hide", "Hide EffectCraft", [], None, "{}", always),
fe!("app.quit", "Quit EffectCraft", [], Some("Cmd+Q"), "{}", always),
fe!("app.commandPalette", "Quick Apply...", ["Edit"], Some("Cmd+Shift+Space"), "{query?}", always),

View File

@ -252,12 +252,19 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
let it = s.project.item(item).ok_or_else(|| bad("layer.addItem", "no such item"))?.clone();
let (src, size, dur) = match &it.kind {
ItemKind::Comp(c) => (LayerSource::Comp { item }, (c.width, c.height), Some(c.duration)),
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), (f.kind != effectcraft_project::FootageKind::Still).then_some(f.duration)),
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Still => {
// Settings ▸ Import ▸ Still Footage: length of the composition or a duration.
let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds));
(LayerSource::Footage { item }, (f.width, f.height), d)
}
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)),
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),
};
let fr = comp.frame_rate;
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO));
// Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time).
let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() };
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(default_start));
let id = s.edit("Add Footage to Comp", None, |proj, st| {
let mut l = build::layer(proj, &comp, &it.name, src, size, dur);
l.start_time = start;

View File

@ -21,6 +21,7 @@ mod prop;
pub mod puppet;
mod query;
mod render_queue;
mod settings;
mod stubs;
mod styles;
mod text_anim;
@ -108,6 +109,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(track::specs());
v.extend(paint::specs());
v.extend(puppet::specs());
v.extend(settings::specs());
v.extend(stubs::specs());
v
})

View File

@ -30,11 +30,23 @@ fn with_prop<T>(
f: impl FnOnce(&mut Property, Tick) -> Result<T>,
) -> Result<T> {
let t = s.time();
let linear = s.prefs.general.default_spatial_linear;
s.edit(label, merge, |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let pr = l.props.find_mut(uid).ok_or_else(|| bad("prop", "property vanished"))?;
f(pr, lt)
let before: Vec<Tick> = pr.keys.iter().map(|k| k.time).collect();
let r = f(pr, lt)?;
// Settings ▸ General ▸ Default Spatial Interpolation to Linear: new keys of spatial
// properties get straight motion paths instead of Auto Bezier.
if linear && pr.spatial {
for k in pr.keys.iter_mut().filter(|k| !before.contains(&k.time)) {
k.spatial_auto = false;
k.spatial_in = [0.0; 3];
k.spatial_out = [0.0; 3];
}
}
Ok(r)
})
}

View File

@ -344,6 +344,14 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
fn render(s: &mut Session, p: &Value) -> Result<Value> {
let wait = b_p(p, "wait").unwrap_or(true);
// Settings ▸ Project ▸ Auto-Save ▸ Save When Starting Render Queue.
if s.prefs.auto_save.enabled
&& s.prefs.auto_save.save_on_render_start
&& s.is_dirty()
&& let Err(e) = s.autosave_now()
{
log::warn!("{e}");
}
let ids = s.start_render(wait).map_err(EngineError::Other)?;
let items: Vec<Value> = s.project.render_queue.iter().enumerate().filter(|(_, i)| ids.contains(&i.id)).map(|(k, i)| item_json(s, i, k)).collect();
Ok(json!({"rendering": s.is_rendering(), "items": items}))

View File

@ -0,0 +1,278 @@
//! Settings (`prefs.*`), keyboard shortcut presets (`shortcuts.*`), File ▸ Open Recent and
//! auto-save commands.
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, str_p};
use crate::shortcuts::{DEFAULT_PRESET, binding_key, normalize};
use crate::{EngineError, Result, Session, cmd, query};
// ---------------------------------------------------------------- prefs
fn prefs_get(s: &mut Session, p: &Value) -> Result<Value> {
let key = str_p(p, "key").unwrap_or("");
s.prefs.get(key).ok_or_else(|| bad("prefs.get", format!("unknown setting `{key}`")))
}
fn prefs_set(s: &mut Session, p: &Value) -> Result<Value> {
let mut pairs: Vec<(String, Value)> = vec![];
if let Some(Value::Object(m)) = p.get("values") {
pairs.extend(m.iter().map(|(k, v)| (k.clone(), v.clone())));
}
if let Some(k) = str_p(p, "key") {
let v = p.get("value").cloned().ok_or_else(|| bad("prefs.set", "missing `value`"))?;
pairs.push((k.to_string(), v));
}
if pairs.is_empty() {
return Err(bad("prefs.set", "give `key` and `value` (or `values: {key: value}`)"));
}
let mut next = s.prefs.clone();
for (k, v) in &pairs {
next.set(k, v.clone()).map_err(|e| bad("prefs.set", e))?;
}
s.prefs = next;
s.prefs_changed();
s.save_prefs();
let out: serde_json::Map<String, Value> = pairs.iter().map(|(k, _)| (k.clone(), s.prefs.get(k).unwrap_or(Value::Null))).collect();
Ok(Value::Object(out))
}
fn prefs_reset(s: &mut Session, p: &Value) -> Result<Value> {
let page = str_p(p, "page");
s.prefs.reset(page).map_err(|e| bad("prefs.reset", e))?;
s.prefs_changed();
s.save_prefs();
s.toast(match page {
Some(pg) => format!("Reset {pg} settings"),
None => "Reset all settings".into(),
});
Ok(json!({"reset": page.unwrap_or("all")}))
}
fn prefs_open(s: &mut Session, p: &Value) -> Result<Value> {
let page = str_p(p, "page").unwrap_or("general");
let id = crate::prefs::page_id(page).ok_or_else(|| bad("prefs.open", format!("unknown settings page `{page}`")))?;
super::frontend(s, "app.settings", &json!({"page": id}))
}
fn prefs_pages(_: &mut Session, _: &Value) -> Result<Value> {
Ok(crate::prefs::pages_json())
}
// ---------------------------------------------------------------- shortcuts
/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`).
fn bindable(s: &Session, p: &Value, cmd: &str) -> Result<crate::shortcuts::Bindable> {
let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?;
let params = p.get("params").cloned().unwrap_or(Value::Null);
let t = s.shortcuts();
let key = binding_key(c, &params);
t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)")))
}
fn keys_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
Ok(match p.get("keys") {
None | Some(Value::Null) => vec![],
Some(Value::String(k)) if k.trim().is_empty() => vec![],
Some(Value::String(k)) => vec![k.clone()],
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
Some(v) => return Err(bad(cmd, format!("`keys` must be a string or a list, not {v}"))),
})
}
fn sc_list(s: &mut Session, p: &Value) -> Result<Value> {
let q = str_p(p, "query").unwrap_or("").to_lowercase();
let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false);
let keys_q = str_p(p, "keys").and_then(normalize);
let t = s.shortcuts();
let rows: Vec<Value> = t
.bindables
.iter()
.filter_map(|b| {
let keys = t.keys.get(&b.key).cloned().unwrap_or_default();
if assigned && keys.is_empty() {
return None;
}
if let Some(k) = &keys_q
&& !keys.contains(k)
{
return None;
}
let hay = format!("{} {} {} {}", b.label, b.command, b.path.join(" "), keys.join(" ")).to_lowercase();
if !q.split_whitespace().all(|w| hay.contains(w)) {
return None;
}
let mut o = serde_json::to_value(b).unwrap_or_default();
o["keys"] = json!(keys);
Some(o)
})
.collect();
Ok(json!({"preset": s.keymaps.active, "presets": s.keymaps.names(), "count": rows.len(), "commands": rows}))
}
fn sc_set(s: &mut Session, p: &Value) -> Result<Value> {
let b = bindable(s, p, "shortcuts.set")?;
let keys = keys_param(p, "shortcuts.set")?;
let conflicts: Vec<_> = keys.iter().flat_map(|k| s.shortcuts().conflicts(&b, k)).collect();
let created = s.keymaps.set(&b, &keys).map_err(|e| bad("shortcuts.set", e))?;
s.shortcuts_changed();
let now = s.shortcuts().keys.get(&b.key).cloned().unwrap_or_default();
if let Some(n) = &created {
s.toast(format!("The default shortcuts can't be changed: created the preset \"{n}\""));
}
if !conflicts.is_empty() {
let names: Vec<String> = conflicts.iter().map(|c| format!("{} ({})", c.label, c.keys)).collect();
s.toast(format!("Shortcut conflict: also used by {}", names.join(", ")));
}
Ok(json!({"command": b.key, "keys": now, "preset": s.keymaps.active, "created": created, "conflicts": conflicts}))
}
fn sc_reset(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(c) = str_p(p, "command") {
let b = bindable(s, p, "shortcuts.reset")?;
let defaults = b.defaults.clone();
s.keymaps.set(&b, &defaults).map_err(|e| bad("shortcuts.reset", e))?;
s.shortcuts_changed();
return Ok(json!({"command": c, "keys": defaults}));
}
s.keymaps.reset_active();
s.shortcuts_changed();
Ok(json!({"preset": s.keymaps.active}))
}
fn sc_preset(s: &mut Session, p: &Value) -> Result<Value> {
let op = str_p(p, "op").unwrap_or("select");
let name = str_p(p, "name");
let e = |m: String| bad("shortcuts.preset", m);
let k = &mut s.keymaps;
match op {
"select" => k.select(name.unwrap_or(DEFAULT_PRESET)).map_err(e)?,
"new" => {
k.duplicate(DEFAULT_PRESET, Some(name.unwrap_or("Custom"))).map_err(e)?;
}
"duplicate" => {
let from = str_p(p, "from").map(str::to_string).unwrap_or_else(|| k.active.clone());
k.duplicate(&from, name).map_err(e)?;
}
"delete" => {
let n = name.map(str::to_string).unwrap_or_else(|| k.active.clone());
k.delete(&n).map_err(e)?;
}
"rename" => {
let to = str_p(p, "newName").ok_or_else(|| bad("shortcuts.preset", "missing `newName`"))?;
let from = name.map(str::to_string).unwrap_or_else(|| k.active.clone());
k.rename(&from, to).map_err(e)?;
}
_ => return Err(bad("shortcuts.preset", "op must be select, new, duplicate, delete or rename")),
}
s.shortcuts_changed();
Ok(json!({"preset": s.keymaps.active, "presets": s.keymaps.names()}))
}
fn sc_export(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.keymaps.export(str_p(p, "preset")).map_err(|e| bad("shortcuts.export", e))?;
if let Some(path) = str_p(p, "path") {
let text = serde_json::to_string_pretty(&doc).unwrap_or_default();
s.services.write_file(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
return Ok(json!({"path": path, "preset": doc["name"]}));
}
Ok(doc)
}
fn sc_import(s: &mut Session, p: &Value) -> Result<Value> {
let doc = match (str_p(p, "path"), p.get("preset")) {
(Some(path), _) => {
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
serde_json::from_slice(&bytes).map_err(|e| bad("shortcuts.import", format!("{path}: {e}")))?
}
(None, Some(v)) if v.is_object() => v.clone(),
_ => return Err(bad("shortcuts.import", "give `path` or `preset` (an exported document)")),
};
let name = s.keymaps.import(&doc, str_p(p, "name")).map_err(|e| bad("shortcuts.import", e))?;
s.shortcuts_changed();
Ok(json!({"preset": name}))
}
fn sc_conflicts(s: &mut Session, _: &Value) -> Result<Value> {
let c: Vec<Value> =
s.shortcuts().all_conflicts().into_iter().map(|(a, c)| json!({"command": a, "keys": c.keys, "with": c.key, "scope": c.scope})).collect();
Ok(json!(c))
}
// ---------------------------------------------------------------- recent / auto-save
fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
let path = match (str_p(p, "path"), p.get("index").and_then(Value::as_u64)) {
(Some(path), _) => path.to_string(),
(None, Some(i)) => s.prefs.recent_projects.get(i as usize).cloned().ok_or_else(|| bad("file.openRecent", format!("no recent project #{i}")))?,
_ => s.prefs.recent_projects.first().cloned().ok_or_else(|| bad("file.openRecent", "no recent projects"))?,
};
match super::file::open(s, &json!({"path": path})) {
Ok(v) => Ok(v),
Err(e) => {
// A project that's gone drops out of the list.
if !std::path::Path::new(&path).exists() {
s.prefs.recent_projects.retain(|x| *x != path);
s.save_prefs();
}
Err(e)
}
}
}
fn clear_recent(s: &mut Session, _: &Value) -> Result<Value> {
s.prefs.recent_projects.clear();
s.save_prefs();
s.prefs_revision += 1;
Ok(Value::Null)
}
fn has_recent(s: &Session) -> std::result::Result<(), String> {
if s.prefs.recent_projects.is_empty() { Err("no recent projects".into()) } else { Ok(()) }
}
fn auto_save(s: &mut Session, _: &Value) -> Result<Value> {
let path = s.autosave_now()?;
s.toast(format!("Auto-saved to {path}"));
Ok(json!({"path": path}))
}
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
let root = s.default_autosave_root();
let latest = crate::autosave::latest(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
Ok(json!({
"lastAutoSave": s.autosave.last_path,
"latestOnDisk": latest,
"folder": crate::autosave::folder(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()),
"recentProjects": s.prefs.recent_projects,
}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("prefs.get", "Get Setting", "{key?: e.g. general.undoLevels (omit for all settings)}", prefs_get),
cmd!("prefs.set", "Change Setting", [], None, "{key, value, values?: {key: value}}", always, prefs_set),
cmd!("prefs.reset", "Reset Settings", [], None, "{page?: general|labels|project|…}", always, prefs_reset),
cmd!(
"prefs.open",
"Open Settings",
[],
None,
"{page?: general|startup|project|composition|previews|appearance|grids|labels|type|import|export|audio|disk|memory|video|3d|scripting}",
always,
prefs_open
),
query!("prefs.pages", "Settings Pages", "{}", prefs_pages),
query!("shortcuts.list", "List Keyboard Shortcuts", "{query?, keys?, assigned?: bool}", sc_list),
cmd!("shortcuts.set", "Set Keyboard Shortcut", [], None, "{command, params?, keys: \"Cmd+Shift+K\" | [keys] | null}", always, sc_set),
cmd!("shortcuts.reset", "Reset Keyboard Shortcuts", [], None, "{command?, params?}", always, sc_reset),
cmd!("shortcuts.preset", "Keyboard Shortcut Preset", [], None, "{op: select|new|duplicate|delete|rename, name?, from?, newName?}", always, sc_preset),
cmd!("shortcuts.export", "Export Keyboard Shortcuts", [], None, "{preset?, path?}", always, sc_export),
cmd!("shortcuts.import", "Import Keyboard Shortcuts", [], None, "{path? | preset?: exported document, name?}", always, sc_import),
query!("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "{}", sc_conflicts),
cmd!("file.openRecent", "Open Recent", [], None, "{index? | path?}", has_recent, open_recent),
cmd!("file.clearRecent", "Clear Recent Projects", ["File", "Open Recent"], None, "{}", has_recent, clear_recent),
cmd!("file.autoSave", "Auto-Save Now", [], None, "{}", always, auto_save),
query!("file.recoveryInfo", "Auto-Save Status", "{}", recovery_info),
]
}

104
crates/engine/src/config.rs Normal file
View File

@ -0,0 +1,104 @@
//! Persistent configuration storage (Settings, keyboard shortcut presets, the crash-recovery
//! sentinel). The engine only sees the [`ConfigStore`] trait; frontends choose where it lives:
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app can back it
//! with `localStorage`, tests and headless runs use [`MemoryConfig`] (or none at all).
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
/// Named text blobs (`prefs.json`, `shortcuts.json`, `session.lock`).
pub trait ConfigStore: Send + Sync {
fn read(&self, name: &str) -> Option<String>;
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
fn remove(&self, name: &str) -> std::io::Result<()>;
/// A directory on disk next to the configuration (default Auto-Save folder for untitled
/// projects); `None` for stores that aren't on the file system.
fn dir(&self) -> Option<PathBuf> {
None
}
}
/// In-memory store (tests, headless sessions).
#[derive(Default)]
pub struct MemoryConfig {
pub files: Mutex<BTreeMap<String, String>>,
}
impl ConfigStore for MemoryConfig {
fn read(&self, name: &str) -> Option<String> {
self.files.lock().ok()?.get(name).cloned()
}
fn write(&self, name: &str, data: &str) -> std::io::Result<()> {
if let Ok(mut m) = self.files.lock() {
m.insert(name.to_string(), data.to_string());
}
Ok(())
}
fn remove(&self, name: &str) -> std::io::Result<()> {
if let Ok(mut m) = self.files.lock() {
m.remove(name);
}
Ok(())
}
}
/// Files in one directory, written atomically.
pub struct DirConfig {
pub dir: PathBuf,
}
impl DirConfig {
pub fn new(dir: impl Into<PathBuf>) -> DirConfig {
DirConfig { dir: dir.into() }
}
}
impl ConfigStore for DirConfig {
fn read(&self, name: &str) -> Option<String> {
std::fs::read_to_string(self.dir.join(name)).ok()
}
fn write(&self, name: &str, data: &str) -> std::io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
atomic_write(&self.dir.join(name), data.as_bytes())
}
fn remove(&self, name: &str) -> std::io::Result<()> {
match std::fs::remove_file(self.dir.join(name)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
}
fn dir(&self) -> Option<PathBuf> {
Some(self.dir.clone())
}
}
/// The temporary file an atomic write of `path` goes through.
pub fn temp_path(path: &Path) -> PathBuf {
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
name.push(".tmp");
path.with_file_name(name)
}
/// Write `data` to `path` atomically: write a temporary file next to it, flush it to disk, then
/// rename it over `path`. A crash at any point leaves either the old file or the new one, never a
/// torn file.
pub fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
atomic_write_with(path, data, |tmp, data| {
use std::io::Write;
let mut f = std::fs::File::create(tmp)?;
f.write_all(data)?;
f.sync_all()
})
}
/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write). A
/// failed write removes the temporary file and leaves `path` untouched.
pub fn atomic_write_with(path: &Path, data: &[u8], write: impl FnOnce(&Path, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> {
let tmp = temp_path(path);
if let Err(e) = write(&tmp, data) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
std::fs::rename(&tmp, path)
}

View File

@ -8,11 +8,16 @@
//! The project is an `Arc<Project>` edited copy-on-write; undo keeps whole-project snapshots
//! (compositions are `Arc`s, so untouched comps are shared).
pub mod autosave;
pub mod commands;
pub mod config;
pub mod demo;
pub mod links;
pub mod menus;
pub mod prefs;
pub mod render_queue;
mod session_settings;
pub mod shortcuts;
pub mod tracking;
use std::sync::Arc;
@ -65,10 +70,7 @@ impl Services for FsServices {
std::fs::read(path)
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
let p = std::path::Path::new(path);
let tmp = p.with_extension("ecproj.tmp");
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, p)
config::atomic_write(std::path::Path::new(path), data)
}
}
@ -224,6 +226,21 @@ pub struct Session {
pub journal: Vec<(String, Value)>,
/// Processed-layer pixels reused across frames and edits (content-keyed, never stale).
pub layer_cache: Arc<LayerCache>,
/// Settings (Preferences).
pub prefs: prefs::Prefs,
/// Bumped whenever settings change (frontends re-apply theme, labels…).
pub prefs_revision: u64,
/// Where settings, shortcut presets and the crash-recovery sentinel are stored (`None` =
/// nothing persists).
pub config: Option<Arc<dyn config::ConfigStore>>,
/// Keyboard shortcut presets.
pub keymaps: shortcuts::Keymaps,
/// Frontend-only commands offered for binding.
pub ui_commands: Vec<shortcuts::UiCommand>,
/// Cache of the resolved active preset (read it with [`Session::shortcuts`]).
pub shortcut_table: std::sync::OnceLock<shortcuts::ShortcutTable>,
/// Auto-save bookkeeping.
pub autosave: autosave::AutoSaveState,
}
impl Default for Session {
@ -246,6 +263,13 @@ impl Default for Session {
events: vec![],
journal: vec![],
layer_cache: Arc::new(LayerCache::default()),
prefs: prefs::Prefs::default(),
prefs_revision: 0,
config: None,
keymaps: shortcuts::Keymaps::default(),
ui_commands: vec![],
shortcut_table: std::sync::OnceLock::new(),
autosave: autosave::AutoSaveState::default(),
}
}
}
@ -298,8 +322,10 @@ impl Session {
let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key;
if !same {
self.history.undo.push((label.to_string(), before));
if self.history.undo.len() > 500 {
self.history.undo.remove(0);
let levels = self.prefs.general.undo_levels.max(1) as usize;
if self.history.undo.len() > levels {
let extra = self.history.undo.len() - levels;
self.history.undo.drain(..extra);
}
}
self.history.merge_key = merge.map(str::to_string);
@ -483,6 +509,8 @@ mod tests_effects;
#[cfg(test)]
mod tests_menu_cmds;
#[cfg(test)]
mod tests_settings;
#[cfg(test)]
mod tests_styles;
#[cfg(test)]
mod tests_text;

View File

@ -93,6 +93,17 @@ pub fn entries() -> Vec<(Vec<String>, &'static MenuEntry)> {
out
}
/// Display label of an entry: label names come from Settings ▸ Labels.
pub fn entry_label(s: &Session, e: &MenuEntry) -> String {
match e.command.as_str() {
"edit.label" => match e.params.get("label").and_then(Value::as_str).and_then(effectcraft_color::Label::from_name) {
Some(l) if l != effectcraft_color::Label::None => s.prefs.label_name(l),
_ => e.label.clone(),
},
_ => e.label.clone(),
}
}
/// Check-mark state of an entry (`None` = not a toggle / radio entry). Frontend-only toggles
/// (rulers, grid…) are answered by the frontend.
pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
@ -292,6 +303,8 @@ File
New Project | file.newProject
New Folder | project.newFolder
Open Project... | file.open
Open Recent
Clear Recent Projects | file.clearRecent
---
Close | file.close
Close Project | file.closeProject

951
crates/engine/src/prefs.rs Normal file
View File

@ -0,0 +1,951 @@
//! Settings (After Effects' Preferences): a versioned serde model, addressed by dotted keys
//! (`general.undoLevels`, `autoSave.intervalMinutes`, `labels.3.name`), plus the page schema the
//! Settings dialog and agents (`prefs.pages`) are built from.
//!
//! Settings are stored as JSON (`prefs.json`) through the session's [`crate::config::ConfigStore`].
//! Loading is forgiving: missing keys take their defaults, unknown keys (written by a newer
//! version) are kept and written back, and older layouts are migrated ([`migrate`]).
//!
//! Every setting in [`pages`] says whether it already changes behaviour (`live`). Settings that
//! don't yet are still shown (After Effects parity of the dialog) and are listed in
//! `docs/preferences.md`; a test keeps that list in step with the schema.
use std::collections::BTreeMap;
use effectcraft_color::Label;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
/// Current settings layout version.
pub const PREFS_VERSION: u32 = 2;
/// Settings file name in the config store.
pub const PREFS_FILE: &str = "prefs.json";
/// Unknown keys inside a page, kept so a newer version's settings survive a round trip.
type Extra = BTreeMap<String, Value>;
macro_rules! page {
($(#[$m:meta])* $name:ident { $($(#[$fm:meta])* $f:ident : $t:ty = $d:expr),* $(,)? }) => {
$(#[$m])*
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct $name {
$($(#[$fm])* pub $f: $t,)*
#[serde(flatten)]
pub extra: Extra,
}
impl Default for $name {
fn default() -> Self {
$name { $($f: $d,)* extra: Extra::new() }
}
}
};
}
page!(General {
/// Levels of Undo (1–99).
undo_levels: u32 = 32,
/// Path Point and Handle Size (px).
path_point_size: u32 = 5,
show_tool_tips: bool = true,
/// Create New Layers at Composition Start Time (off: at the current time).
create_layers_at_comp_start: bool = true,
switches_affect_nested_comps: bool = true,
/// Default Spatial Interpolation to Linear.
default_spatial_linear: bool = false,
preserve_constant_vertex_count: bool = true,
/// Synchronize Time of All Related Items.
sync_time_related_items: bool = true,
expression_pick_whip_compact: bool = true,
create_split_layers_above: bool = false,
use_system_color_picker: bool = false,
/// Number of projects in File ▸ Open Recent.
recent_items: u32 = 10,
});
page!(Startup {
show_home_on_launch: bool = true,
show_home_on_open_project: bool = false,
/// After a crash, offer to open the most recent auto-save.
offer_crash_recovery: bool = true,
});
page!(ProjectPrefs {
/// Open a template project for File ▸ New ▸ New Project.
use_template: bool = false,
template_path: String = String::new(),
});
page!(AutoSave {
enabled: bool = true,
interval_minutes: u32 = 20,
max_versions: u32 = 5,
/// `nextToProject` (an "EffectCraft Auto-Save" folder beside the project) or `custom`.
location: String = "nextToProject".into(),
folder: String = String::new(),
save_on_render_start: bool = false,
});
page!(CompositionPrefs {
/// Motion path: `all`, `none`, `seconds`, `keyframes`.
motion_path: String = "keyframes".into(),
motion_path_seconds: f64 = 15.0,
motion_path_keyframes: u32 = 15,
show_rendering_progress: bool = true,
hardware_accelerate_panels: bool = true,
});
page!(Previews {
/// Largest downsampling while playing back: `1/2`, `1/4`, `1/8`.
adaptive_resolution_limit: String = "1/8".into(),
show_internal_wireframes: bool = false,
cache_frames_when_idle: bool = false,
fast_previews: bool = false,
/// `faster` or `moreAccurate`.
zoom_quality: String = "moreAccurate".into(),
show_gpu_info: bool = false,
});
page!(Appearance {
/// `dark`, `darker` or `light`.
theme: String = "dark".into(),
/// User interface brightness, -1 (darker) … 1 (lighter).
brightness: f64 = 0.0,
use_label_color_for_handles: bool = true,
use_label_color_for_tabs: bool = true,
cycle_mask_colors: bool = true,
use_gradients: bool = true,
});
page!(Grids {
grid_color: String = "#7a9cff".into(),
grid_spacing: f64 = 100.0,
grid_subdivisions: u32 = 4,
/// `lines`, `dashed` or `dots`.
grid_style: String = "lines".into(),
proportional_horizontal: u32 = 4,
proportional_vertical: u32 = 4,
guide_color: String = "#3cc8f0".into(),
guide_style: String = "lines".into(),
action_safe: f64 = 10.0,
title_safe: f64 = 20.0,
});
page!(TypePrefs {
/// `latin` or `southAsian` (South Asian and Middle Eastern).
text_engine: String = "latin".into(),
font_preview: bool = true,
recent_fonts: u32 = 10,
font_names_in_english: bool = false,
});
page!(Import {
/// Still footage duration: `compLength` or `seconds`.
still_footage: String = "compLength".into(),
still_seconds: f64 = 5.0,
sequence_fps: f64 = 30.0,
report_missing_frames: bool = true,
/// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`.
unlabeled_alpha: String = "ask".into(),
/// Default drag import as: `footage`, `comp`, `compLayerSizes`.
drag_import_as: String = "footage".into(),
});
page!(Export {
default_output_folder: String = String::new(),
segment_sequences: bool = false,
segment_sequence_files: u32 = 700,
segment_movies: bool = false,
segment_movie_mb: u32 = 1024,
append_bits_to_name: bool = false,
});
page!(Audio {
/// Output device name (empty = the system default).
output_device: String = String::new(),
/// Output mapping: device channel for the left / right output (1-based).
output_left: u32 = 1,
output_right: u32 = 2,
preview_sample_rate: u32 = 48_000,
});
page!(Disk {
disk_cache_enabled: bool = true,
disk_cache_max_gb: u32 = 100,
disk_cache_folder: String = String::new(),
media_cache_folder: String = String::new(),
conformed_media_folder: String = String::new(),
});
page!(Memory {
ram_reserved_gb: u32 = 6,
/// Processed-layer cache budget (MB).
layer_cache_mb: u32 = 1024,
/// Decoded footage frame cache budget (MB).
media_cache_mb: u32 = 1024,
/// RAM preview (viewer frame) cache budget (MB).
preview_cache_mb: u32 = 3072,
reduce_cache_when_low: bool = true,
});
page!(Video {
enable_output: bool = false,
device: String = String::new(),
output_during_playback: bool = true,
mirror_on_monitor: bool = true,
disable_when_background: bool = false,
});
page!(ThreeD {
/// Default 3D renderer for new compositions: `classic` or `advanced`.
default_renderer: String = "classic".into(),
show_reference_axes: bool = true,
extended_viewer: bool = false,
realtime_shadows: bool = true,
});
page!(Scripting {
allow_scripts_write_files: bool = false,
warn_executing_files: bool = true,
enable_js_debugger: bool = false,
editor_font_size: u32 = 13,
syntax_highlighting: bool = true,
line_numbers: bool = true,
auto_complete: bool = true,
bracket_matching: bool = true,
word_wrap: bool = false,
error_banner: bool = true,
});
/// One editable label: name and `#rrggbb` colour.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LabelPref {
pub name: String,
pub color: String,
}
fn default_labels() -> Vec<LabelPref> {
Label::ALL.iter().skip(1).map(|l| LabelPref { name: l.name().to_string(), color: hex(l.rgb()) }).collect()
}
fn hex([r, g, b]: [u8; 3]) -> String {
format!("#{r:02x}{g:02x}{b:02x}")
}
fn parse_hex(s: &str) -> Option<[u8; 3]> {
let h = s.trim().trim_start_matches('#');
if h.len() != 6 {
return None;
}
let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
Some([p(0)?, p(2)?, p(4)?])
}
/// All settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Prefs {
pub version: u32,
pub general: General,
pub startup: Startup,
pub project: ProjectPrefs,
pub auto_save: AutoSave,
pub composition: CompositionPrefs,
pub previews: Previews,
pub appearance: Appearance,
pub grids: Grids,
/// The 16 labels (Red … Dark Green), in order.
pub labels: Vec<LabelPref>,
#[serde(rename = "type")]
pub type_: TypePrefs,
pub import: Import,
pub export: Export,
pub audio: Audio,
pub disk: Disk,
pub memory: Memory,
pub video: Video,
#[serde(rename = "threeD")]
pub three_d: ThreeD,
pub scripting: Scripting,
/// File ▸ Open Recent, newest first.
pub recent_projects: Vec<String>,
/// Top-level keys this version doesn't know (kept for newer versions).
#[serde(flatten)]
pub extra: Extra,
}
impl Default for Prefs {
fn default() -> Self {
Prefs {
version: PREFS_VERSION,
general: General::default(),
startup: Startup::default(),
project: ProjectPrefs::default(),
auto_save: AutoSave::default(),
composition: CompositionPrefs::default(),
previews: Previews::default(),
appearance: Appearance::default(),
grids: Grids::default(),
labels: default_labels(),
type_: TypePrefs::default(),
import: Import::default(),
export: Export::default(),
audio: Audio::default(),
disk: Disk::default(),
memory: Memory::default(),
video: Video::default(),
three_d: ThreeD::default(),
scripting: Scripting::default(),
recent_projects: vec![],
extra: Extra::new(),
}
}
}
/// Bring an older settings document up to [`PREFS_VERSION`].
///
/// - version 0/1 (early builds) kept a flat object: `undoLevels`, `autoSaveEnabled`,
/// `autoSaveMinutes`, `autoSaveVersions`, `labelNames` (array) and `theme` at the top level.
pub fn migrate(mut v: Value) -> Value {
let Some(obj) = v.as_object_mut() else { return json!({}) };
let version = obj.get("version").and_then(Value::as_u64).unwrap_or(0);
if version < 2 {
let moved = |from: &str, page: &str, key: &str, obj: &mut Map<String, Value>| {
if let Some(x) = obj.remove(from) {
let p = obj.entry(page.to_string()).or_insert_with(|| json!({}));
if let Some(p) = p.as_object_mut() {
p.entry(key.to_string()).or_insert(x);
}
}
};
moved("undoLevels", "general", "undoLevels", obj);
moved("autoSaveEnabled", "autoSave", "enabled", obj);
moved("autoSaveMinutes", "autoSave", "intervalMinutes", obj);
moved("autoSaveVersions", "autoSave", "maxVersions", obj);
moved("theme", "appearance", "theme", obj);
if let Some(Value::Array(names)) = obj.remove("labelNames") {
let mut labels = default_labels();
for (l, n) in labels.iter_mut().zip(names) {
if let Some(n) = n.as_str() {
l.name = n.to_string();
}
}
obj.insert("labels".into(), serde_json::to_value(labels).unwrap_or_default());
}
}
obj.insert("version".into(), json!(PREFS_VERSION));
v
}
impl Prefs {
/// Parse a settings document (migrating older layouts; unknown keys are kept, bad values
/// fall back to defaults).
pub fn from_json(text: &str) -> Prefs {
let v: Value = serde_json::from_str(text).unwrap_or_else(|_| json!({}));
let v = migrate(v);
let mut p: Prefs = match serde_json::from_value(v.clone()) {
Ok(p) => p,
// One bad value shouldn't lose everything: take each page that parses.
Err(_) => {
let mut base = serde_json::to_value(Prefs::default()).unwrap_or_default();
if let (Some(b), Some(o)) = (base.as_object_mut(), v.as_object()) {
for (k, x) in o {
let mut trial = b.clone();
trial.insert(k.clone(), x.clone());
if serde_json::from_value::<Prefs>(Value::Object(trial)).is_ok() {
b.insert(k.clone(), x.clone());
}
}
}
serde_json::from_value(base).unwrap_or_default()
}
};
p.normalize();
p
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
/// Clamp values into their valid ranges and fill missing labels.
pub fn normalize(&mut self) {
self.version = PREFS_VERSION;
let g = &mut self.general;
g.undo_levels = g.undo_levels.clamp(1, 99);
g.path_point_size = g.path_point_size.clamp(3, 20);
g.recent_items = g.recent_items.clamp(1, 30);
let a = &mut self.auto_save;
a.interval_minutes = a.interval_minutes.clamp(1, 240);
a.max_versions = a.max_versions.clamp(1, 99);
self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0);
let defaults = default_labels();
self.labels.truncate(defaults.len());
for d in defaults.iter().skip(self.labels.len()) {
self.labels.push(d.clone());
}
for (l, d) in self.labels.iter_mut().zip(&defaults) {
if parse_hex(&l.color).is_none() {
l.color = d.color.clone();
}
}
let m = &mut self.memory;
m.layer_cache_mb = m.layer_cache_mb.clamp(64, 1 << 20);
m.media_cache_mb = m.media_cache_mb.clamp(64, 1 << 20);
m.preview_cache_mb = m.preview_cache_mb.clamp(64, 1 << 20);
self.grids.grid_spacing = self.grids.grid_spacing.clamp(1.0, 10_000.0);
let n = self.general.recent_items as usize;
self.recent_projects.truncate(n);
}
/// The value at a dotted key (`general.undoLevels`, `labels.3.color`); `""` = everything.
pub fn get(&self, key: &str) -> Option<Value> {
let v = serde_json::to_value(self).ok()?;
if key.is_empty() {
return Some(v);
}
let ptr = format!("/{}", key.replace('.', "/"));
v.pointer(&ptr).cloned()
}
/// Set the value at a dotted key. The key must exist and the value must have its type.
pub fn set(&mut self, key: &str, value: Value) -> Result<(), String> {
let mut v = serde_json::to_value(&*self).map_err(|e| e.to_string())?;
let ptr = format!("/{}", key.replace('.', "/"));
let slot = v.pointer_mut(&ptr).ok_or_else(|| format!("unknown setting `{key}`"))?;
let value = coerce(slot, value).ok_or_else(|| format!("`{key}` expects a {}", kind_name(slot)))?;
*slot = value;
let mut next: Prefs = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?;
next.normalize();
*self = next;
Ok(())
}
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent is
/// kept.
pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> {
let d = Prefs::default();
let Some(page) = page else {
*self = Prefs { recent_projects: std::mem::take(&mut self.recent_projects), ..d };
return Ok(());
};
let keys: Vec<&str> = match page {
"project" => vec!["project", "autoSave"],
p => vec![section_key(p).ok_or_else(|| format!("unknown settings page `{p}`"))?],
};
let dv = serde_json::to_value(&d).map_err(|e| e.to_string())?;
for k in keys {
if let Some(x) = dv.get(k) {
self.set(k, x.clone())?;
}
}
Ok(())
}
/// Display name of a label (Labels settings).
pub fn label_name(&self, l: Label) -> String {
match label_index(l) {
Some(i) => self.labels.get(i).map(|p| p.name.clone()).unwrap_or_else(|| l.name().to_string()),
None => l.name().to_string(),
}
}
/// sRGB colour of a label (Labels settings).
pub fn label_rgb(&self, l: Label) -> [u8; 3] {
label_index(l).and_then(|i| self.labels.get(i)).and_then(|p| parse_hex(&p.color)).unwrap_or(l.rgb())
}
/// A label by its built-in name or its (renamed) display name.
pub fn label_from_name(&self, s: &str) -> Option<Label> {
Label::from_name(s).or_else(|| Label::ALL.iter().skip(1).copied().find(|l| self.label_name(*l).eq_ignore_ascii_case(s)))
}
/// Remember a project in File ▸ Open Recent.
pub fn push_recent(&mut self, path: &str) {
self.recent_projects.retain(|p| p != path);
self.recent_projects.insert(0, path.to_string());
self.recent_projects.truncate(self.general.recent_items as usize);
}
/// Layer / media / preview cache budgets in bytes.
pub fn layer_cache_bytes(&self) -> usize {
(self.memory.layer_cache_mb as usize) << 20
}
pub fn media_cache_bytes(&self) -> usize {
(self.memory.media_cache_mb as usize) << 20
}
pub fn preview_cache_bytes(&self) -> usize {
(self.memory.preview_cache_mb as usize) << 20
}
/// The adaptive resolution limit as a render-scale floor (1/2 → 0.5).
pub fn adaptive_limit(&self) -> f64 {
match self.previews.adaptive_resolution_limit.as_str() {
"1/2" => 0.5,
"1/4" => 0.25,
_ => 0.125,
}
}
}
fn label_index(l: Label) -> Option<usize> {
Label::ALL.iter().position(|x| *x == l).and_then(|i| i.checked_sub(1))
}
/// Settings key of a page id (`3d` → `threeD`).
pub fn section_key(page: &str) -> Option<&'static str> {
Some(match page {
"general" => "general",
"startup" => "startup",
"project" => "project",
"autoSave" | "autosave" => "autoSave",
"composition" | "display" => "composition",
"previews" => "previews",
"appearance" => "appearance",
"grids" => "grids",
"labels" => "labels",
"type" => "type",
"import" => "import",
"export" => "export",
"audio" => "audio",
"disk" => "disk",
"memory" => "memory",
"video" => "video",
"3d" | "threeD" => "threeD",
"scripting" => "scripting",
_ => return None,
})
}
/// Settings page id for a page name, including older After Effects page names
/// (Auto-Save → Project, Media & Disk Cache → Disk, Memory & Performance → Memory & CPU…).
pub fn page_id(name: &str) -> Option<&'static str> {
let n = name.to_ascii_lowercase().replace([' ', '&', '-', '_'], "");
let id = match n.as_str() {
"general" => "general",
"startup" | "startuprepair" => "startup",
"project" | "autosave" | "newproject" => "project",
"composition" | "display" => "composition",
"previews" | "preview" => "previews",
"appearance" => "appearance",
"grids" | "gridsguides" => "grids",
"labels" => "labels",
"type" => "type",
"import" => "import",
"export" | "output" => "export",
"audio" | "audiohardware" | "audiooutputmapping" => "audio",
"disk" | "mediadiskcache" | "mediacache" => "disk",
"memory" | "memorycpu" | "memoryperformance" => "memory",
"video" | "videopreview" => "video",
"3d" | "threed" => "3d",
"scripting" | "scriptingexpressions" => "scripting",
_ => return None,
};
Some(id)
}
fn kind_name(v: &Value) -> &'static str {
match v {
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "list",
Value::Object(_) => "object",
Value::Null => "value",
}
}
/// `value` converted to the type of `slot` (numbers from strings, booleans from 0/1…).
fn coerce(slot: &Value, value: Value) -> Option<Value> {
Some(match (slot, value) {
(Value::Bool(_), Value::Bool(b)) => json!(b),
(Value::Bool(_), Value::Number(n)) => json!(n.as_f64()? != 0.0),
(Value::Bool(_), Value::String(s)) => json!(matches!(s.as_str(), "true" | "on" | "1" | "yes")),
(Value::Number(cur), v) => {
let f = match &v {
Value::Number(n) => n.as_f64()?,
Value::String(s) => s.trim().parse().ok()?,
_ => return None,
};
if cur.is_u64() || cur.is_i64() { json!(f.round().max(0.0) as u64) } else { json!(f) }
}
(Value::String(_), Value::String(s)) => json!(s),
(Value::String(_), Value::Number(n)) => json!(n.to_string()),
(Value::Array(_), v @ Value::Array(_)) => v,
(Value::Object(_), v @ Value::Object(_)) => v,
_ => return None,
})
}
// ---------------------------------------------------------------- schema (Settings dialog)
/// What a setting row edits.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Kind {
Bool,
/// Integer with range and unit.
Int(i64, i64, &'static str),
/// Number with range and unit.
Float(f64, f64, &'static str),
/// One of (label, value).
Choice(&'static [(&'static str, &'static str)]),
Text,
/// A folder or file path (text + Choose…).
Path,
/// `#rrggbb`.
Color,
/// UI brightness slider.
Slider(f64, f64),
}
/// One row of a settings page.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Item {
Section(&'static str),
Setting {
key: &'static str,
label: &'static str,
kind: Kind,
/// Changes behaviour today (false = shown for parity, listed in docs/preferences.md).
live: bool,
},
/// A button running a command (`params` is JSON).
Button {
label: &'static str,
command: &'static str,
params: &'static str,
},
/// The 16 label names and colours.
Labels,
/// Output device picker (device list from the host).
AudioDevices {
key: &'static str,
},
/// Read-only information line.
Note(&'static str),
}
/// One page of the Settings dialog.
#[derive(Clone, Debug)]
pub struct Page {
pub id: &'static str,
pub title: &'static str,
pub items: Vec<Item>,
}
const fn s(key: &'static str, label: &'static str, kind: Kind, live: bool) -> Item {
Item::Setting { key, label, kind, live }
}
const B: Kind = Kind::Bool;
const ON_OFF_RES: &[(&str, &str)] = &[("1/2", "1/2"), ("1/4", "1/4"), ("1/8", "1/8")];
const STYLES: &[(&str, &str)] = &[("Lines", "lines"), ("Dashed Lines", "dashed"), ("Dots", "dots")];
/// The Settings pages in After Effects 2026 order.
pub fn pages() -> Vec<Page> {
use Item::*;
vec![
Page {
id: "general",
title: "General",
items: vec![
s("general.undoLevels", "Levels of Undo", Kind::Int(1, 99, ""), true),
s("general.pathPointSize", "Path Point and Handle Size", Kind::Int(3, 20, "px"), true),
s("general.recentItems", "Recent Projects Shown", Kind::Int(1, 30, ""), true),
Section("Options"),
s("general.showToolTips", "Show Tool Tips", B, true),
s("general.createLayersAtCompStart", "Create Layers at Composition Start Time", B, true),
s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, false),
s("general.defaultSpatialLinear", "Default Spatial Interpolation to Linear", B, true),
s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, false),
s("general.syncTimeRelatedItems", "Synchronize Time of All Related Items", B, false),
s("general.expressionPickWhipCompact", "Expression Pick Whip Writes Compact English", B, false),
s("general.createSplitLayersAbove", "Create Split Layers Above Original Layer", B, false),
s("general.useSystemColorPicker", "Use System Color Picker", B, false),
],
},
Page {
id: "startup",
title: "Startup & Repair",
items: vec![
s("startup.showHomeOnLaunch", "Show Home Screen When Launching", B, true),
s("startup.showHomeOnOpenProject", "Show Home Screen When Opening a Project", B, false),
s("startup.offerCrashRecovery", "Offer to Open the Latest Auto-Save After a Crash", B, true),
Section("Repair"),
Button { label: "Reset Settings", command: "prefs.reset", params: "{}" },
Button { label: "Reset Keyboard Shortcuts", command: "shortcuts.reset", params: "{}" },
Button { label: "Empty Disk Cache", command: "edit.purge", params: r#"{"what":"disk"}"# },
],
},
Page {
id: "project",
title: "Project",
items: vec![
Section("New Project"),
s("project.useTemplate", "New Project Loads Template", B, true),
s("project.templatePath", "Template Project", Kind::Path, true),
Section("Auto-Save"),
s("autoSave.enabled", "Automatically Save Projects", B, true),
s("autoSave.intervalMinutes", "Save Every", Kind::Int(1, 240, "Minutes"), true),
s("autoSave.maxVersions", "Maximum Project Versions", Kind::Int(1, 99, ""), true),
s("autoSave.location", "Auto-Save Location", Kind::Choice(&[("Next to Project", "nextToProject"), ("Custom Location", "custom")]), true),
s("autoSave.folder", "Custom Location", Kind::Path, true),
s("autoSave.saveOnRenderStart", "Save When Starting Render Queue", B, true),
Button { label: "Save Now", command: "file.autoSave", params: "{}" },
],
},
Page {
id: "composition",
title: "Composition",
items: vec![
Section("Motion Path"),
s(
"composition.motionPath",
"Motion Path",
Kind::Choice(&[
("All Keyframes", "all"),
("No Motion Path", "none"),
("No More Than (Seconds)", "seconds"),
("No More Than (Keyframes)", "keyframes"),
]),
false,
),
s("composition.motionPathSeconds", "Seconds", Kind::Float(0.0, 3600.0, "s"), false),
s("composition.motionPathKeyframes", "Keyframes", Kind::Int(1, 10_000, ""), false),
Section("Display"),
s("composition.showRenderingProgress", "Show Rendering Progress in Info Panel and Flowchart", B, true),
s("composition.hardwareAcceleratePanels", "Hardware Accelerate Composition, Layer and Footage Panels", B, false),
],
},
Page {
id: "previews",
title: "Previews",
items: vec![
s("previews.adaptiveResolutionLimit", "Adaptive Resolution Limit", Kind::Choice(ON_OFF_RES), true),
s("previews.showInternalWireframes", "Show Internal Wireframes", B, false),
s("previews.cacheFramesWhenIdle", "Cache Frames When Idle", B, true),
s("previews.fastPreviews", "Fast Previews (Draft 3D, Faster Effects)", B, true),
s("previews.zoomQuality", "Viewer Zoom Quality", Kind::Choice(&[("Faster", "faster"), ("More Accurate", "moreAccurate")]), false),
Button { label: "GPU Information...", command: "app.gpuInfo", params: "{}" },
],
},
Page {
id: "appearance",
title: "Appearance",
items: vec![
s("appearance.theme", "Theme", Kind::Choice(&[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]), true),
s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),
Section("Labels and Colors"),
s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true),
s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, false),
s("appearance.cycleMaskColors", "Cycle Mask Colors", B, false),
s("appearance.useGradients", "Use Gradients", B, false),
],
},
Page {
id: "grids",
title: "Grids & Guides",
items: vec![
Section("Grid"),
s("grids.gridColor", "Color", Kind::Color, true),
s("grids.gridSpacing", "Gridline Every", Kind::Float(1.0, 10_000.0, "px"), true),
s("grids.gridSubdivisions", "Subdivisions", Kind::Int(1, 100, ""), true),
s("grids.gridStyle", "Style", Kind::Choice(STYLES), false),
Section("Proportional Grid"),
s("grids.proportionalHorizontal", "Horizontal", Kind::Int(1, 100, ""), false),
s("grids.proportionalVertical", "Vertical", Kind::Int(1, 100, ""), false),
Section("Guides"),
s("grids.guideColor", "Color", Kind::Color, true),
s("grids.guideStyle", "Style", Kind::Choice(STYLES), false),
Section("Safe Margins"),
s("grids.actionSafe", "Action-safe", Kind::Float(0.0, 50.0, "%"), true),
s("grids.titleSafe", "Title-safe", Kind::Float(0.0, 50.0, "%"), true),
],
},
Page { id: "labels", title: "Labels", items: vec![Labels] },
Page {
id: "type",
title: "Type",
items: vec![
s("type.textEngine", "Text Engine", Kind::Choice(&[("Latin", "latin"), ("South Asian and Middle Eastern", "southAsian")]), false),
s("type.fontPreview", "Show Font Preview", B, false),
s("type.recentFonts", "Number of Recent Fonts to Display", Kind::Int(0, 30, ""), false),
s("type.fontNamesInEnglish", "Show Font Names in English", B, false),
],
},
Page {
id: "import",
title: "Import",
items: vec![
s("import.stillFootage", "Still Footage", Kind::Choice(&[("Length of Composition", "compLength"), ("Duration (Seconds)", "seconds")]), true),
s("import.stillSeconds", "Still Duration", Kind::Float(0.04, 86_400.0, "s"), true),
s("import.sequenceFps", "Sequence Footage", Kind::Float(1.0, 999.0, "frames per second"), true),
s("import.reportMissingFrames", "Report Missing Frames", B, false),
s(
"import.unlabeledAlpha",
"Interpret Unlabeled Alpha As",
Kind::Choice(&[
("Ask User", "ask"),
("Guess", "guess"),
("Ignore", "ignore"),
("Straight", "straight"),
("Premultiplied", "premultiplied"),
]),
false,
),
s(
"import.dragImportAs",
"Default Drag Import As",
Kind::Choice(&[("Footage", "footage"), ("Composition", "comp"), ("Composition - Retain Layer Sizes", "compLayerSizes")]),
false,
),
],
},
Page {
id: "export",
title: "Export",
items: vec![
s("export.defaultOutputFolder", "Default Output Folder", Kind::Path, false),
s("export.segmentSequences", "Segment Sequences", B, false),
s("export.segmentSequenceFiles", "Files per Segment", Kind::Int(1, 100_000, "files"), false),
s("export.segmentMovies", "Segment Movie Files", B, false),
s("export.segmentMovieMb", "Segment Size", Kind::Int(1, 1_000_000, "MB"), false),
s("export.appendBitsToName", "Append Bit Depth to File Name", B, false),
],
},
Page {
id: "audio",
title: "Audio",
items: vec![
Section("Audio Hardware"),
AudioDevices { key: "audio.outputDevice" },
s("audio.previewSampleRate", "Preview Sample Rate", Kind::Int(8_000, 192_000, "Hz"), false),
Section("Audio Output Mapping"),
s("audio.outputLeft", "Left", Kind::Int(1, 64, "channel"), true),
s("audio.outputRight", "Right", Kind::Int(1, 64, "channel"), true),
],
},
Page {
id: "disk",
title: "Disk",
items: vec![
Section("Disk Cache"),
s("disk.diskCacheEnabled", "Enable Disk Cache", B, false),
s("disk.diskCacheMaxGb", "Maximum Disk Cache Size", Kind::Int(1, 100_000, "GB"), false),
s("disk.diskCacheFolder", "Disk Cache Folder", Kind::Path, false),
Button { label: "Empty Disk Cache", command: "edit.purge", params: r#"{"what":"disk"}"# },
Section("Media Cache"),
s("disk.mediaCacheFolder", "Database and Cache Folder", Kind::Path, false),
s("disk.conformedMediaFolder", "Conformed Audio Folder", Kind::Path, false),
],
},
Page {
id: "memory",
title: "Memory & CPU",
items: vec![
s("memory.ramReservedGb", "RAM Reserved for Other Applications", Kind::Int(0, 1024, "GB"), false),
Section("Cache Budgets"),
s("memory.layerCacheMb", "Layer Cache", Kind::Int(64, 1 << 20, "MB"), true),
s("memory.mediaCacheMb", "Footage Frame Cache", Kind::Int(64, 1 << 20, "MB"), true),
s("memory.previewCacheMb", "Preview (RAM) Cache", Kind::Int(64, 1 << 20, "MB"), true),
s("memory.reduceCacheWhenLow", "Reduce Cache Size When System Is Low on Memory", B, false),
],
},
Page {
id: "video",
title: "Video",
items: vec![
s("video.enableOutput", "Enable Video Preview Output", B, false),
s("video.device", "Video Device", Kind::Text, false),
s("video.outputDuringPlayback", "Video Output During Playback", B, false),
s("video.mirrorOnMonitor", "Mirror on Computer Monitor", B, false),
s("video.disableWhenBackground", "Disable Video Output When in Background", B, false),
],
},
Page {
id: "3d",
title: "3D",
items: vec![
s("threeD.defaultRenderer", "Default 3D Renderer", Kind::Choice(&[("Classic 3D", "classic"), ("Advanced 3D", "advanced")]), true),
s("threeD.showReferenceAxes", "Show 3D Reference Axes", B, false),
s("threeD.extendedViewer", "Extended Viewer", B, false),
s("threeD.realtimeShadows", "Realtime Shadows in Draft", B, false),
],
},
Page {
id: "scripting",
title: "Scripting & Expressions",
items: vec![
Section("Application Scripting"),
s("scripting.allowScriptsWriteFiles", "Allow Scripts to Write Files and Access Network", B, false),
s("scripting.warnExecutingFiles", "Warn User When Executing Files", B, false),
s("scripting.enableJsDebugger", "Enable JavaScript Debugger", B, false),
Section("Expressions Editor"),
s("scripting.editorFontSize", "Font Size", Kind::Int(8, 40, "pt"), false),
s("scripting.syntaxHighlighting", "Syntax Highlighting", B, false),
s("scripting.lineNumbers", "Line Numbers", B, false),
s("scripting.autoComplete", "Auto-complete", B, false),
s("scripting.bracketMatching", "Bracket Matching", B, false),
s("scripting.wordWrap", "Word Wrap", B, false),
s("scripting.errorBanner", "Show Expression Error Banner", B, false),
Note("Expressions use the JavaScript engine."),
],
},
]
}
/// Keys of every setting that doesn't change behaviour yet (the TODO list in
/// `docs/preferences.md`).
pub fn todo_keys() -> Vec<&'static str> {
pages()
.into_iter()
.flat_map(|p| p.items)
.filter_map(|i| match i {
Item::Setting { key, live: false, .. } => Some(key),
_ => None,
})
.collect()
}
/// The schema as JSON (for `prefs.pages`).
pub fn pages_json() -> Value {
let kind = |k: &Kind| -> Value {
match k {
Kind::Bool => json!({"type": "bool"}),
Kind::Int(a, b, u) => json!({"type": "int", "min": a, "max": b, "unit": u}),
Kind::Float(a, b, u) => json!({"type": "number", "min": a, "max": b, "unit": u}),
Kind::Choice(o) => json!({"type": "choice", "options": o.iter().map(|(l, v)| json!({"label": l, "value": v})).collect::<Vec<_>>()}),
Kind::Text => json!({"type": "text"}),
Kind::Path => json!({"type": "path"}),
Kind::Color => json!({"type": "color"}),
Kind::Slider(a, b) => json!({"type": "slider", "min": a, "max": b}),
}
};
let pages: Vec<Value> = pages()
.iter()
.map(|p| {
let items: Vec<Value> = p
.items
.iter()
.map(|i| match i {
Item::Section(t) => json!({"section": t}),
Item::Setting { key, label, kind: k, live } => {
let mut o = kind(k);
o["key"] = json!(key);
o["label"] = json!(label);
o["live"] = json!(live);
o
}
Item::Button { label, command, params } => {
json!({"button": label, "command": command, "params": serde_json::from_str::<Value>(params).unwrap_or_default()})
}
Item::Labels => json!({"labels": "labels.N.name / labels.N.color (N = 0..15)"}),
Item::AudioDevices { key } => json!({"type": "device", "key": key, "label": "Default Output"}),
Item::Note(t) => json!({"note": t}),
})
.collect();
json!({"id": p.id, "title": p.title, "items": items})
})
.collect();
json!(pages)
}

View File

@ -0,0 +1,152 @@
//! Session side of Settings, keyboard shortcut presets, auto-save and crash recovery.
use std::path::Path;
use crate::autosave::{self, Sentinel};
use crate::prefs::{PREFS_FILE, Prefs};
use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, ShortcutTable, UiCommand};
use crate::{EngineError, Result, Session};
impl Session {
/// Load settings and shortcut presets from the config store and apply them.
pub fn load_settings(&mut self) {
if let Some(c) = &self.config {
if let Some(t) = c.read(PREFS_FILE) {
self.prefs = Prefs::from_json(&t);
}
if let Some(t) = c.read(SHORTCUTS_FILE) {
self.keymaps = Keymaps::from_json(&t);
}
}
self.prefs_changed();
self.shortcuts_changed();
}
/// Settings changed: apply what the engine owns (cache budgets) and tell frontends.
pub fn prefs_changed(&mut self) {
self.prefs.normalize();
self.layer_cache.set_budget(self.prefs.layer_cache_bytes());
self.footage.set_cache_budget(self.prefs.media_cache_bytes());
// Fewer undo levels apply right away.
let levels = self.prefs.general.undo_levels.max(1) as usize;
if self.history.undo.len() > levels {
let extra = self.history.undo.len() - levels;
self.history.undo.drain(..extra);
}
self.prefs_revision += 1;
}
/// Write settings to the config store.
pub fn save_prefs(&self) {
if let Some(c) = &self.config
&& let Err(e) = c.write(PREFS_FILE, &self.prefs.to_json())
{
log::warn!("saving settings: {e}");
}
}
/// Shortcut presets changed: rebuild the table and save them.
pub fn shortcuts_changed(&mut self) {
self.shortcut_table = std::sync::OnceLock::new();
if let Some(c) = &self.config
&& let Err(e) = c.write(SHORTCUTS_FILE, &self.keymaps.to_json())
{
log::warn!("saving keyboard shortcuts: {e}");
}
}
/// The frontend's own bindable commands (tools, timeline navigation…).
pub fn set_ui_commands(&mut self, cmds: Vec<UiCommand>) {
self.ui_commands = cmds;
self.shortcut_table = std::sync::OnceLock::new();
}
/// The active shortcut preset resolved against every bindable command.
pub fn shortcuts(&self) -> &ShortcutTable {
self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands))
}
/// Remember a project in File ▸ Open Recent and in the crash-recovery sentinel.
pub(crate) fn note_project_path(&mut self, path: &str) {
self.prefs.push_recent(path);
self.save_prefs();
self.autosave = autosave::AutoSaveState { last: self.autosave.last, sentinel: self.autosave.sentinel, ..Default::default() };
self.update_sentinel();
}
/// Refresh the crash-recovery sentinel (when one is active).
pub fn update_sentinel(&self) {
if let Some(c) = &self.config
&& self.autosave.sentinel
{
let s = Sentinel { pid: autosave::pid(), project: self.path.clone(), autosave: self.autosave.last_path.clone(), dirty: self.is_dirty() };
autosave::update(c.as_ref(), &s);
}
}
/// Fallback folder for auto-saves of untitled projects (next to the settings).
pub fn default_autosave_root(&self) -> Option<std::path::PathBuf> {
self.config.as_ref().and_then(|c| c.dir())
}
/// Write an auto-save now (whether or not the project is dirty). Returns its path.
pub fn autosave_now(&mut self) -> Result<String> {
let json = self.project.to_json();
let root = self.default_autosave_root();
let (path, slot) = autosave::write(&self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
.map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?;
let p = path.to_string_lossy().to_string();
self.autosave.last_slot = Some(slot);
self.autosave.saved_revision = Some(self.revision);
self.autosave.last_path = Some(p.clone());
self.update_sentinel();
Ok(p)
}
/// Call periodically with wall-clock seconds: auto-saves a dirty project once the interval
/// has passed since the last auto-save (or since the first call). Returns the written path.
pub fn autosave_tick(&mut self, now: f64) -> Option<Result<String>> {
let a = &self.prefs.auto_save;
if !a.enabled {
self.autosave.last = Some(now);
return None;
}
let interval = a.interval_minutes.max(1) as f64 * 60.0;
let last = *self.autosave.last.get_or_insert(now);
if now - last < interval {
return None;
}
self.autosave.last = Some(now);
if !self.is_dirty() || self.autosave.saved_revision == Some(self.revision) || self.render_job.is_some() {
return None;
}
if self.path.is_none() && self.prefs.auto_save.location != "custom" && self.default_autosave_root().is_none() {
return None;
}
let r = self.autosave_now();
if let Ok(p) = &r {
self.toast(format!("Auto-saved {}", Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()));
}
Some(r)
}
/// At launch: start crash recovery. Returns the previous run's state when it didn't exit
/// cleanly (and the setting asks for it).
pub fn begin_recovery(&mut self) -> Option<autosave::Recovery> {
let c = self.config.clone()?;
let r = autosave::begin(c.as_ref(), &self.prefs);
self.autosave.sentinel = true;
self.update_sentinel();
r.filter(|_| self.prefs.startup.offer_crash_recovery)
}
/// Clean exit: remove the crash-recovery sentinel.
pub fn end_recovery(&mut self) {
if let Some(c) = &self.config
&& self.autosave.sentinel
{
autosave::end(c.as_ref());
self.autosave.sentinel = false;
}
}
}

View File

@ -0,0 +1,468 @@
//! Keyboard shortcut presets (Edit ▸ Keyboard Shortcuts).
//!
//! Every menu entry, registry command and frontend command is *bindable*: it has a binding key
//! (the command id, plus its bound parameters for entries such as `app.settings
//! {"page":"general"}`), a scope (application-wide or a panel) and default keys. The built-in
//! preset "EffectCraft Default" is After Effects' default keyboard layout and is read-only; custom
//! presets store only their differences from it. Editing the default preset first duplicates it
//! (as After Effects asks you to "Save As" a custom set). The shortcut dispatcher and the menus
//! read the active preset through [`ShortcutTable`].
//!
//! Shortcut text is `Ctrl+Cmd+Alt+Shift+Key` (`Cmd` is ⌘ on macOS and Ctrl elsewhere).
use std::collections::{BTreeMap, HashMap};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
/// Preset file in the config store.
pub const SHORTCUTS_FILE: &str = "shortcuts.json";
/// The built-in (read-only) preset.
pub const DEFAULT_PRESET: &str = "EffectCraft Default";
/// Scope of application-wide shortcuts.
pub const APP_SCOPE: &str = "Application";
/// A frontend-only command offered for binding (tools, timeline navigation…).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UiCommand {
pub id: String,
pub label: String,
pub shortcut: Option<String>,
}
/// One thing a shortcut can run.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Bindable {
/// Binding key: `command` or `command {params}`.
pub key: String,
pub command: String,
#[serde(skip_serializing_if = "Value::is_null")]
pub params: Value,
pub label: String,
/// Menu path (`["Layer", "New"]`) or a category for commands outside the menus.
pub path: Vec<String>,
/// `Application` or the panel the shortcut works in (`Timeline`, `Composition`…).
pub scope: String,
/// After Effects default keys.
pub defaults: Vec<String>,
}
/// The binding key of a command with bound parameters.
pub fn binding_key(command: &str, params: &Value) -> String {
match params {
Value::Null => command.to_string(),
Value::Object(m) if m.is_empty() => command.to_string(),
p => format!("{command} {p}"),
}
}
/// Scope of a frontend command from its id.
fn ui_scope(id: &str) -> &'static str {
if id.starts_with("timeline.") {
"Timeline"
} else if id.starts_with("view.") {
"Composition"
} else {
APP_SCOPE
}
}
/// Every bindable command: menu entries, registry commands outside the menus, then the
/// frontend's own commands.
pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
let mut out: Vec<Bindable> = vec![];
let mut seen: HashMap<String, usize> = HashMap::new();
let mut push = |b: Bindable, out: &mut Vec<Bindable>| {
if let Some(&i) = seen.get(&b.key) {
// Same command in two menus: merge defaults.
for d in b.defaults {
if !out[i].defaults.contains(&d) {
out[i].defaults.push(d);
}
}
return;
}
seen.insert(b.key.clone(), out.len());
out.push(b);
};
let entries = crate::menus::entries();
for (path, e) in &entries {
let key = binding_key(&e.command, &e.params);
let defaults = e.shortcut.iter().filter_map(|s| normalize(s)).collect();
push(
Bindable {
key,
command: e.command.clone(),
params: e.params.clone(),
label: e.label.clone(),
path: path.clone(),
scope: APP_SCOPE.into(),
defaults,
},
&mut out,
);
}
for c in crate::command_specs() {
if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) {
continue;
}
let defaults = c.shortcut.and_then(normalize).into_iter().collect();
push(
Bindable {
key: c.id.to_string(),
command: c.id.to_string(),
params: Value::Null,
label: c.label.to_string(),
path: vec!["Commands".into()],
scope: APP_SCOPE.into(),
defaults,
},
&mut out,
);
}
for c in ui {
let scope = ui_scope(&c.id);
let cat = if scope == APP_SCOPE { "Tools & Panels" } else { scope };
push(
Bindable {
key: c.id.clone(),
command: c.id.clone(),
params: Value::Null,
label: c.label.clone(),
path: vec![cat.into()],
scope: scope.into(),
defaults: c.shortcut.as_deref().and_then(normalize).into_iter().collect(),
},
&mut out,
);
}
out
}
/// Canonical shortcut text (`cmd+shift+k`, `Shift+Cmd+K`, `⌘⇧K` → `Cmd+Shift+K`); `None` if it
/// has no key.
pub fn normalize(s: &str) -> Option<String> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (mut ctrl, mut cmd, mut alt, mut shift) = (false, false, false, false);
let mut key: Option<String> = None;
// `Cmd++` / `+` name the plus key.
let (head, plus) = match s.strip_suffix("++") {
Some(h) => (h, true),
None if s == "+" => ("", true),
None => (s, false),
};
let mut parts: Vec<String> = head.split('+').filter(|p| !p.is_empty()).map(str::to_string).collect();
// macOS glyph notation.
if parts.len() == 1 && parts[0].chars().any(|c| "⌃⌥⇧⌘".contains(c)) {
let p = parts.remove(0);
let mut rest = String::new();
for c in p.chars() {
match c {
'⌃' => ctrl = true,
'⌥' => alt = true,
'⇧' => shift = true,
'⌘' => cmd = true,
c => rest.push(c),
}
}
if !rest.is_empty() {
parts.push(rest);
}
}
for p in parts {
match p.to_ascii_lowercase().as_str() {
"cmd" | "command" | "meta" | "super" | "win" => cmd = true,
"ctrl" | "control" => ctrl = true,
"alt" | "opt" | "option" => alt = true,
"shift" => shift = true,
_ => key = Some(key_name(&p)),
}
}
if plus {
key = Some("+".into());
}
let key = key?;
let mut out = String::new();
for (on, m) in [(ctrl, "Ctrl+"), (cmd, "Cmd+"), (alt, "Alt+"), (shift, "Shift+")] {
if on {
out.push_str(m);
}
}
out.push_str(&key);
Some(out)
}
fn key_name(k: &str) -> String {
let l = k.to_ascii_lowercase();
match l.as_str() {
"space" | "spacebar" => "Space".into(),
"esc" | "escape" => "Escape".into(),
"enter" | "return" => "Enter".into(),
"tab" => "Tab".into(),
"backspace" => "Backspace".into(),
"delete" | "del" => "Delete".into(),
"home" => "Home".into(),
"end" => "End".into(),
"pageup" | "pgup" => "PageUp".into(),
"pagedown" | "pgdn" => "PageDown".into(),
"up" | "arrowup" => "ArrowUp".into(),
"down" | "arrowdown" => "ArrowDown".into(),
"left" | "arrowleft" => "ArrowLeft".into(),
"right" | "arrowright" => "ArrowRight".into(),
_ if l.starts_with("num") => format!("Num{}", &k[3..]),
_ if l.len() > 1 && l.starts_with('f') && l[1..].chars().all(|c| c.is_ascii_digit()) => l.to_ascii_uppercase(),
_ if k.chars().count() == 1 => k.to_uppercase(),
_ => k.to_string(),
}
}
/// A custom preset: differences from the default (`[]` removes a default shortcut).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Preset {
pub name: String,
#[serde(default)]
pub overrides: BTreeMap<String, Vec<String>>,
}
/// The saved presets and which one is active.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Keymaps {
pub active: String,
pub presets: Vec<Preset>,
}
impl Default for Keymaps {
fn default() -> Self {
Keymaps { active: DEFAULT_PRESET.into(), presets: vec![] }
}
}
/// A conflict: another command already using the keys in an overlapping scope.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Conflict {
pub keys: String,
pub key: String,
pub label: String,
pub scope: String,
}
fn scopes_overlap(a: &str, b: &str) -> bool {
a == b || a == APP_SCOPE || b == APP_SCOPE
}
impl Keymaps {
pub fn from_json(text: &str) -> Keymaps {
let mut k: Keymaps = serde_json::from_str(text).unwrap_or_default();
if !k.presets.iter().any(|p| p.name == k.active) {
k.active = DEFAULT_PRESET.into();
}
k
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
/// Preset names, the default first.
pub fn names(&self) -> Vec<String> {
std::iter::once(DEFAULT_PRESET.to_string()).chain(self.presets.iter().map(|p| p.name.clone())).collect()
}
pub fn active_preset(&self) -> Option<&Preset> {
self.presets.iter().find(|p| p.name == self.active)
}
/// Effective keys of a bindable in the active preset.
pub fn keys_of(&self, b: &Bindable) -> Vec<String> {
match self.active_preset().and_then(|p| p.overrides.get(&b.key)) {
Some(k) => k.clone(),
None => b.defaults.clone(),
}
}
fn unique_name(&self, base: &str) -> String {
if !self.names().iter().any(|n| n == base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !self.names().contains(n)).unwrap_or_else(|| base.to_string())
}
/// Make sure a custom preset is active (editing the default duplicates it as "Custom").
/// Returns the name of a preset created for the edit.
fn ensure_custom(&mut self) -> Option<String> {
if self.active_preset().is_some() {
return None;
}
let name = self.unique_name("Custom");
self.presets.push(Preset { name: name.clone(), overrides: BTreeMap::new() });
self.active = name.clone();
Some(name)
}
/// Set the keys of a bindable (normalized; empty = no shortcut). Returns a newly created
/// preset name when the default was active.
pub fn set(&mut self, b: &Bindable, keys: &[String]) -> Result<Option<String>, String> {
let mut norm = vec![];
for k in keys {
let n = normalize(k).ok_or_else(|| format!("`{k}` is not a shortcut"))?;
if !norm.contains(&n) {
norm.push(n);
}
}
let created = self.ensure_custom();
let p = self.presets.iter_mut().find(|p| p.name == self.active).ok_or("no active preset")?;
if norm == b.defaults {
p.overrides.remove(&b.key);
} else {
p.overrides.insert(b.key.clone(), norm);
}
Ok(created)
}
/// Clear the active preset's changes (its shortcuts become the defaults).
pub fn reset_active(&mut self) {
let active = self.active.clone();
if let Some(p) = self.presets.iter_mut().find(|p| p.name == active) {
p.overrides.clear();
}
}
pub fn select(&mut self, name: &str) -> Result<(), String> {
if !self.names().iter().any(|n| n == name) {
return Err(format!("no shortcut preset `{name}`"));
}
self.active = name.to_string();
Ok(())
}
/// Duplicate a preset (the default or a custom one) under a new name and activate it.
pub fn duplicate(&mut self, from: &str, name: Option<&str>) -> Result<String, String> {
let overrides = if from == DEFAULT_PRESET {
BTreeMap::new()
} else {
self.presets.iter().find(|p| p.name == from).ok_or_else(|| format!("no shortcut preset `{from}`"))?.overrides.clone()
};
let name = self.unique_name(name.unwrap_or(&format!("{from} copy")));
self.presets.push(Preset { name: name.clone(), overrides });
self.active = name.clone();
Ok(name)
}
pub fn delete(&mut self, name: &str) -> Result<(), String> {
if name == DEFAULT_PRESET {
return Err("the default preset can't be deleted".into());
}
let n = self.presets.len();
self.presets.retain(|p| p.name != name);
if self.presets.len() == n {
return Err(format!("no shortcut preset `{name}`"));
}
if self.active == name {
self.active = DEFAULT_PRESET.into();
}
Ok(())
}
pub fn rename(&mut self, from: &str, to: &str) -> Result<(), String> {
if from == DEFAULT_PRESET || to == DEFAULT_PRESET {
return Err("the default preset can't be renamed".into());
}
if self.names().iter().any(|n| n == to) {
return Err(format!("a preset named `{to}` exists"));
}
let p = self.presets.iter_mut().find(|p| p.name == from).ok_or_else(|| format!("no shortcut preset `{from}`"))?;
p.name = to.to_string();
if self.active == from {
self.active = to.to_string();
}
Ok(())
}
/// A preset as a portable JSON document.
pub fn export(&self, name: Option<&str>) -> Result<Value, String> {
let name = name.unwrap_or(&self.active);
let overrides = if name == DEFAULT_PRESET {
BTreeMap::new()
} else {
self.presets.iter().find(|p| p.name == name).ok_or_else(|| format!("no shortcut preset `{name}`"))?.overrides.clone()
};
Ok(json!({"format": "effectcraft-shortcuts", "version": 1, "name": name, "base": DEFAULT_PRESET, "overrides": overrides}))
}
/// Import an exported preset (renamed if the name is taken) and activate it.
pub fn import(&mut self, doc: &Value, name: Option<&str>) -> Result<String, String> {
let overrides: BTreeMap<String, Vec<String>> =
serde_json::from_value(doc.get("overrides").cloned().unwrap_or_else(|| json!({}))).map_err(|e| format!("bad shortcut preset: {e}"))?;
let overrides = overrides.into_iter().map(|(k, v)| (k, v.iter().filter_map(|s| normalize(s)).collect())).collect();
let base = name.or_else(|| doc.get("name").and_then(Value::as_str)).unwrap_or("Imported");
let base = if base == DEFAULT_PRESET { "Imported" } else { base };
let name = self.unique_name(base);
self.presets.push(Preset { name: name.clone(), overrides });
self.active = name.clone();
Ok(name)
}
}
/// The active preset resolved against every bindable command.
#[derive(Clone, Debug, Default)]
pub struct ShortcutTable {
pub bindables: Vec<Bindable>,
/// Effective keys per binding key.
pub keys: HashMap<String, Vec<String>>,
}
impl ShortcutTable {
pub fn build(maps: &Keymaps, ui: &[UiCommand]) -> ShortcutTable {
let bindables = bindables(ui);
let keys = bindables.iter().map(|b| (b.key.clone(), maps.keys_of(b))).collect();
ShortcutTable { bindables, keys }
}
pub fn find(&self, key: &str) -> Option<&Bindable> {
self.bindables.iter().find(|b| b.key == key)
}
/// First shortcut of a command / menu entry (for menus).
pub fn shortcut_of(&self, command: &str, params: &Value) -> Option<&str> {
self.keys.get(&binding_key(command, params)).and_then(|k| k.first()).map(String::as_str)
}
/// Every active binding: (keys, bindable).
pub fn bindings(&self) -> Vec<(&str, &Bindable)> {
self.bindables.iter().flat_map(|b| self.keys.get(&b.key).into_iter().flatten().map(move |k| (k.as_str(), b))).collect()
}
/// Other bindables using `keys` in an overlapping scope.
pub fn conflicts(&self, b: &Bindable, keys: &str) -> Vec<Conflict> {
let Some(keys) = normalize(keys) else { return vec![] };
self.bindings()
.into_iter()
.filter(|(k, o)| *k == keys && o.key != b.key && scopes_overlap(&o.scope, &b.scope))
.map(|(k, o)| Conflict { keys: k.to_string(), key: o.key.clone(), label: o.label.clone(), scope: o.scope.clone() })
.collect()
}
/// Every pair of commands sharing keys in an overlapping scope.
pub fn all_conflicts(&self) -> Vec<(String, Conflict)> {
let mut by_keys: BTreeMap<&str, Vec<&Bindable>> = BTreeMap::new();
for (k, b) in self.bindings() {
if k != "Num*" {
by_keys.entry(k).or_default().push(b);
}
}
let mut out = vec![];
for (k, bs) in by_keys {
for (i, a) in bs.iter().enumerate() {
for b in &bs[i + 1..] {
if scopes_overlap(&a.scope, &b.scope) {
out.push((a.key.clone(), Conflict { keys: k.to_string(), key: b.key.clone(), label: b.label.clone(), scope: b.scope.clone() }));
}
}
}
}
out
}
}

View File

@ -0,0 +1,466 @@
//! Settings, keyboard shortcut presets, auto-save and crash recovery.
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::json;
use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with, temp_path};
use crate::prefs::{PREFS_FILE, Prefs};
use crate::shortcuts::{DEFAULT_PRESET, Keymaps, normalize};
use crate::{Session, autosave};
fn tmp(name: &str) -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let d = std::env::temp_dir().join(format!("ec-settings-{}-{}-{name}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn session_with_comp() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 10})).unwrap();
s
}
// ---------------------------------------------------------------- prefs model
#[test]
fn prefs_round_trip_through_json() {
let mut p = Prefs::default();
p.set("general.undoLevels", json!(12)).unwrap();
p.set("labels.2.name", json!("Teal")).unwrap();
p.set("autoSave.location", json!("custom")).unwrap();
p.set("appearance.brightness", json!(0.25)).unwrap();
p.push_recent("/a/b.ecproj");
let back = Prefs::from_json(&p.to_json());
assert_eq!(back, p);
assert_eq!(Prefs::from_json(&Prefs::default().to_json()), Prefs::default());
}
#[test]
fn prefs_migrate_old_layout_and_keep_unknown_keys() {
let old = r#"{
"undoLevels": 50, "autoSaveMinutes": 7, "autoSaveVersions": 3, "theme": "light",
"labelNames": ["Hot", "Sun"],
"futureSetting": {"x": 1},
"general": {"showToolTips": false, "notYetKnown": true},
"memory": {"layerCacheMb": "not a number"}
}"#;
let p = Prefs::from_json(old);
assert_eq!(p.version, crate::prefs::PREFS_VERSION);
assert_eq!(p.general.undo_levels, 50);
assert!(!p.general.show_tool_tips);
assert_eq!(p.auto_save.interval_minutes, 7);
assert_eq!(p.auto_save.max_versions, 3);
assert_eq!(p.appearance.theme, "light");
assert_eq!(p.labels[0].name, "Hot");
assert_eq!(p.labels[1].name, "Sun");
assert_eq!(p.labels[2].name, "Aqua");
assert_eq!(p.labels.len(), 16);
// A bad value falls back to that page's defaults; other pages survive.
assert_eq!(p.memory.layer_cache_mb, 1024);
// Unknown keys (a newer version's) are kept and written back.
let again: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap();
assert_eq!(again["futureSetting"], json!({"x": 1}));
assert_eq!(again["general"]["notYetKnown"], json!(true));
assert!(again.get("undoLevels").is_none(), "migrated keys leave the top level");
// Garbage is defaults.
assert_eq!(Prefs::from_json("not json"), Prefs::default());
}
#[test]
fn prefs_set_validates_keys_types_and_ranges() {
let mut p = Prefs::default();
assert!(p.set("general.nope", json!(1)).is_err());
assert!(p.set("general.showToolTips", json!([1])).is_err());
p.set("general.undoLevels", json!(500)).unwrap();
assert_eq!(p.general.undo_levels, 99);
p.set("general.undoLevels", json!("7")).unwrap();
assert_eq!(p.general.undo_levels, 7);
p.set("labels.0.color", json!("zzz")).unwrap();
assert_eq!(p.labels[0].color, Prefs::default().labels[0].color);
p.reset(Some("general")).unwrap();
assert_eq!(p.general.undo_levels, 32);
assert!(p.reset(Some("bogus")).is_err());
}
#[test]
fn every_schema_key_exists_and_docs_list_the_todo_settings() {
let p = Prefs::default();
let mut ids = vec![];
for page in crate::prefs::pages() {
assert!(crate::prefs::page_id(page.id).is_some(), "page {}", page.id);
ids.push(page.id);
for item in page.items {
if let crate::prefs::Item::Setting { key, .. } | crate::prefs::Item::AudioDevices { key } = item {
assert!(p.get(key).is_some(), "schema key `{key}` is not a setting");
}
}
}
// The Settings menu and the dialog list the same pages.
for (path, e) in crate::menus::entries() {
if e.command == "app.settings" {
let page = e.params["page"].as_str().unwrap();
assert!(ids.contains(&page), "{path:?} opens unknown page {page}");
}
}
let docs = include_str!("../../../docs/preferences.md");
for k in crate::prefs::todo_keys() {
assert!(docs.contains(&format!("`{k}`")), "docs/preferences.md must list the not-yet-wired setting `{k}`");
}
}
// ---------------------------------------------------------------- prefs effects
#[test]
fn prefs_commands_get_set_reset_and_persist() {
let store = Arc::new(MemoryConfig::default());
let mut s = Session { config: Some(store.clone()), ..Default::default() };
s.execute_checked("prefs.set", json!({"key": "general.recentItems", "value": 4})).unwrap();
assert_eq!(s.execute("prefs.get", json!({"key": "general.recentItems"})).unwrap(), json!(4));
s.execute("prefs.set", json!({"values": {"autoSave.maxVersions": 9, "grids.gridSpacing": 50}})).unwrap();
assert!(s.execute("prefs.set", json!({"key": "nope.nope", "value": 1})).is_err());
let saved = store.read(PREFS_FILE).unwrap();
let mut s2 = Session { config: Some(store.clone()), ..Default::default() };
s2.load_settings();
assert_eq!(s2.prefs.general.recent_items, 4);
assert_eq!(s2.prefs.auto_save.max_versions, 9);
assert!(saved.contains("\"maxVersions\": 9"));
s2.execute("prefs.reset", json!({"page": "project"})).unwrap();
assert_eq!(s2.prefs.auto_save.max_versions, 5);
assert!(s2.execute("prefs.pages", json!({})).unwrap().as_array().unwrap().len() >= 17);
s2.execute("prefs.open", json!({"page": "Auto-Save"})).unwrap();
assert!(
s2.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, params } if command == "app.settings" && params["page"] == "project"))
);
}
#[test]
fn undo_levels_are_honoured() {
let mut s = session_with_comp();
s.execute("prefs.set", json!({"key": "general.undoLevels", "value": 3})).unwrap();
for i in 0..6 {
s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap();
}
assert_eq!(s.history.undo.len(), 3);
for _ in 0..3 {
assert!(s.undo());
}
assert!(!s.undo());
assert_eq!(s.active_comp().unwrap().layers.len(), 3);
// Lowering the setting trims the history right away.
s.execute("prefs.set", json!({"key": "general.undoLevels", "value": 1})).unwrap();
assert!(s.history.undo.len() <= 1);
}
#[test]
fn renamed_labels_show_in_the_label_menu_and_apply_by_name() {
let mut s = session_with_comp();
s.execute("layer.newNull", json!({})).unwrap();
s.execute("prefs.set", json!({"key": "labels.0.name", "value": "Hot Sauce"})).unwrap();
s.execute("prefs.set", json!({"key": "labels.0.color", "value": "#102030"})).unwrap();
let red = crate::menus::entries().into_iter().find(|(_, e)| e.command == "edit.label" && e.params["label"] == "Red").unwrap().1;
assert_eq!(crate::menus::entry_label(&s, red), "Hot Sauce");
assert_eq!(s.prefs.label_rgb(effectcraft_color::Label::Red), [0x10, 0x20, 0x30]);
s.execute("edit.label", json!({"label": "Yellow"})).unwrap();
s.execute("edit.label", json!({"label": "hot sauce"})).unwrap();
let l = &s.active_comp().unwrap().layers[0];
assert_eq!(l.label, effectcraft_color::Label::Red);
}
#[test]
fn cache_budgets_are_applied() {
let mut s = Session::default();
s.execute("prefs.set", json!({"key": "memory.layerCacheMb", "value": 128})).unwrap();
assert_eq!(s.layer_cache.budget(), 128 << 20);
struct Src(std::sync::Mutex<usize>);
impl effectcraft_render::FootageSource for Src {
fn frame(&self, _: effectcraft_project::ItemId, _: &effectcraft_project::Footage, _: effectcraft_time::Tick) -> Option<Arc<effectcraft_raster::Image>> {
None
}
fn set_cache_budget(&self, b: usize) {
*self.0.lock().unwrap() = b;
}
}
let src = Arc::new(Src(Default::default()));
s.footage = src.clone();
s.execute("prefs.set", json!({"key": "memory.mediaCacheMb", "value": 256})).unwrap();
assert_eq!(*src.0.lock().unwrap(), 256 << 20);
}
#[test]
fn new_layers_start_at_the_current_time_when_asked() {
let mut s = session_with_comp();
let nested = s.execute("comp.new", json!({"name": "Nested", "open": false})).unwrap()["comp"].as_u64().unwrap();
s.set_time(effectcraft_time::Tick::from_seconds_f64(2.0));
s.execute("layer.addItem", json!({"item": nested})).unwrap();
assert_eq!(s.active_comp().unwrap().layers[0].start_time, effectcraft_time::Tick::ZERO);
s.execute("prefs.set", json!({"key": "general.createLayersAtCompStart", "value": false})).unwrap();
s.execute("layer.addItem", json!({"item": nested})).unwrap();
assert_eq!(s.active_comp().unwrap().layers[0].start_time.seconds().round(), 2.0);
}
#[test]
fn default_spatial_interpolation_linear() {
let mut s = session_with_comp();
s.execute("layer.newNull", json!({})).unwrap();
s.execute("prop.set", json!({"path": "transform/position", "value": [0, 0], "time": 0})).unwrap();
s.execute("prop.toggleAnimation", json!({"path": "transform/position", "value": true})).ok();
s.execute("prop.addKey", json!({"path": "transform/position", "time": 0})).unwrap();
let auto =
|s: &Session| s.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().keys.iter().map(|k| k.spatial_auto).collect::<Vec<_>>();
assert!(auto(&s).iter().all(|a| *a));
s.execute("prefs.set", json!({"key": "general.defaultSpatialLinear", "value": true})).unwrap();
s.execute("prop.addKey", json!({"path": "transform/position", "time": 1, "value": [100, 50]})).unwrap();
assert_eq!(auto(&s), vec![true, false]);
}
#[test]
fn new_project_template_and_default_renderer() {
let d = tmp("template");
let tpl = d.join("tpl.ecproj");
let mut a = session_with_comp();
a.execute("file.saveAs", json!({"path": tpl.to_string_lossy()})).unwrap();
let mut s = Session::default();
s.execute("prefs.set", json!({"values": {"project.useTemplate": true, "project.templatePath": tpl.to_string_lossy()}})).unwrap();
s.execute("file.newProject", json!({})).unwrap();
assert_eq!(s.project.comps().count(), 1);
assert!(s.path.is_none(), "a template opens untitled");
s.execute("prefs.set", json!({"key": "threeD.defaultRenderer", "value": "advanced"})).unwrap();
s.execute("comp.new", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Advanced3D);
let _ = std::fs::remove_dir_all(d);
}
// ---------------------------------------------------------------- shortcuts
#[test]
fn shortcut_text_normalizes() {
assert_eq!(normalize("shift+cmd+k").as_deref(), Some("Cmd+Shift+K"));
assert_eq!(normalize("Opt+Ctrl+f5").as_deref(), Some("Ctrl+Alt+F5"));
assert_eq!(normalize("⌘⇧S").as_deref(), Some("Cmd+Shift+S"));
assert_eq!(normalize("Cmd++").as_deref(), Some("Cmd++"));
assert_eq!(normalize("space").as_deref(), Some("Space"));
assert_eq!(normalize("Cmd+").as_deref(), None);
}
#[test]
fn defaults_have_no_conflicts_and_match_the_menus() {
let s = Session::default();
let t = s.shortcuts();
assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts());
assert_eq!(t.shortcut_of("file.save", &json!(null)), Some("Cmd+S"));
assert_eq!(t.shortcut_of("app.settings", &json!({"page": "general"})), Some("Cmd+Alt+;"));
assert!(t.bindables.len() > 500);
}
#[test]
fn shortcut_edits_detect_conflicts_and_create_a_custom_preset() {
let store = Arc::new(MemoryConfig::default());
let mut s = Session { config: Some(store.clone()), ..Default::default() };
let r = s.execute_checked("shortcuts.set", json!({"command": "layer.newNull", "keys": "cmd+s"})).unwrap();
assert_eq!(r["keys"], json!(["Cmd+S"]));
assert_eq!(r["created"], json!("Custom"));
assert!(r["conflicts"].as_array().unwrap().iter().any(|c| c["key"] == "file.save"), "{r}");
assert_eq!(s.keymaps.active, "Custom");
assert_eq!(s.shortcuts().shortcut_of("layer.newNull", &json!(null)), Some("Cmd+S"));
assert!(!s.execute("shortcuts.conflicts", json!({})).unwrap().as_array().unwrap().is_empty());
// The default preset is untouched.
s.execute("shortcuts.preset", json!({"op": "select", "name": DEFAULT_PRESET})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("layer.newNull", &json!(null)), Some("Cmd+Alt+Shift+Y"));
// Remove a shortcut; reset one.
s.execute("shortcuts.preset", json!({"op": "select", "name": "Custom"})).unwrap();
s.execute("shortcuts.set", json!({"command": "file.save", "keys": null})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("file.save", &json!(null)), None);
s.execute("shortcuts.reset", json!({"command": "file.save"})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("file.save", &json!(null)), Some("Cmd+S"));
// Bound-parameter entries are bindable too.
s.execute("shortcuts.set", json!({"command": "app.settings", "params": {"page": "labels"}, "keys": ["F9"]})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("app.settings", &json!({"page": "labels"})), Some("F9"));
// Persisted.
let k = Keymaps::from_json(&store.read(crate::shortcuts::SHORTCUTS_FILE).unwrap());
assert_eq!(k.active, "Custom");
assert!(s.execute("shortcuts.set", json!({"command": "no.such", "keys": "F1"})).is_err());
assert!(s.execute("shortcuts.set", json!({"command": "file.save", "keys": "Cmd+"})).is_err());
let list = s.execute("shortcuts.list", json!({"keys": "F9"})).unwrap();
assert!(list["commands"].as_array().unwrap().iter().any(|c| c["command"] == "app.settings" && c["params"]["page"] == "labels"), "{list}");
}
#[test]
fn shortcut_presets_duplicate_rename_delete_export_import() {
let d = tmp("presets");
let mut s = Session::default();
s.execute("shortcuts.preset", json!({"op": "new", "name": "Mine"})).unwrap();
s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": ["Cmd+Z", "F2"]})).unwrap();
s.execute("shortcuts.preset", json!({"op": "duplicate", "name": "Mine 2"})).unwrap();
assert_eq!(s.keymaps.names(), vec![DEFAULT_PRESET, "Mine", "Mine 2"]);
s.execute("shortcuts.preset", json!({"op": "rename", "name": "Mine 2", "newName": "Other"})).unwrap();
assert!(s.execute("shortcuts.preset", json!({"op": "delete", "name": DEFAULT_PRESET})).is_err());
let path = d.join("mine.json");
s.execute("shortcuts.export", json!({"preset": "Mine", "path": path.to_string_lossy()})).unwrap();
s.execute("shortcuts.preset", json!({"op": "delete", "name": "Mine"})).unwrap();
s.execute("shortcuts.preset", json!({"op": "delete", "name": "Other"})).unwrap();
assert_eq!(s.keymaps.active, DEFAULT_PRESET);
let r = s.execute("shortcuts.import", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(r["preset"], json!("Mine"));
assert_eq!(s.shortcuts().keys["edit.undo"], vec!["Cmd+Z".to_string(), "F2".into()]);
s.execute("shortcuts.reset", json!({})).unwrap();
assert_eq!(s.shortcuts().keys["edit.undo"], vec!["Cmd+Z".to_string()]);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn frontend_commands_are_bindable_with_panel_scope() {
let mut s = Session::default();
s.set_ui_commands(vec![
crate::shortcuts::UiCommand { id: "timeline.zoomIn".into(), label: "Zoom In Time".into(), shortcut: Some("=".into()) },
crate::shortcuts::UiCommand { id: "tool.hand".into(), label: "Hand Tool".into(), shortcut: Some("H".into()) },
]);
let b = s.shortcuts().find("timeline.zoomIn").unwrap().clone();
assert_eq!(b.scope, "Timeline");
assert_eq!(s.shortcuts().shortcut_of("tool.hand", &json!(null)), Some("H"));
// A panel shortcut conflicts with an application shortcut on the same keys.
let c = s.shortcuts().conflicts(&b, "H");
assert!(c.iter().any(|c| c.key == "tool.hand"));
}
// ---------------------------------------------------------------- auto-save / recovery
#[test]
fn atomic_write_survives_a_crash_mid_write() {
let d = tmp("atomic");
let f = d.join("p.ecproj");
atomic_write(&f, b"version 1").unwrap();
// The writer dies halfway through the temporary file.
let r = atomic_write_with(&f, b"version 2 is much longer", |tmp, data| {
std::fs::write(tmp, &data[..5])?;
Err(std::io::Error::other("power cut"))
});
assert!(r.is_err());
assert_eq!(std::fs::read(&f).unwrap(), b"version 1");
assert!(!temp_path(&f).exists(), "the partial temp file is cleaned up");
// A temp file left by a hard kill doesn't count as an auto-save.
std::fs::write(d.join("p auto-save 1.ecproj.tmp"), b"junk").unwrap();
assert!(autosave::existing(&d, "p").is_empty());
atomic_write(&f, b"version 3").unwrap();
assert_eq!(std::fs::read(&f).unwrap(), b"version 3");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn autosave_rotates_through_max_versions() {
let d = tmp("rotate");
let proj = d.join("Intro.ecproj");
let mut s = session_with_comp();
s.execute("file.saveAs", json!({"path": proj.to_string_lossy()})).unwrap();
s.execute("prefs.set", json!({"key": "autoSave.maxVersions", "value": 3})).unwrap();
let folder = d.join(autosave::AUTOSAVE_FOLDER);
let mut written = vec![];
for i in 0..5 {
s.execute("layer.newNull", json!({"name": format!("save{i}")})).unwrap();
written.push(s.autosave_now().unwrap());
}
let names: Vec<String> = written.iter().map(|p| PathBuf::from(p).file_name().unwrap().to_string_lossy().to_string()).collect();
assert_eq!(
names,
["Intro auto-save 1.ecproj", "Intro auto-save 2.ecproj", "Intro auto-save 3.ecproj", "Intro auto-save 1.ecproj", "Intro auto-save 2.ecproj"]
);
let have: Vec<u32> = autosave::existing(&folder, "Intro").iter().map(|e| e.0).collect();
assert_eq!(have, [1, 2, 3]);
let slot2 = std::fs::read_to_string(folder.join("Intro auto-save 2.ecproj")).unwrap();
assert!(slot2.contains("save4"), "slot 2 holds the newest save");
// The project file itself is untouched and still dirty.
assert!(s.is_dirty());
assert!(!std::fs::read_to_string(&proj).unwrap().contains("save0"));
// Lowering the maximum drops the extra slots.
s.execute("prefs.set", json!({"key": "autoSave.maxVersions", "value": 2})).unwrap();
s.autosave_now().unwrap();
let have: Vec<u32> = autosave::existing(&folder, "Intro").iter().map(|e| e.0).collect();
assert_eq!(have, [1, 2]);
// A fresh session continues after the newest file on disk.
let next = autosave::next_slot(&autosave::existing(&folder, "Intro"), None, 2);
let newest = autosave::existing(&folder, "Intro").into_iter().max_by_key(|e| e.2).unwrap().0;
assert_eq!(next, newest % 2 + 1);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn autosave_tick_waits_for_the_interval_and_changes() {
let d = tmp("tick");
let mut s = session_with_comp();
s.execute("file.saveAs", json!({"path": d.join("T.ecproj").to_string_lossy()})).unwrap();
s.execute(
"prefs.set",
json!({"values": {"autoSave.intervalMinutes": 1, "autoSave.location": "custom", "autoSave.folder": d.join("custom").to_string_lossy()}}),
)
.unwrap();
assert!(s.autosave_tick(1000.0).is_none(), "the first tick starts the clock");
s.execute("layer.newNull", json!({})).unwrap();
assert!(s.autosave_tick(1030.0).is_none(), "interval not reached");
let p = s.autosave_tick(1061.0).unwrap().unwrap();
assert!(p.contains("custom") && p.ends_with("T auto-save 1.ecproj"), "{p}");
assert!(s.autosave_tick(1200.0).is_none(), "nothing changed since");
s.execute("prefs.set", json!({"key": "autoSave.enabled", "value": false})).unwrap();
s.execute("layer.newNull", json!({})).unwrap();
assert!(s.autosave_tick(5000.0).is_none(), "disabled");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn crash_recovery_detects_an_unclean_exit() {
let d = tmp("recovery");
let store: Arc<dyn ConfigStore> = Arc::new(DirConfig::new(d.join("config")));
// First run: open a project, auto-save, then "crash" (no end_recovery).
let mut s = session_with_comp();
s.config = Some(store.clone());
assert!(s.begin_recovery().is_none(), "first launch is clean");
let proj = d.join("Crash.ecproj");
s.execute("file.saveAs", json!({"path": proj.to_string_lossy()})).unwrap();
s.execute("layer.newNull", json!({"name": "unsaved work"})).unwrap();
let auto = s.autosave_now().unwrap();
drop(s);
// Second run finds the sentinel and offers the auto-save.
let mut s2 = Session { config: Some(store.clone()), ..Default::default() };
let r = s2.begin_recovery().expect("unclean exit detected");
assert_eq!(r.project.as_deref(), Some(proj.to_string_lossy().as_ref()));
assert_eq!(r.autosave.as_deref(), Some(auto.as_str()));
s2.execute("file.open", json!({"path": r.autosave.unwrap()})).unwrap();
assert!(s2.active_comp().unwrap().layers.iter().any(|l| l.name == "unsaved work"));
// Clean exit: nothing to recover next time.
s2.end_recovery();
let mut s3 = Session { config: Some(store.clone()), ..Default::default() };
assert!(s3.begin_recovery().is_none());
// The setting can turn the offer off.
let mut s4 = Session { config: Some(store), ..Default::default() };
s4.prefs.startup.offer_crash_recovery = false;
assert!(s4.begin_recovery().is_none());
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn open_recent_increment_and_revert() {
let d = tmp("recent");
let a = d.join("A.ecproj");
let mut s = session_with_comp();
s.execute("file.saveAs", json!({"path": a.to_string_lossy()})).unwrap();
let r = s.execute("file.incrementAndSave", json!({})).unwrap();
assert!(r["path"].as_str().unwrap().ends_with("A 2.ecproj"));
let r = s.execute("file.incrementAndSave", json!({})).unwrap();
assert!(r["path"].as_str().unwrap().ends_with("A 3.ecproj"));
assert!(s.prefs.recent_projects[0].ends_with("A 3.ecproj"));
assert!(s.prefs.recent_projects[2].ends_with("A.ecproj"));
s.execute("layer.newNull", json!({})).unwrap();
s.execute("file.revert", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 0);
s.execute("file.openRecent", json!({"index": 2})).unwrap();
assert!(s.path.as_deref().unwrap().ends_with("A.ecproj"));
assert!(s.prefs.recent_projects[0].ends_with("A.ecproj"));
s.execute("file.clearRecent", json!({})).unwrap();
assert!(!s.is_enabled("file.openRecent"));
assert_eq!(autosave::increment_path("/x/Intro 9.ecproj", |_| false), "/x/Intro 10.ecproj");
assert_eq!(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj")), "/x/v1 3.ecproj");
let _ = std::fs::remove_dir_all(d);
}

View File

@ -451,6 +451,12 @@ impl Inner {
}
impl FootageSource for MediaPool {
fn set_cache_budget(&self, bytes: usize) {
self.set_budget(bytes);
}
fn cache_budget(&self) -> Option<usize> {
Some(self.budget())
}
fn frame(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
if footage.missing {
return None;

View File

@ -112,6 +112,29 @@ impl LayerCache {
}
}
/// The memory budget in bytes.
pub fn budget(&self) -> usize {
self.inner.lock().map(|g| g.budget).unwrap_or(0)
}
/// Change the memory budget (evicts least-recently used entries when shrinking).
pub fn set_budget(&self, budget: usize) {
let Ok(mut g) = self.inner.lock() else { return };
g.budget = budget;
if g.bytes > budget {
let mut by_age: Vec<(u64, u64)> = g.map.iter().map(|(k, e)| (e.last_use, *k)).collect();
by_age.sort_unstable();
for (_, k) in by_age {
if g.bytes <= budget {
break;
}
if let Some(e) = g.map.remove(&k) {
g.bytes -= e.bytes;
}
}
}
}
pub fn clear(&self) {
if let Ok(mut g) = self.inner.lock() {
g.map.clear();

View File

@ -37,6 +37,13 @@ pub trait FootageSource: Send + Sync {
fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
None
}
/// Set the decoded-frame cache budget in bytes (Settings ▸ Memory & CPU); sources without
/// a cache ignore it.
fn set_cache_budget(&self, _bytes: usize) {}
/// The decoded-frame cache budget in bytes, if the source has a cache.
fn cache_budget(&self) -> Option<usize> {
None
}
}
/// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]).

View File

@ -38,7 +38,22 @@ pub trait AudioDevice: Send {
}
/// Opens the default output device (`None` when there is none).
pub type AudioDeviceFactory = Box<dyn Fn() -> Option<Box<dyn AudioDevice>>>;
pub type AudioDeviceFactory = Box<dyn Fn(&AudioOutput) -> Option<Box<dyn AudioDevice>>>;
/// Which output to open (Settings ▸ Audio): device name (empty = the system default) and the
/// 1-based device channels for left and right.
#[derive(Clone, Debug, PartialEq)]
pub struct AudioOutput {
pub device: String,
pub left: u32,
pub right: u32,
}
impl AudioOutput {
pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> AudioOutput {
AudioOutput { device: p.audio.output_device.clone(), left: p.audio.output_left, right: p.audio.output_right }
}
}
/// Lock-protected sample queue between the feeder thread and the device callback.
#[derive(Default)]

View File

@ -129,6 +129,24 @@ impl Frames {
v
}
/// Change the RAM preview cache budget (Settings ▸ Memory & CPU), evicting the oldest
/// frames when it shrinks.
pub fn set_budget(&self, bytes: usize) {
if let Ok(mut c) = self.cache.lock() {
c.budget = bytes;
while c.bytes > c.budget {
let Some(old) = c.order.pop_front() else { break };
if let Some(i) = c.map.remove(&old) {
c.bytes -= i.pixels.len() * 4;
}
}
}
}
pub fn budget(&self) -> usize {
self.cache.lock().map(|c| c.budget).unwrap_or(0)
}
pub fn clear(&self) {
if let Ok(mut c) = self.cache.lock() {
c.map.clear();

View File

@ -63,6 +63,8 @@ pub enum Dialog {
TrackTarget,
/// Tracker ▸ Apply (Transform / Stabilize): Apply Dimensions.
TrackApply,
/// Crash recovery: offer the latest auto-save (`EffectcraftApp::recovery`).
Recovery,
}
/// Host hooks provided by the native app (file pickers etc.).
@ -73,6 +75,12 @@ pub struct Hooks {
pub pick_open_project: Option<Box<dyn Fn() -> Option<String>>>,
/// Opens the audio output for preview playback (the desktop app uses cpal).
pub audio_device: Option<audio::AudioDeviceFactory>,
/// Lists audio output devices (Settings ▸ Audio).
pub audio_devices: Option<Box<dyn Fn() -> Vec<String>>>,
/// Picks a folder (Settings paths).
pub pick_folder: Option<Box<dyn Fn() -> Option<String>>>,
/// Save dialog for other file kinds: (default name, extension).
pub pick_save_file: Option<Box<dyn Fn(&str, &str) -> Option<String>>>,
}
#[derive(Default)]
@ -128,6 +136,10 @@ pub struct EffectcraftApp {
pub(crate) waveforms: panels::waveform::Cache,
/// Last reveal shortcut and when (double-press shortcuts such as LL).
pub(crate) last_reveal: Option<(String, f64)>,
/// Settings revision applied to the theme, tooltips and caches.
applied_prefs: Option<u64>,
/// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`).
pub recovery: Option<effectcraft_engine::autosave::Recovery>,
}
impl EffectcraftApp {
@ -164,7 +176,53 @@ impl EffectcraftApp {
meter: Default::default(),
waveforms: Default::default(),
last_reveal: None,
applied_prefs: None,
recovery: None,
}
.with_ui_commands()
}
/// Offer the frontend's commands (tools, timeline navigation…) for keyboard shortcuts.
fn with_ui_commands(mut self) -> Self {
let cmds = menus::UI_COMMANDS
.iter()
.map(|c| effectcraft_engine::shortcuts::UiCommand { id: c.id.into(), label: c.label.into(), shortcut: c.shortcut.map(str::to_string) })
.collect();
self.session.set_ui_commands(cmds);
self
}
/// Show the crash-recovery dialog for a previous run that didn't exit cleanly.
pub fn offer_recovery(&mut self, r: effectcraft_engine::autosave::Recovery) {
if r.autosave.is_some() {
self.recovery = Some(r);
self.dialog = Some(Dialog::Recovery);
}
}
/// Apply changed settings: theme and brightness, label colours, tool tips, preview caches.
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
if self.applied_prefs == Some(self.session.prefs_revision) {
return;
}
self.applied_prefs = Some(self.session.prefs_revision);
let p = &self.session.prefs;
self.ui.theme = theme::ThemeKind::from_name(&p.appearance.theme).unwrap_or_default();
self.tokens = Tokens::from_prefs(p);
theme::apply_visuals(ctx, &self.tokens);
let tips = p.general.show_tool_tips;
ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY });
self.ui.cache_when_idle = p.previews.cache_frames_when_idle;
self.ui.viewer.fast_preview = p.previews.fast_previews;
self.frames.set_budget(p.preview_cache_bytes());
}
/// Change settings from the UI (applied next frame and saved).
pub fn set_pref(&mut self, key: &str, value: serde_json::Value) -> Result<(), String> {
self.session.prefs.set(key, value)?;
self.session.prefs_changed();
self.session.save_prefs();
Ok(())
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
@ -173,9 +231,13 @@ impl EffectcraftApp {
}
pub fn set_theme(&mut self, ctx: &egui::Context, k: theme::ThemeKind) {
self.ui.theme = k;
self.tokens = Tokens::for_kind(k);
theme::apply_visuals(ctx, &self.tokens);
let name = match k {
theme::ThemeKind::Dark => "dark",
theme::ThemeKind::Darker => "darker",
theme::ThemeKind::Light => "light",
};
let _ = self.set_pref("appearance.theme", json!(name));
self.apply_prefs(ctx);
}
pub fn set_workspace(&mut self, name: &str) {
@ -209,8 +271,10 @@ impl EffectcraftApp {
/// The render scale used by the viewer right now.
pub fn viewer_scale(&self, zoom: f32, ppp: f32) -> f64 {
if self.playback.playing && self.ui.viewer.res == state::Resolution::Auto {
// Keep previews snappy: never more than the displayed size.
return self.ui.viewer.res.scale(zoom, ppp).min(0.5);
// Keep previews snappy: half the displayed size, but no lower than Settings ▸
// Previews ▸ Adaptive Resolution Limit.
let full = self.ui.viewer.res.scale(zoom, ppp);
return full.min((full * 0.5).max(self.session.prefs.adaptive_limit()));
}
self.ui.viewer.res.scale(zoom, ppp)
}
@ -291,7 +355,8 @@ impl EffectcraftApp {
if !effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) {
return;
}
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f()) else { return };
let out = audio::AudioOutput::from_prefs(&self.session.prefs);
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return };
match audio::AudioPlayback::start(dev, self.render_source(), cid, t, c.work_area.0, c.work_area.1, self.ui.preview_loop) {
Ok(a) => self.audio = Some(a),
Err(e) => log::warn!("audio preview: {e}"),
@ -546,7 +611,9 @@ impl EffectcraftApp {
self.session.poll_track();
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
self.apply_prefs(&ctx);
self.handle_events(&ctx);
self.tick_autosave(&ctx);
if let Some(rx) = self.command_inbox.take() {
while let Ok(id) = rx.try_recv() {
if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) {
@ -664,6 +731,17 @@ impl EffectcraftApp {
}
impl EffectcraftApp {
/// Auto-save a dirty project when the interval has passed (Settings ▸ Project ▸ Auto-Save).
fn tick_autosave(&mut self, ctx: &egui::Context) {
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0);
if let Some(Err(e)) = self.session.autosave_tick(now) {
self.ui.status = e.to_string();
}
if self.session.prefs.auto_save.enabled {
ctx.request_repaint_after(std::time::Duration::from_secs(30));
}
}
/// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call
/// [`eframe::App::raw_input_hook`] (the headless test harness) feed these in themselves.
pub fn take_synthetic_input(&mut self) -> Vec<egui::Event> {
@ -672,6 +750,11 @@ impl EffectcraftApp {
}
impl eframe::App for EffectcraftApp {
fn on_exit(&mut self) {
// Clean exit: no crash recovery next launch.
self.session.end_recovery();
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
if !self.synthetic.is_empty() {
// Pointer events go one per frame so egui sees press → moves → release as a real drag.

View File

@ -194,7 +194,11 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
}
"view.safeMargins" => app.ui.viewer.safe_margins = !app.ui.viewer.safe_margins,
"view.transparencyGrid" => app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid,
"view.fastPreviews" => app.ui.viewer.fast_preview = !app.ui.viewer.fast_preview,
"view.fastPreviews" => {
let on = !app.ui.viewer.fast_preview;
app.set_pref("previews.fastPreviews", json!(on))?;
app.ui.viewer.fast_preview = on;
}
"timeline.zoomIn" | "timeline.zoomOut" => {
let k = if id == "timeline.zoomIn" { 1.5 } else { 1.0 / 1.5 };
crate::panels::timeline::zoom(app, ctx, k);
@ -311,8 +315,21 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
Value::Null
}
"app.settings" => {
app.dialog_state.settings_page = p.get("page").and_then(Value::as_str).unwrap_or("general").to_string();
app.dialog = Some(crate::Dialog::Settings);
let page = p.get("page").and_then(Value::as_str).unwrap_or("general");
crate::panels::settings::open(app, effectcraft_engine::prefs::page_id(page).unwrap_or("general"));
Value::Null
}
"app.gpuInfo" => {
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
crate::panels::dialogs::info(
app,
"GPU Information",
&format!(
"Compositing: CPU, {threads} threads (pure Rust, rayon).\nDisplay: egui on wgpu.\nLayer cache: {} MB • Preview cache: {} MB.",
app.session.layer_cache.budget() >> 20,
app.frames.budget() >> 20
),
);
Value::Null
}
"app.hide" => {
@ -330,7 +347,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
Value::Null
}
"app.keyboardShortcuts" => {
app.dialog = Some(crate::Dialog::Shortcuts);
crate::panels::shortcut_editor::open(app);
Value::Null
}
"app.templates" => {
@ -350,7 +367,11 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
app.toggle_play(now);
json!({"playing": app.playback.playing})
}
"playback.cacheWhenIdle" => toggle(&mut app.ui.cache_when_idle, &p),
"playback.cacheWhenIdle" => {
let r = toggle(&mut app.ui.cache_when_idle, &p);
app.set_pref("previews.cacheFramesWhenIdle", r.clone())?;
r
}
"playback.audio" => {
let r = toggle(&mut app.ui.preview_audio, &p);
if !app.ui.preview_audio {
@ -571,10 +592,15 @@ fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String {
match e.command.as_str() {
"edit.undo" => app.session.history.undo.last().map(|u| format!("Undo {}", u.0)).unwrap_or_else(|| "Can't Undo".into()),
"edit.redo" => app.session.history.redo.last().map(|u| format!("Redo {}", u.0)).unwrap_or_else(|| "Can't Redo".into()),
_ => e.label.clone(),
_ => effectcraft_engine::menus::entry_label(&app.session, e),
}
}
/// The entry's shortcut in the active keyboard shortcut preset.
fn entry_shortcut(app: &EffectcraftApp, e: &MenuEntry) -> Option<String> {
app.session.shortcuts().shortcut_of(&e.command, &e.params).map(str::to_string)
}
/// Check-mark state of frontend toggles (engine state is answered by the engine).
fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option<bool> {
let v = &app.ui.viewer;
@ -623,7 +649,7 @@ pub fn menu_items(app: &EffectcraftApp) -> Vec<MenuItem> {
id: e.command.clone(),
label: entry_label(app, e),
path,
shortcut: e.shortcut.clone(),
shortcut: entry_shortcut(app, e),
enabled: entry_enabled(app, e),
checked: entry_checked(app, e),
params: e.params.clone(),
@ -718,36 +744,17 @@ fn shifted_alias(k: egui::Key) -> Option<egui::Key> {
/// A key binding: modifiers, key, command id and bound params.
pub type Binding = (egui::Modifiers, egui::Key, String, Value);
/// All active bindings, most modifiers first.
pub fn bindings() -> Vec<Binding> {
/// All bindings of the active keyboard shortcut preset, most modifiers first.
pub fn bindings(session: &effectcraft_engine::Session) -> Vec<Binding> {
let mut v: Vec<Binding> = Vec::new();
let mut push = |sc: &str, id: &str, params: Value| {
for (sc, b) in session.shortcuts().bindings() {
if let Some((m, k)) = parse_shortcut(sc) {
if m.shift
&& let Some(alias) = shifted_alias(k)
{
v.push((m, alias, id.to_string(), params.clone()));
v.push((m, alias, b.command.clone(), b.params.clone()));
}
v.push((m, k, id.to_string(), params));
}
};
// Menu entries (their shortcuts may bind params), then command defaults not in the menus.
let entries = effectcraft_engine::menus::entries();
for (_, e) in &entries {
if let Some(sc) = &e.shortcut {
push(sc, &e.command, e.params.clone());
}
}
for c in effectcraft_engine::command_specs() {
if let Some(sc) = c.shortcut
&& !entries.iter().any(|(_, e)| e.command == c.id)
{
push(sc, c.id, Value::Null);
}
}
for c in UI_COMMANDS {
if let Some(sc) = c.shortcut {
push(sc, c.id, Value::Null);
v.push((m, k, b.command.clone(), b.params.clone()));
}
}
v.sort_by_key(|(m, ..)| std::cmp::Reverse(m.command as u8 + m.shift as u8 + m.alt as u8 + m.ctrl as u8));
@ -779,10 +786,21 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
if events.is_empty() {
return;
}
let binds = bindings();
let binds = bindings(&app.session);
for (key, mods) in events {
// Escape closes dialogs.
if key == egui::Key::Escape && app.dialog.is_some() {
// Escape while recording a shortcut cancels the recording, not the editor.
if crate::panels::shortcut_editor::recording(app) {
continue;
}
// Escape in Settings is Cancel: restore the settings from when it opened.
if app.dialog == Some(crate::Dialog::Settings)
&& let Some(p) = app.dialog_state.prefs_snapshot.take()
{
app.session.prefs = p;
app.session.prefs_changed();
}
app.dialog = None;
continue;
}
@ -834,6 +852,27 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c
MenuNode::Separator => {
ui.separator();
}
MenuNode::Submenu { label, children } if label == "Open Recent" => {
// File ▸ Open Recent: the recent projects (Settings), then the static entries.
ui.menu_button((gutter(false), label.as_str()), |ui| {
ui.set_min_width(320.0);
let recent = app.session.prefs.recent_projects.clone();
if recent.is_empty() {
ui.add_enabled(false, egui::Button::new((gutter(false), "No Recent Projects")));
}
for (i, path) in recent.iter().enumerate() {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
let r = ui.add(egui::Button::new((gutter(false), name))).on_hover_text(path);
app.auto.add(&format!("menu.openRecent.{i}"), r.rect, path);
if r.clicked() {
*clicked = Some(("file.openRecent".into(), json!({"index": i})));
ui.close();
}
}
ui.separator();
menu_nodes(app, ui, children, clicked);
});
}
MenuNode::Submenu { label, children } => {
ui.menu_button((gutter(false), label.as_str()), |ui| {
ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 });
@ -862,8 +901,8 @@ fn gutter(checked: bool) -> egui::Atom<'static> {
fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> bool {
let label = entry_label(app, e);
let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), label));
if let Some(s) = &e.shortcut {
b = b.shortcut_text(shortcut_text(s));
if let Some(s) = entry_shortcut(app, e) {
b = b.shortcut_text(shortcut_text(&s));
}
ui.add_enabled(entry_enabled(app, e), b).clicked()
}
@ -888,8 +927,9 @@ mod tests {
#[test]
fn ui_and_menu_shortcuts_do_not_collide() {
let app = EffectcraftApp::new(effectcraft_engine::Session::default());
let mut seen: std::collections::BTreeMap<(String, String), (String, String)> = Default::default();
for (m, k, id, params) in bindings() {
for (m, k, id, params) in bindings(&app.session) {
let key = (format!("{m:?}"), format!("{k:?}"));
let target = (id.clone(), params.to_string());
if let Some(prev) = seen.get(&key) {
@ -900,6 +940,36 @@ mod tests {
}
}
#[test]
fn dispatch_uses_the_active_custom_preset() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let find = |app: &EffectcraftApp, id: &str| bindings(&app.session).into_iter().filter(|b| b.2 == id).map(|b| (b.0, b.1)).collect::<Vec<_>>();
let (m, k) = parse_shortcut("Cmd+Alt+Shift+Y").unwrap();
assert!(find(&app, "layer.newNull").contains(&(m, k)));
// A UI command rebound in a custom preset.
app.session.execute("shortcuts.set", json!({"command": "tool.hand", "keys": "Ctrl+Alt+H"})).unwrap();
assert_eq!(app.session.keymaps.active, "Custom");
let (m2, k2) = parse_shortcut("Ctrl+Alt+H").unwrap();
assert_eq!(find(&app, "tool.hand"), vec![(m2, k2)]);
app.session.execute("shortcuts.set", json!({"command": "layer.newNull", "keys": "F6"})).unwrap();
assert_eq!(find(&app, "layer.newNull"), vec![(egui::Modifiers::NONE, egui::Key::F6)]);
// The menus show the preset's shortcut.
let item = menu_items(&app).into_iter().find(|i| i.id == "layer.newNull").unwrap();
assert_eq!(item.shortcut.as_deref(), Some("F6"));
// Back to the default preset.
app.session.execute("shortcuts.preset", json!({"op": "select", "name": effectcraft_engine::shortcuts::DEFAULT_PRESET})).unwrap();
assert!(find(&app, "layer.newNull").contains(&(m, k)));
}
#[test]
fn renamed_label_shows_in_the_label_menu() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
app.set_pref("labels.1.name", json!("Sunflower")).unwrap();
let labels: Vec<String> = menu_items(&app).into_iter().filter(|i| i.id == "edit.label").map(|i| i.label).collect();
assert!(labels.contains(&"Sunflower".to_string()), "{labels:?}");
assert!(!labels.contains(&"Yellow".to_string()));
}
#[test]
fn menu_items_cover_the_tree() {
assert_eq!(menus().last(), Some(&"Help"));

View File

@ -22,6 +22,10 @@ pub struct DialogState {
pub palette_sel: usize,
/// Settings dialog page id (`general`, `appearance`…).
pub settings_page: String,
/// Settings when the dialog opened (Cancel restores them).
pub prefs_snapshot: Option<effectcraft_engine::prefs::Prefs>,
/// Keyboard Shortcuts editor.
pub shortcuts: super::shortcut_editor::EditorState,
/// The open parameter form.
pub form: super::forms::Form,
/// Message box (title, body).
@ -101,8 +105,9 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
Dialog::NewComp | Dialog::CompSettings => comp_settings(app, ctx, &t, d == Dialog::CompSettings),
Dialog::SolidSettings => solid(app, ctx, &t),
Dialog::CommandPalette => palette(app, ctx, &t),
Dialog::Settings => super::forms::show_settings(app, ctx, &t),
Dialog::Shortcuts => super::forms::show_shortcuts(app, ctx, &t),
Dialog::Settings => super::settings::show(app, ctx, &t),
Dialog::Shortcuts => super::shortcut_editor::show(app, ctx, &t),
Dialog::Recovery => super::settings::recovery(app, ctx, &t),
Dialog::Form => super::forms::show_form(app, ctx, &t),
Dialog::Info => super::forms::show_info(app, ctx, &t),
Dialog::ViewOptions => super::forms::show_view_options(app, ctx, &t),

View File

@ -1,7 +1,7 @@
//! Menu dialogs: a generic parameter form (numeric Transform dialogs, Mask Feather/Opacity/
//! Expansion, Auto-Orient, Go to Time, Add Guide, Sequence Layers, Interpret Footage, Project
//! Settings, placeholders…), Settings (Preferences), Keyboard Shortcuts, View Options and simple
//! message boxes. Every form runs an engine command with the collected parameters, so whatever a
//! Settings, placeholders…), View Options and simple message boxes (Settings: `settings`;
//! Keyboard Shortcuts: `shortcut_editor`). Every form runs an engine command with the collected parameters, so whatever a
//! dialog does an agent can do with one `engine.execute`.
use egui::{Color32, vec2};
@ -359,147 +359,6 @@ pub fn show_info(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
}
}
pub const SETTINGS_PAGES: [(&str, &str); 17] = [
("general", "General"),
("startup", "Startup & Repair"),
("project", "Project"),
("composition", "Composition"),
("previews", "Previews"),
("appearance", "Appearance"),
("grids", "Grids & Guides"),
("labels", "Labels"),
("type", "Type"),
("import", "Import"),
("export", "Export"),
("audio", "Audio"),
("disk", "Disk"),
("memory", "Memory & CPU"),
("video", "Video"),
("3d", "3D"),
("scripting", "Scripting & Expressions"),
];
pub fn show_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut page = app.dialog_state.settings_page.clone();
let mut close = false;
let mut theme = None;
super::dialogs::modal(ctx, "Settings", vec2(640.0, 440.0), t, |ui| {
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(170.0);
for (id, label) in SETTINGS_PAGES {
if ui.selectable_label(page == id, label).clicked() {
page = id.to_string();
}
}
});
ui.separator();
ui.vertical(|ui| {
let title = SETTINGS_PAGES.iter().find(|p| p.0 == page).map(|p| p.1).unwrap_or("General");
ui.label(egui::RichText::new(title).font(Tokens::semibold(13.0)));
ui.add_space(8.0);
match page.as_str() {
"general" => {
ui.checkbox(&mut app.ui.show_menu_bar, "Show the in-window menu bar");
ui.checkbox(&mut app.ui.snapping, "Snapping");
}
"appearance" => {
for (k, name) in [("dark", "Dark"), ("darker", "Darker"), ("light", "Light")] {
if ui.button(name).clicked() {
theme = crate::theme::ThemeKind::from_name(k);
}
}
}
"previews" => {
ui.checkbox(&mut app.ui.cache_when_idle, "Cache frames when idle");
ui.checkbox(&mut app.ui.viewer.fast_preview, "Fast previews");
}
"grids" => {
ui.checkbox(&mut app.ui.viewer.grid, "Show grid");
ui.checkbox(&mut app.ui.viewer.guides, "Show guides");
ui.checkbox(&mut app.ui.viewer.safe_margins, "Show title/action safe");
}
"labels" => {
for l in effectcraft_engine::color::Label::ALL.into_iter().skip(1) {
ui.horizontal(|ui| {
let [r, g, b] = l.rgb();
let (rect, _) = ui.allocate_exact_size(vec2(26.0, 14.0), egui::Sense::hover());
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(r, g, b));
ui.label(l.name());
});
}
}
_ => {
ui.label(egui::RichText::new("No options on this page yet.").color(t.text_dim));
}
}
});
});
ui.add_space(10.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
close = true;
}
});
});
app.dialog_state.settings_page = page;
if let Some(k) = theme {
app.set_theme(ctx, k);
}
if close {
app.dialog = None;
}
}
pub fn show_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut q = app.dialog_state.palette_query.clone();
let mut close = false;
// (shortcut, where, label)
let mut rows: Vec<(String, String, String)> = vec![];
for (path, e) in effectcraft_engine::menus::entries() {
if let Some(sc) = &e.shortcut {
rows.push((crate::menus::shortcut_text(sc), path.join(" ▸ "), e.label.clone()));
}
}
for c in effectcraft_engine::command_specs().iter().filter(|c| c.menu.is_empty()) {
if let Some(sc) = c.shortcut
&& !rows.iter().any(|r| r.2 == c.label)
{
rows.push((crate::menus::shortcut_text(sc), "Commands".into(), c.label.to_string()));
}
}
for c in crate::menus::UI_COMMANDS {
if let Some(sc) = c.shortcut {
rows.push((crate::menus::shortcut_text(sc), "Panels & Tools".into(), c.label.to_string()));
}
}
super::dialogs::modal(ctx, "Keyboard Shortcuts", vec2(640.0, 520.0), t, |ui| {
ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search commands or shortcuts").desired_width(f32::INFINITY));
ui.add_space(8.0);
let ql = q.to_lowercase();
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
egui::Grid::new("shortcut-grid").num_columns(3).striped(true).spacing([18.0, 4.0]).show(ui, |ui| {
for (sc, place, label) in rows.iter().filter(|r| ql.is_empty() || format!("{} {} {}", r.0, r.1, r.2).to_lowercase().contains(&ql)) {
ui.label(egui::RichText::new(sc).monospace());
ui.label(label);
ui.label(egui::RichText::new(place).color(t.text_dim));
ui.end_row();
}
});
});
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
close = true;
}
});
});
app.dialog_state.palette_query = q;
if close {
app.dialog = None;
}
}
pub fn show_view_options(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut close = false;
super::dialogs::modal(ctx, "View Options", vec2(360.0, 330.0), t, |ui| {

View File

@ -83,8 +83,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ly += 18.0;
}
}
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("Render: {ms:.0} ms • UI {:.0} fps", app.fps), Tokens::ui(11.5), t.text_faint);
// Settings ▸ Composition ▸ Show Rendering Progress in Info Panel.
if app.session.prefs.composition.show_rendering_progress {
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
let rq = app.session.render_job.as_ref().map(|_| " • Rendering queue…").unwrap_or("");
p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("Render: {ms:.0} ms • UI {:.0} fps{rq}", app.fps), Tokens::ui(11.5), t.text_faint);
}
if !app.session.state.selected_keys.is_empty() {
p.text(
pos2(x0, ly + 18.0),

View File

@ -17,6 +17,8 @@ pub mod project;
pub mod properties;
pub mod puppet_tool;
pub mod render_queue;
pub mod settings;
pub mod shortcut_editor;
pub mod text_panels;
pub mod timeline;
pub mod tracker;

View File

@ -217,7 +217,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Tokens::ui(12.0),
if selected { Color32::WHITE } else { t.text },
);
lp.rect_filled(Rect::from_center_size(pos2(r.min.x + name_w + 14.0, r.center().y), vec2(10.0, 10.0)), 2.0, Tokens::label(it.label));
lp.rect_filled(Rect::from_center_size(pos2(r.min.x + name_w + 14.0, r.center().y), vec2(10.0, 10.0)), 2.0, t.label(it.label));
lp.text(pos2(col_type, r.center().y), Align2::LEFT_CENTER, it.type_name(), Tokens::ui(11.5), t.text_dim);
if let Some(b) = file_size(&it) {
lp.text(pos2(col_size, r.center().y), Align2::LEFT_CENTER, fmt_size(b), Tokens::ui(11.5), t.text_dim);

View File

@ -313,7 +313,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" });
let comp_item = app.session.project.item(it.comp);
let label_col = comp_item.map(|i| Tokens::label(i.label)).unwrap_or(t.text_faint);
let label_col = comp_item.map(|i| t.label(i.label)).unwrap_or(t.text_faint);
lp.rect_filled(Rect::from_center_size(pos2(cx_label + 6.0, row.center().y), vec2(10.0, 10.0)), 2.0, label_col);
lp.text(pos2(cx_num + 2.0, row.center().y), Align2::LEFT_CENTER, n.to_string(), Tokens::ui(12.0), t.text_dim);
let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into());

View File

@ -0,0 +1,411 @@
//! The Settings dialog (After Effects' Preferences): the page list on the left, the page on the
//! right, OK / Cancel / Previous / Next. Pages are built from the engine's schema
//! (`effectcraft_engine::prefs::pages`), so the dialog, `prefs.pages` and the docs agree. Edits
//! apply live; Cancel restores the settings from when the dialog opened, OK saves them.
//!
//! Automation ids: `settings.page.<id>`, `settings.<key>` (e.g. `settings.general.undoLevels`),
//! `settings.labels.<n>.name` / `.color`, `settings.button.<label>`, `settings.ok`,
//! `settings.cancel`, `settings.previous`, `settings.next`.
use effectcraft_engine::prefs::{Item, Kind, Page, pages};
use egui::{Color32, RichText, vec2};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
/// Open the dialog on a page, remembering the settings for Cancel.
pub fn open(app: &mut EffectcraftApp, page: &str) {
if app.dialog != Some(Dialog::Settings) {
app.dialog_state.prefs_snapshot = Some(app.session.prefs.clone());
}
app.dialog_state.settings_page = page.to_string();
app.dialog = Some(Dialog::Settings);
}
enum Act {
Set(String, Value),
Run(String, Value),
PickFolder(String),
PickProject(String),
}
fn hex(c: [u8; 3]) -> String {
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
}
fn reg(app: &mut EffectcraftApp, id: &str, r: &egui::Response, label: &str) {
app.auto.add(id, r.rect, label);
}
/// One page's rows.
fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value, t: &Tokens, acts: &mut Vec<Act>) {
let get = |key: &str| cur.pointer(&format!("/{}", key.replace('.', "/"))).cloned().unwrap_or(Value::Null);
let label_w = 250.0;
for item in &page.items {
match *item {
Item::Section(title) => {
ui.add_space(6.0);
ui.label(RichText::new(title).font(Tokens::semibold(12.5)).color(t.tab_text_active));
ui.separator();
}
Item::Note(text) => {
ui.label(RichText::new(text).color(t.text_dim));
}
Item::Button { label, command, params } => {
let r = ui.button(label);
reg(app, &format!("settings.button.{}", label.trim_end_matches('.')), &r, label);
if r.clicked() {
acts.push(Act::Run(command.into(), serde_json::from_str(params).unwrap_or(json!({}))));
}
}
Item::Labels => labels_ui(app, ui, cur, acts),
Item::AudioDevices { key } => {
let devices: Vec<String> = app.hooks.audio_devices.as_ref().map(|f| f()).unwrap_or_default();
let sel = get(key).as_str().unwrap_or("").to_string();
ui.horizontal(|ui| {
ui.add_sized(vec2(label_w, 18.0), egui::Label::new("Default Output:"));
let shown = if sel.is_empty() { "System Default".to_string() } else { sel.clone() };
let r = egui::ComboBox::from_id_salt(("settings", key)).selected_text(shown).width(260.0).show_ui(ui, |ui| {
if ui.selectable_label(sel.is_empty(), "System Default").clicked() {
acts.push(Act::Set(key.into(), json!("")));
}
for d in &devices {
if ui.selectable_label(*d == sel, d).clicked() {
acts.push(Act::Set(key.into(), json!(d)));
}
}
});
reg(app, &format!("settings.{key}"), &r.response, "Default Output");
});
if devices.is_empty() {
ui.label(RichText::new("No device list from this host; the system default output is used.").color(t.text_dim));
}
}
Item::Setting { key, label, kind, live } => {
let v = get(key);
let id = format!("settings.{key}");
let tip = (!live).then_some("Stored, but not used by EffectCraft yet");
let mut resp: Option<egui::Response> = None;
match kind {
Kind::Bool => {
let mut b = v.as_bool().unwrap_or(false);
let r = ui.checkbox(&mut b, label);
if r.changed() {
acts.push(Act::Set(key.into(), json!(b)));
}
resp = Some(r);
}
_ => {
ui.horizontal(|ui| {
ui.add_sized(vec2(label_w, 18.0), egui::Label::new(format!("{label}:")).truncate());
let r = match kind {
Kind::Int(lo, hi, unit) => {
let mut x = v.as_i64().unwrap_or(0);
let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25));
if !unit.is_empty() {
ui.label(RichText::new(unit).color(t.text_dim));
}
if r.changed() {
acts.push(Act::Set(key.into(), json!(x)));
}
r
}
Kind::Float(lo, hi, unit) => {
let mut x = v.as_f64().unwrap_or(0.0);
let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3));
if !unit.is_empty() {
ui.label(RichText::new(unit).color(t.text_dim));
}
if r.changed() {
acts.push(Act::Set(key.into(), json!(x)));
}
r
}
Kind::Slider(lo, hi) => {
let mut x = v.as_f64().unwrap_or(0.0);
ui.label(RichText::new("Darker").color(t.text_dim));
let r = ui.add(egui::Slider::new(&mut x, lo..=hi).show_value(false));
ui.label(RichText::new("Lighter").color(t.text_dim));
if ui.small_button("Default").clicked() {
acts.push(Act::Set(key.into(), json!(0.0)));
}
if r.changed() {
acts.push(Act::Set(key.into(), json!(x)));
}
r
}
Kind::Choice(opts) => {
let curv = v.as_str().unwrap_or("").to_string();
let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string();
egui::ComboBox::from_id_salt(("settings", key))
.selected_text(shown)
.width(220.0)
.show_ui(ui, |ui| {
for (l, val) in opts {
if ui.selectable_label(*val == curv, *l).clicked() {
acts.push(Act::Set(key.into(), json!(val)));
}
}
})
.response
}
Kind::Text | Kind::Path => {
let mut s = v.as_str().unwrap_or("").to_string();
let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(if kind == Kind::Path { 230.0 } else { 260.0 }));
if r.changed() {
acts.push(Act::Set(key.into(), json!(s)));
}
if kind == Kind::Path {
let b = ui.button("Choose...");
reg(app, &format!("{id}.choose"), &b, "Choose...");
if b.clicked() {
acts.push(if key.ends_with("Path") { Act::PickProject(key.into()) } else { Act::PickFolder(key.into()) });
}
}
r
}
Kind::Color => {
let c = crate::panels::viewer::hex_rgb(v.as_str().unwrap_or("")).unwrap_or([128, 128, 128]);
let mut rgb = c;
let r = ui.color_edit_button_srgb(&mut rgb);
ui.label(RichText::new(hex(rgb)).monospace().color(t.text_dim));
if rgb != c {
acts.push(Act::Set(key.into(), json!(hex(rgb))));
}
r
}
Kind::Bool => unreachable!(),
};
resp = Some(r);
});
}
}
if let Some(r) = resp {
let r = match tip {
Some(tip) => r.on_hover_text(tip),
None => r,
};
reg(app, &id, &r, label);
}
}
}
}
}
fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mut Vec<Act>) {
ui.label("Label Colors and Names:");
ui.add_space(4.0);
let labels = cur.get("labels").and_then(Value::as_array).cloned().unwrap_or_default();
egui::Grid::new("settings-labels").num_columns(4).spacing([10.0, 4.0]).show(ui, |ui| {
for (i, l) in labels.iter().enumerate() {
let c = crate::panels::viewer::hex_rgb(l["color"].as_str().unwrap_or("")).unwrap_or([128, 128, 128]);
let mut rgb = c;
let r = ui.color_edit_button_srgb(&mut rgb);
app.auto.add(&format!("settings.labels.{i}.color"), r.rect, "label colour");
if rgb != c {
acts.push(Act::Set(format!("labels.{i}.color"), json!(hex(rgb))));
}
let mut name = l["name"].as_str().unwrap_or("").to_string();
let r = ui.add_sized(vec2(160.0, 20.0), egui::TextEdit::singleline(&mut name));
app.auto.add(&format!("settings.labels.{i}.name"), r.rect, "label name");
if r.changed() {
acts.push(Act::Set(format!("labels.{i}.name"), json!(name)));
}
if i % 2 == 1 {
ui.end_row();
}
}
});
ui.add_space(8.0);
let r = ui.button("Reset Labels");
app.auto.add("settings.button.Reset Labels", r.rect, "Reset Labels");
if r.clicked() {
acts.push(Act::Run("prefs.reset".into(), json!({"page": "labels"})));
}
}
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let all = pages();
let mut page = app.dialog_state.settings_page.clone();
if !all.iter().any(|p| p.id == page) {
page = "general".into();
}
let idx = all.iter().position(|p| p.id == page).unwrap_or(0);
let cur = serde_json::to_value(&app.session.prefs).unwrap_or_default();
let mut acts: Vec<Act> = vec![];
let (mut ok, mut cancel) = (false, false);
super::dialogs::modal(ctx, "Settings", vec2(820.0, 600.0), t, |ui| {
let body_h = 470.0;
ui.allocate_ui_with_layout(vec2(ui.available_width(), body_h), egui::Layout::left_to_right(egui::Align::Min), |ui| {
ui.set_height(body_h);
// Page list (left, like After Effects).
ui.vertical(|ui| {
ui.set_width(180.0);
for p in &all {
let (r, resp) = ui.allocate_exact_size(vec2(176.0, 24.0), egui::Sense::click());
let sel = page == p.id;
if sel {
ui.painter().rect_filled(r, 3.0, t.accent);
} else if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
ui.painter().text(
r.left_center() + vec2(10.0, 0.0),
egui::Align2::LEFT_CENTER,
p.title,
Tokens::ui(12.5),
if sel { Color32::WHITE } else { t.text },
);
app.auto.add(&format!("settings.page.{}", p.id), r, p.title);
if resp.clicked() {
page = p.id.to_string();
}
}
});
ui.separator();
ui.vertical(|ui| {
ui.set_width(590.0);
let p = &all[idx];
ui.label(RichText::new(p.title).font(Tokens::semibold(14.0)).color(t.tab_text_active));
ui.add_space(6.0);
egui::ScrollArea::vertical().max_height(body_h - 30.0).auto_shrink([false, false]).show(ui, |ui| {
page_ui(app, ui, p, &cur, t, &mut acts);
});
});
});
ui.add_space(8.0);
ui.separator();
ui.horizontal(|ui| {
let r = ui.add_enabled(idx > 0, egui::Button::new("Previous"));
app.auto.add("settings.previous", r.rect, "Previous");
if r.clicked() {
page = all[idx - 1].id.to_string();
}
let r = ui.add_enabled(idx + 1 < all.len(), egui::Button::new("Next"));
app.auto.add("settings.next", r.rect, "Next");
if r.clicked() {
page = all[idx + 1].id.to_string();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.add(egui::Button::new(RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
app.auto.add("settings.ok", r.rect, "OK");
ok |= r.clicked();
let r = ui.button("Cancel");
app.auto.add("settings.cancel", r.rect, "Cancel");
cancel |= r.clicked();
});
});
});
app.dialog_state.settings_page = page;
for a in acts {
match a {
Act::Set(k, v) => {
if let Err(e) = app.session.prefs.set(&k, v) {
app.ui.status = e;
}
app.session.prefs_changed();
}
Act::Run(cmd, params) => {
if let Err(e) = crate::menus::invoke(app, ctx, &cmd, params) {
app.ui.status = e;
}
// Buttons such as Reset Settings change the baseline Cancel returns to.
if cmd.starts_with("prefs.") {
app.dialog_state.prefs_snapshot = Some(app.session.prefs.clone());
}
if app.dialog != Some(Dialog::Settings) && app.dialog != Some(Dialog::Info) {
app.dialog = Some(Dialog::Settings);
}
}
Act::PickFolder(k) => {
if let Some(f) = app.hooks.pick_folder.as_ref().and_then(|f| f()) {
let _ = app.session.prefs.set(&k, json!(f));
app.session.prefs_changed();
}
}
Act::PickProject(k) => {
if let Some(f) = app.hooks.pick_open_project.as_ref().and_then(|f| f()) {
let _ = app.session.prefs.set(&k, json!(f));
app.session.prefs_changed();
}
}
}
}
if cancel {
if let Some(p) = app.dialog_state.prefs_snapshot.take() {
app.session.prefs = p;
app.session.prefs_changed();
}
app.dialog = None;
} else if ok {
app.dialog_state.prefs_snapshot = None;
app.session.save_prefs();
app.dialog = None;
}
}
/// Crash recovery: the previous session didn't exit cleanly; offer its latest auto-save.
pub fn recovery(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let Some(r) = app.recovery.clone() else {
app.dialog = None;
return;
};
let mut choice: Option<&str> = None;
super::dialogs::modal(ctx, "Recover Project", vec2(560.0, 230.0), t, |ui| {
ui.label("EffectCraft didn't quit normally last time.");
ui.add_space(6.0);
if let Some(p) = &r.project {
ui.label(format!("Project: {p}"));
}
if let Some(a) = &r.autosave {
let when = std::fs::metadata(a).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).map(|d| {
let m = d.as_secs() / 60;
if m < 1 {
"less than a minute ago".to_string()
} else if m < 120 {
format!("{m} minutes ago")
} else {
format!("{} hours ago", m / 60)
}
});
ui.label(format!("Latest auto-save: {a}"));
if let Some(w) = when {
ui.label(RichText::new(format!("Saved {w}")).color(t.text_dim));
}
}
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let b = ui.add(egui::Button::new(RichText::new(" Open Auto-Save ").color(Color32::WHITE)).fill(t.accent));
app.auto.add("recovery.openAutoSave", b.rect, "Open Auto-Save");
if b.clicked() {
choice = Some("autosave");
}
if r.project.is_some() {
let b = ui.button("Open Last Saved Project");
app.auto.add("recovery.openProject", b.rect, "Open Last Saved Project");
if b.clicked() {
choice = Some("project");
}
}
let b = ui.button("Don't Recover");
app.auto.add("recovery.dismiss", b.rect, "Don't Recover");
if b.clicked() {
choice = Some("none");
}
});
});
let Some(c) = choice else { return };
app.dialog = None;
app.recovery = None;
let path = match c {
"autosave" => r.autosave,
"project" => r.project,
_ => None,
};
if let Some(p) = path
&& let Err(e) = crate::menus::invoke(app, ctx, "file.open", json!({"path": p}))
{
app.ui.status = e;
}
}

View File

@ -0,0 +1,590 @@
//! Edit ▸ Keyboard Shortcuts: After Effects' visual shortcut editor.
//!
//! Top: the preset menu (EffectCraft Default = After Effects' defaults, read-only; custom presets
//! can be duplicated, renamed, deleted, imported and exported) and the search field. Middle: an
//! on-screen keyboard for the toggled (or held) modifiers, keys coloured by what they run in
//! that combination: purple for application-wide commands, green for panel-specific ones, both
//! when a key has each. Clicking a key lists everything bound to it. Bottom: every bindable
//! command with its shortcuts; select one, then record keys (press them in the shortcut field)
//! or assign the key selected on the keyboard. Conflicts are shown before assigning.
//!
//! All changes go through the `shortcuts.*` engine commands, so agents can do the same.
//! Automation ids: `shortcuts.preset`, `shortcuts.search`, `shortcuts.mod.<ctrl|cmd|alt|shift>`,
//! `shortcuts.key.<Key>`, `shortcuts.row.<n>`, `shortcuts.record`, `shortcuts.assign`,
//! `shortcuts.removeKey.<n>`, `shortcuts.resetCommand`, `shortcuts.duplicate`,
//! `shortcuts.rename`, `shortcuts.delete`, `shortcuts.import`, `shortcuts.export`,
//! `shortcuts.resetPreset`, `shortcuts.close`.
use effectcraft_engine::shortcuts::{APP_SCOPE, Bindable, DEFAULT_PRESET, normalize};
use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
/// Editor state (in `DialogState`).
#[derive(Clone, Debug, Default)]
pub struct EditorState {
pub query: String,
/// Binding key of the selected command.
pub selected: Option<String>,
/// Keyboard key selected on the on-screen keyboard (shortcut key name).
pub key: Option<String>,
/// Modifier toggles: Ctrl, Cmd, Alt, Shift.
pub mods: [bool; 4],
/// Recording a shortcut from the keyboard.
pub recording: bool,
/// Recorded (or typed) shortcut waiting to be assigned.
pub pending: String,
/// Rename field for the active preset.
pub rename: Option<String>,
}
const APP_COLOR: Color32 = Color32::from_rgb(0x8e, 0x6c, 0xe8);
const PANEL_COLOR: Color32 = Color32::from_rgb(0x3f, 0xa8, 0x6c);
pub fn open(app: &mut EffectcraftApp) {
app.dialog_state.shortcuts = EditorState::default();
app.dialog = Some(Dialog::Shortcuts);
}
/// `true` while the editor records keys (the dispatcher must not close the dialog on Escape).
pub fn recording(app: &EffectcraftApp) -> bool {
app.dialog == Some(Dialog::Shortcuts) && app.dialog_state.shortcuts.recording
}
/// Shortcut text of a key event (`Cmd+Shift+K`).
pub fn combo_text(m: egui::Modifiers, k: egui::Key) -> Option<String> {
use egui::Key::*;
let key = match k {
Semicolon => ";",
Quote => "'",
Comma => ",",
Period => ".",
Slash => "/",
Backslash => "\\",
Equals => "=",
Minus => "-",
Backtick => "`",
OpenBracket => "[",
CloseBracket => "]",
Plus => "+",
Colon => ";",
_ => k.name(),
};
let mut s = String::new();
if cfg!(target_os = "macos") {
if m.ctrl {
s.push_str("Ctrl+");
}
if m.mac_cmd || m.command {
s.push_str("Cmd+");
}
} else if m.ctrl || m.command {
s.push_str("Cmd+");
}
if m.alt {
s.push_str("Alt+");
}
if m.shift {
s.push_str("Shift+");
}
if key == "+" {
// `Cmd++` names the plus key.
return normalize(&format!("{s}+"));
}
s.push_str(key);
normalize(&s)
}
/// Split normalized shortcut text into (ctrl, cmd, alt, shift) and the key.
fn split(sc: &str) -> ([bool; 4], String) {
let (head, key) = match sc.strip_suffix("++") {
Some(h) => (h.to_string(), "+".to_string()),
None if sc == "+" => (String::new(), "+".into()),
None => match sc.rsplit_once('+') {
Some((h, k)) => (h.to_string(), k.to_string()),
None => (String::new(), sc.to_string()),
},
};
let has = |m: &str| head.split('+').any(|p| p == m);
([has("Ctrl"), has("Cmd"), has("Alt"), has("Shift")], key)
}
fn mods_text(m: [bool; 4]) -> String {
let mut s = String::new();
for (on, n) in m.iter().zip(["Ctrl+", "Cmd+", "Alt+", "Shift+"]) {
if *on {
s.push_str(n);
}
}
s
}
/// The on-screen keyboard: rows of (label, key name, width in key units).
fn keyboard_rows() -> Vec<Vec<(&'static str, &'static str, f32)>> {
let mut rows: Vec<Vec<(&str, &str, f32)>> = vec![];
rows.push(vec![
("Esc", "Escape", 1.0),
("", "", 0.5),
("F1", "F1", 1.0),
("F2", "F2", 1.0),
("F3", "F3", 1.0),
("F4", "F4", 1.0),
("", "", 0.3),
("F5", "F5", 1.0),
("F6", "F6", 1.0),
("F7", "F7", 1.0),
("F8", "F8", 1.0),
("", "", 0.3),
("F9", "F9", 1.0),
("F10", "F10", 1.0),
("F11", "F11", 1.0),
("F12", "F12", 1.0),
]);
let mut r: Vec<(&str, &str, f32)> = vec![("`", "`", 1.0)];
for d in ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"] {
r.push((d, d, 1.0));
}
r.extend([("-", "-", 1.0), ("=", "=", 1.0), ("Delete", "Backspace", 1.6)]);
rows.push(r);
let mut r: Vec<(&str, &str, f32)> = vec![("Tab", "Tab", 1.5)];
for c in ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"] {
r.push((c, c, 1.0));
}
r.extend([("[", "[", 1.0), ("]", "]", 1.0), ("\\", "\\", 1.1)]);
rows.push(r);
let mut r: Vec<(&str, &str, f32)> = vec![("Caps", "", 1.8)];
for c in ["A", "S", "D", "F", "G", "H", "J", "K", "L"] {
r.push((c, c, 1.0));
}
r.extend([(";", ";", 1.0), ("'", "'", 1.0), ("Return", "Enter", 1.8)]);
rows.push(r);
let mut r: Vec<(&str, &str, f32)> = vec![("Shift", "#shift", 2.3)];
for c in ["Z", "X", "C", "V", "B", "N", "M"] {
r.push((c, c, 1.0));
}
r.extend([(",", ",", 1.0), (".", ".", 1.0), ("/", "/", 1.0), ("Shift", "#shift", 2.3)]);
rows.push(r);
let (cmd, alt) = if cfg!(target_os = "macos") { ("⌘ Cmd", "⌥ Opt") } else { ("Ctrl", "Alt") };
rows.push(vec![
("Ctrl", if cfg!(target_os = "macos") { "#ctrl" } else { "#cmd" }, 1.4),
(alt, "#alt", 1.4),
(cmd, "#cmd", 1.6),
("Space", "Space", 6.0),
(cmd, "#cmd", 1.6),
(alt, "#alt", 1.4),
]);
rows
}
/// Navigation and numeric keypad block: (label, key name, column, row).
const SIDE_KEYS: &[(&str, &str, f32, f32)] = &[
("Home", "Home", 0.0, 1.0),
("PgUp", "PageUp", 1.0, 1.0),
("Del", "Delete", 0.0, 2.0),
("End", "End", 1.0, 2.0),
("PgDn", "PageDown", 2.0, 2.0),
("↑", "ArrowUp", 1.0, 4.0),
("←", "ArrowLeft", 0.0, 5.0),
("↓", "ArrowDown", 1.0, 5.0),
("→", "ArrowRight", 2.0, 5.0),
];
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut st = std::mem::take(&mut app.dialog_state.shortcuts);
let mut run: Vec<(String, Value)> = vec![];
let mut close = false;
// Record keys while the shortcut field listens.
if st.recording {
let got = ctx.input(|i| {
i.events.iter().find_map(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)),
_ => None,
})
});
if let Some((k, m)) = got {
st.recording = false;
if k != egui::Key::Escape
&& let Some(txt) = combo_text(m, k)
{
st.pending = txt;
}
}
}
// Modifiers: toggles, or the ones held on the keyboard.
let held = ctx.input(|i| i.modifiers);
let held = [held.ctrl && cfg!(target_os = "macos"), held.command, held.alt, held.shift];
let mods = if held.iter().any(|m| *m) && !st.recording { held } else { st.mods };
let table = app.session.shortcuts().clone();
let presets = app.session.keymaps.names();
let active = app.session.keymaps.active.clone();
// key name → (app?, panel?) for the current modifiers.
let mut keymap: std::collections::HashMap<String, (bool, bool)> = Default::default();
for (sc, b) in table.bindings() {
let (m, k) = split(sc);
if m == mods {
let e = keymap.entry(k).or_default();
if b.scope == APP_SCOPE {
e.0 = true;
} else {
e.1 = true;
}
}
}
super::dialogs::modal(ctx, "Keyboard Shortcuts", vec2(1000.0, 720.0), t, |ui| {
// ---- preset bar
ui.horizontal(|ui| {
ui.label("Preset:");
let r = egui::ComboBox::from_id_salt("sc-preset").selected_text(&active).width(220.0).show_ui(ui, |ui| {
for n in &presets {
if ui.selectable_label(*n == active, n).clicked() {
run.push(("shortcuts.preset".into(), json!({"op": "select", "name": n})));
}
}
});
app.auto.add("shortcuts.preset", r.response.rect, "Preset");
let custom = active != DEFAULT_PRESET;
let b = ui.button("Duplicate");
app.auto.add("shortcuts.duplicate", b.rect, "Duplicate");
if b.clicked() {
run.push(("shortcuts.preset".into(), json!({"op": "duplicate", "from": active, "name": format!("{active} copy")})));
}
if let Some(name) = &mut st.rename {
let r = ui.add(egui::TextEdit::singleline(name).desired_width(140.0));
app.auto.add("shortcuts.renameField", r.rect, "New name");
if ui.button("Rename").clicked() || (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
run.push(("shortcuts.preset".into(), json!({"op": "rename", "name": active, "newName": name})));
st.rename = None;
}
} else {
let b = ui.add_enabled(custom, egui::Button::new("Rename"));
app.auto.add("shortcuts.rename", b.rect, "Rename");
if b.clicked() {
st.rename = Some(active.clone());
}
}
let b = ui.add_enabled(custom, egui::Button::new("Delete"));
app.auto.add("shortcuts.delete", b.rect, "Delete");
if b.clicked() {
run.push(("shortcuts.preset".into(), json!({"op": "delete", "name": active})));
}
let b = ui.add_enabled(custom, egui::Button::new("Reset Preset"));
app.auto.add("shortcuts.resetPreset", b.rect, "Reset Preset");
if b.clicked() {
run.push(("shortcuts.reset".into(), json!({})));
}
let b = ui.button("Import...");
app.auto.add("shortcuts.import", b.rect, "Import");
if b.clicked()
&& let Some(p) = app.hooks.pick_files.as_ref().and_then(|f| f(&["json"]).into_iter().next())
{
run.push(("shortcuts.import".into(), json!({"path": p})));
}
let b = ui.button("Export...");
app.auto.add("shortcuts.export", b.rect, "Export");
if b.clicked()
&& let Some(p) = app.hooks.pick_save_file.as_ref().and_then(|f| f(&format!("{active}.json"), "json"))
{
run.push(("shortcuts.export".into(), json!({"preset": active, "path": p})));
}
});
ui.add_space(6.0);
// ---- modifiers + legend
ui.horizontal(|ui| {
ui.label("Modifiers:");
let names = if cfg!(target_os = "macos") { ["⌃ Ctrl", "⌘ Cmd", "⌥ Opt", "⇧ Shift"] } else { ["", "Ctrl", "Alt", "Shift"] };
for (i, (n, id)) in names.iter().zip(["ctrl", "cmd", "alt", "shift"]).enumerate() {
if n.is_empty() {
continue;
}
let r = ui.add(egui::Button::selectable(mods[i], *n));
app.auto.add(&format!("shortcuts.mod.{id}"), r.rect, n);
if r.clicked() {
st.mods[i] = !st.mods[i];
}
}
ui.add_space(20.0);
for (c, l) in [(APP_COLOR, "Application-wide"), (PANEL_COLOR, "Panel-specific")] {
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
ui.painter().rect_filled(r, 2.0, c);
ui.label(RichText::new(l).color(t.text_dim));
}
});
ui.add_space(6.0);
// ---- keyboard
let unit = 40.0;
let gap = 3.0;
let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), unit * 6.0 + gap * 6.0 + 4.0), Sense::hover());
let p = ui.painter_at(area);
let draw_key = |app: &mut EffectcraftApp, r: Rect, label: &str, name: &str, st: &mut EditorState| {
let mod_idx = match name {
"#ctrl" => Some(0),
"#cmd" => Some(1),
"#alt" => Some(2),
"#shift" => Some(3),
_ => None,
};
let (a, pn) = keymap.get(name).copied().unwrap_or((false, false));
let fill = match mod_idx {
Some(i) if mods[i] => t.accent,
_ => t.field_bg,
};
p.rect_filled(r, 4.0, fill);
if a && pn {
let half = Rect::from_min_max(r.min, pos2(r.center().x, r.max.y));
p.rect_filled(half, 4.0, APP_COLOR);
p.rect_filled(Rect::from_min_max(pos2(r.center().x, r.min.y), r.max), 4.0, PANEL_COLOR);
} else if a {
p.rect_filled(r, 4.0, APP_COLOR);
} else if pn {
p.rect_filled(r, 4.0, PANEL_COLOR);
}
let sel = st.key.as_deref() == Some(name) && !name.is_empty();
p.rect_stroke(r, 4.0, Stroke::new(if sel { 2.0 } else { 1.0 }, if sel { Color32::WHITE } else { t.field_border }), StrokeKind::Inside);
p.text(
r.left_top() + vec2(5.0, 4.0),
Align2::LEFT_TOP,
label,
Tokens::ui(11.0),
if a || pn || mod_idx.is_some_and(|i| mods[i]) { Color32::WHITE } else { t.text },
);
if name.is_empty() {
return;
}
let id = format!("shortcuts.key.{}", name.trim_start_matches('#'));
app.auto.add(&id, r, label);
let resp = ui.interact(r, egui::Id::new(("sc-key", name, r.min.x as i32)), Sense::click());
if resp.clicked() {
if let Some(i) = mod_idx {
st.mods[i] = !st.mods[i];
} else {
st.key = Some(name.to_string());
st.pending = format!("{}{}", mods_text(mods), name);
}
}
if !name.starts_with('#') {
let who: Vec<String> =
table.bindings().into_iter().filter(|(sc, _)| split(sc) == (mods, name.to_string())).map(|(_, b)| b.label.clone()).collect();
if !who.is_empty() {
resp.on_hover_text(who.join("\n"));
}
}
};
let mut y = area.min.y + 2.0;
for row in keyboard_rows() {
let mut x = area.min.x + 2.0;
for (label, name, w) in row {
let r = Rect::from_min_size(pos2(x, y), vec2(unit * w - gap, unit - gap));
if !(label.is_empty() && name.is_empty()) {
draw_key(app, r, label, name, &mut st);
}
x += unit * w;
}
y += unit + gap;
}
let side_x = area.min.x + unit * 16.0;
for (label, name, c, r) in SIDE_KEYS {
let rect = Rect::from_min_size(pos2(side_x + c * unit, area.min.y + 2.0 + r * (unit + gap)), vec2(unit - gap, unit - gap));
draw_key(app, rect, label, name, &mut st);
}
// Key details: everything bound to the selected key.
let info_x = side_x + unit * 3.5;
let info = Rect::from_min_max(pos2(info_x, area.min.y + 2.0), pos2(area.max.x - 2.0, area.max.y - 2.0));
p.rect_filled(info, 4.0, t.row);
let p = ui.painter_at(info.shrink(2.0));
if let Some(k) = &st.key {
p.text(info.left_top() + vec2(8.0, 6.0), Align2::LEFT_TOP, format!("Key: {k}"), Tokens::semibold(12.0), t.tab_text_active);
let mut yy = info.min.y + 26.0;
let mut list: Vec<(String, String, String)> =
table.bindings().into_iter().filter(|(sc, _)| split(sc).1 == *k).map(|(sc, b)| (sc.to_string(), b.label.clone(), b.scope.clone())).collect();
list.sort();
if list.is_empty() {
p.text(pos2(info.min.x + 8.0, yy), Align2::LEFT_TOP, "Not assigned", Tokens::ui(11.5), t.text_dim);
}
for (sc, label, scope) in list.into_iter().take(9) {
let col = if scope == APP_SCOPE { APP_COLOR } else { PANEL_COLOR };
p.text(pos2(info.min.x + 8.0, yy), Align2::LEFT_TOP, crate::menus::shortcut_text(&sc), Tokens::ui(11.5), col);
p.text(pos2(info.min.x + 78.0, yy), Align2::LEFT_TOP, label, Tokens::ui(11.5), t.text);
yy += 17.0;
}
} else {
p.text(info.left_top() + vec2(8.0, 6.0), Align2::LEFT_TOP, "Click a key to see its commands.", Tokens::ui(11.5), t.text_dim);
}
ui.add_space(4.0);
// ---- search + command list + editor
ui.horizontal(|ui| {
let r = ui.add(egui::TextEdit::singleline(&mut st.query).hint_text("Search commands or shortcuts").desired_width(360.0));
app.auto.add("shortcuts.search", r.rect, "Search");
});
ui.add_space(4.0);
let q = st.query.to_lowercase();
let rows: Vec<&Bindable> = table
.bindables
.iter()
.filter(|b| {
if q.is_empty() {
return true;
}
let keys = table.keys.get(&b.key).map(|k| k.join(" ")).unwrap_or_default();
let hay = format!("{} {} {} {}", b.label, b.command, b.path.join(" "), keys).to_lowercase();
q.split_whitespace().all(|w| hay.contains(w))
})
.collect();
ui.allocate_ui_with_layout(vec2(ui.available_width(), 270.0), egui::Layout::left_to_right(egui::Align::Min), |ui| {
ui.set_height(270.0);
// Command list.
ui.vertical(|ui| {
ui.set_width(620.0);
let row_h = 20.0;
egui::ScrollArea::vertical().id_salt("sc-rows").max_height(230.0).auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, range| {
for i in range {
let b = rows[i];
let keys = table.keys.get(&b.key).cloned().unwrap_or_default();
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
let sel = st.selected.as_deref() == Some(b.key.as_str());
if sel {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if i % 2 == 1 {
ui.painter().rect_filled(r, 0.0, t.row_alt);
}
let pp = ui.painter();
pp.text(pos2(r.min.x + 6.0, r.center().y), Align2::LEFT_CENTER, &b.label, Tokens::ui(11.5), t.text);
pp.text(pos2(r.min.x + 230.0, r.center().y), Align2::LEFT_CENTER, b.path.join(" > "), Tokens::ui(10.5), t.text_faint);
let ks: Vec<String> = keys.iter().map(|k| crate::menus::shortcut_text(k)).collect();
let col = if b.scope == APP_SCOPE { APP_COLOR } else { PANEL_COLOR };
pp.text(
pos2(r.max.x - 8.0, r.center().y),
Align2::RIGHT_CENTER,
ks.join(", "),
Tokens::ui(11.5),
if keys.is_empty() { t.text_faint } else { col },
);
app.auto.add(&format!("shortcuts.row.{i}"), r, &b.label);
if resp.clicked() {
st.selected = Some(b.key.clone());
st.pending.clear();
}
}
});
ui.label(RichText::new(format!("{} of {} commands", rows.len(), table.bindables.len())).color(t.text_faint));
});
ui.separator();
// Selected command editor.
ui.vertical(|ui| {
ui.set_width(310.0);
let Some(b) = st.selected.as_ref().and_then(|k| table.find(k)).cloned() else {
ui.label(RichText::new("Select a command to change its shortcut.").color(t.text_dim));
return;
};
ui.label(RichText::new(&b.label).font(Tokens::semibold(13.0)).color(t.tab_text_active));
ui.label(RichText::new(format!("{} • {}", b.command, b.scope)).color(t.text_faint));
ui.add_space(6.0);
let keys = table.keys.get(&b.key).cloned().unwrap_or_default();
if keys.is_empty() {
ui.label(RichText::new("No shortcut").color(t.text_dim));
}
for (n, k) in keys.iter().enumerate() {
ui.horizontal(|ui| {
ui.label(RichText::new(crate::menus::shortcut_text(k)).monospace());
let r = ui.small_button("Remove");
app.auto.add(&format!("shortcuts.removeKey.{n}"), r.rect, "Remove");
if r.clicked() {
let rest: Vec<&String> = keys.iter().filter(|x| *x != k).collect();
run.push(("shortcuts.set".into(), json!({"command": b.command, "params": b.params, "keys": rest})));
}
});
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let hint = if st.recording { "Press keys…" } else { "Click, then press keys" };
let r = ui.add(egui::TextEdit::singleline(&mut st.pending).hint_text(hint).desired_width(170.0));
app.auto.add("shortcuts.record", r.rect, "Shortcut");
if r.gained_focus() || (r.clicked() && !st.recording) {
st.recording = true;
}
if r.lost_focus() {
st.recording = false;
}
let norm = normalize(&st.pending);
let b2 = ui.add_enabled(norm.is_some(), egui::Button::new("Assign"));
app.auto.add("shortcuts.assign", b2.rect, "Assign");
if b2.clicked()
&& let Some(k) = norm
{
let mut all = keys.clone();
if !all.contains(&k) {
all.push(k);
}
run.push(("shortcuts.set".into(), json!({"command": b.command, "params": b.params, "keys": all})));
st.pending.clear();
}
});
if let Some(k) = normalize(&st.pending) {
let conflicts = table.conflicts(&b, &k);
if !conflicts.is_empty() {
ui.add_space(4.0);
ui.label(RichText::new(format!("⚠ {} is used by:", crate::menus::shortcut_text(&k))).color(t.warning));
for c in conflicts.iter().take(4) {
ui.label(RichText::new(format!(" {} ({})", c.label, c.scope)).color(t.warning));
}
}
}
ui.add_space(6.0);
let r = ui.add_enabled(keys != b.defaults, egui::Button::new("Reset to Default"));
app.auto.add("shortcuts.resetCommand", r.rect, "Reset to Default");
if r.clicked() {
run.push(("shortcuts.reset".into(), json!({"command": b.command, "params": b.params})));
}
if !b.defaults.is_empty() {
let d: Vec<String> = b.defaults.iter().map(|k| crate::menus::shortcut_text(k)).collect();
ui.label(RichText::new(format!("Default: {}", d.join(", "))).color(t.text_faint));
}
if active == DEFAULT_PRESET {
ui.add_space(6.0);
ui.label(RichText::new("Changing a shortcut creates a custom preset; the default stays as it is.").color(t.text_faint).small());
}
});
});
ui.add_space(6.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.add(egui::Button::new(RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
app.auto.add("shortcuts.close", r.rect, "OK");
close |= r.clicked();
});
});
app.dialog_state.shortcuts = st;
for (cmd, params) in run {
if let Err(e) = app.session.execute(&cmd, params) {
app.ui.status = e.to_string();
}
}
if close {
app.dialog = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn combo_text_round_trips_through_the_parser() {
let cases = [
(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::K),
(egui::Modifiers::ALT, egui::Key::OpenBracket),
(egui::Modifiers::NONE, egui::Key::F9),
];
for (m, k) in cases {
let txt = combo_text(m, k).unwrap();
let (pm, pk) = crate::menus::parse_shortcut(&txt).unwrap();
assert_eq!(pk, k, "{txt}");
assert_eq!((pm.command, pm.alt, pm.shift), (m.command, m.alt, m.shift), "{txt}");
}
assert_eq!(split("Cmd+Shift+K"), ([false, true, false, true], "K".into()));
assert_eq!(split("Cmd++"), ([false, true, false, false], "+".into()));
assert_eq!(split("F9"), ([false; 4], "F9".into()));
}
}

View File

@ -635,7 +635,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let x = tm.x(m.time.seconds());
pr.add(egui::Shape::convex_polygon(
vec![pos2(x - 4.0, ruler.max.y - 10.0), pos2(x + 4.0, ruler.max.y - 10.0), pos2(x, ruler.max.y - 4.0)],
Tokens::label(m.label),
t.label(m.label),
Stroke::NONE,
));
}
@ -763,11 +763,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// Label swatch.
let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0));
lp.rect_filled(lr, 2.0, Tokens::label(layer.label));
lp.rect_filled(lr, 2.0, t.label(layer.label));
let lresp = ui.interact(lr, egui::Id::new(("label", layer.id.0)), Sense::click());
lresp.context_menu(|ui| {
for lab in effectcraft_engine::color::Label::ALL {
if ui.button(lab.name()).clicked() {
let name = if lab == effectcraft_engine::color::Label::None { lab.name().to_string() } else { app.session.prefs.label_name(lab) };
if ui.button(name).clicked() {
actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()})));
ui.close();
}
@ -940,7 +941,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
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 + 3.0), pos2(x_out, r.max.y - 3.0));
let lc = Tokens::label(layer.label);
let lc = t.label(layer.label);
let fill = if is_sel { lc.gamma_multiply(0.85) } else { lc.gamma_multiply(0.55) };
gp.rect_filled(bar, 2.0, fill);
if is_sel {

View File

@ -331,7 +331,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Guides.
if app.ui.viewer.safe_margins {
for (k, a) in [(0.9, 120u8), (0.8, 160)] {
let g = &app.session.prefs.grids;
let (act, title) = (1.0 - g.action_safe as f32 / 100.0, 1.0 - g.title_safe as f32 / 100.0);
for (k, a) in [(act, 120u8), (title, 160)] {
let r = Rect::from_center_size(comp_rect.center(), comp_rect.size() * k);
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_white_alpha(a)), StrokeKind::Middle);
}
@ -340,22 +342,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
painter.line_segment([c - vec2(0.0, 10.0), c + vec2(0.0, 10.0)], Stroke::new(1.0, Color32::from_white_alpha(140)));
}
if app.ui.viewer.grid {
let step = 100.0 * zoom;
// Settings ▸ Grids & Guides: gridline spacing, subdivisions and colour.
let g = &app.session.prefs.grids;
let [r, gg, b] = hex_rgb(&g.grid_color).unwrap_or([120, 160, 255]);
let major = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 90));
let minor = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 35));
let step = (g.grid_spacing as f32 * zoom).max(2.0);
let sub = g.grid_subdivisions.max(1) as f32;
let sstep = step / sub;
let draw_minor = sstep >= 4.0;
let mut i = 0u32;
let mut gx = comp_rect.min.x;
while gx <= comp_rect.max.x {
painter.line_segment([pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 160, 255, 50)));
gx += step;
let is_major = i.is_multiple_of(g.grid_subdivisions.max(1));
if is_major || draw_minor {
painter.line_segment([pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], if is_major { major } else { minor });
}
gx += sstep;
i += 1;
}
let mut i = 0u32;
let mut gy = comp_rect.min.y;
while gy <= comp_rect.max.y {
painter.line_segment([pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 160, 255, 50)));
gy += step;
let is_major = i.is_multiple_of(g.grid_subdivisions.max(1));
if is_major || draw_minor {
painter.line_segment([pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], if is_major { major } else { minor });
}
gy += sstep;
i += 1;
}
}
// Comp guides (View ▸ Show Guides) and the region of interest.
if app.ui.viewer.guides {
let stroke = Stroke::new(1.0, Color32::from_rgb(0x3c, 0xc8, 0xf0));
let [r, gg, b] = hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]);
let stroke = Stroke::new(1.0, Color32::from_rgb(r, gg, b));
for g in &comp.guides {
if g.vertical {
let x = comp_rect.min.x + g.position as f32 * zoom;
@ -384,8 +405,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.data_mut(|d| d.remove::<(LayerId, u64)>(pen_id()));
}
if app.ui.viewer.show_layer_controls {
// Settings ▸ General ▸ Path Point and Handle Size; Appearance ▸ Use Label Color for
// Layer Handles and Paths.
let hs = app.session.prefs.general.path_point_size as f32 + 2.0;
let vs = app.session.prefs.general.path_point_size as f32 + 1.0;
let label_handles = app.session.prefs.appearance.use_label_color_for_handles;
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) {
let col = Tokens::label(l.label);
let col = if label_handles { t.label(l.label) } else { t.accent };
// Motion path of animated position.
if let Some(pos) = l.transform().and_then(|tr| tr.get("position"))
&& pos.keys.len() > 1
@ -450,10 +476,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
painter.circle_filled(h, 3.0, mc);
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: m });
}
painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, mc);
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, mc);
} else {
painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, Color32::from_black_alpha(160));
painter.rect_stroke(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160));
painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
}
app.auto.add(&format!("viewer.mask.{}.vertex.{i}", g.uid), Rect::from_center_size(s, vec2(8.0, 8.0)), &g.name);
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: s, tangent: None, vertex: *v, l2c: m });
@ -479,7 +505,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
painter.add(egui::Shape::closed_line(sq.clone(), Stroke::new(1.0, col)));
for i in 0..8 {
let hp = if i < 4 { sq[i] } else { sq[i - 4] + (sq[(i - 3) % 4] - sq[i - 4]) * 0.5 };
let hr = Rect::from_center_size(hp, vec2(HANDLE, HANDLE));
let hr = Rect::from_center_size(hp, vec2(hs, hs));
painter.rect_filled(hr, 0.0, col);
painter.rect_stroke(hr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(120)), StrokeKind::Outside);
handle_hits.push((l.id, i, hp));
@ -1019,7 +1045,7 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap,
};
for l in ectx.comp.layers.iter().filter(|l| (l.is_camera() || l.is_light()) && l.is_active_at(ectx.time)) {
let sel = selected.contains(&l.id);
let col = Tokens::label(l.label).gamma_multiply(if sel { 1.0 } else { 0.6 });
let col = app.tokens.label(l.label).gamma_multiply(if sel { 1.0 } else { 0.6 });
let stroke = Stroke::new(if sel { 1.5 } else { 1.0 }, col);
let (eye, fwd, down) = three_d::camera::layer_frame(ectx, l);
let poi = |l: &Layer| -> Option<Vec3> {
@ -1372,3 +1398,13 @@ fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
app.auto.add("viewer.empty.import", b2, "New Composition From Footage");
}
/// `#rrggbb` → sRGB bytes.
pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> {
let h = s.trim().trim_start_matches('#');
if h.len() != 6 || !h.is_ascii() {
return None;
}
let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
Some([p(0)?, p(2)?, p(4)?])
}

View File

@ -66,6 +66,8 @@ pub struct Tokens {
pub timecode: Color32,
pub danger: Color32,
pub warning: Color32,
/// Label colours (`Label::ALL` order), from Settings ▸ Labels.
pub labels: [Color32; 17],
pub radius: f32,
pub radius_sm: f32,
pub gap: f32,
@ -112,6 +114,7 @@ impl Tokens {
timecode: Color32::from_rgb(0x3d, 0x8f, 0xf5),
danger: Color32::from_rgb(0xe0, 0x4a, 0x3c),
warning: Color32::from_rgb(0xe8, 0x9a, 0x2c),
labels: default_labels(),
radius: 6.0,
radius_sm: 3.0,
gap: 4.0,
@ -172,11 +175,65 @@ impl Tokens {
pub fn medium(size: f32) -> FontId {
FontId::new(size, FontFamily::Name("medium".into()))
}
/// sRGB colour of an item/layer label.
pub fn label(l: effectcraft_color::Label) -> Color32 {
/// sRGB colour of an item/layer label (Settings ▸ Labels).
pub fn label(&self, l: effectcraft_color::Label) -> Color32 {
let i = effectcraft_color::Label::ALL.iter().position(|x| *x == l).unwrap_or(0);
self.labels[i]
}
/// Theme tokens for the current settings: theme, UI brightness and label colours.
pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> Tokens {
let kind = ThemeKind::from_name(&p.appearance.theme).unwrap_or_default();
let mut t = Tokens::for_kind(kind).with_brightness(p.appearance.brightness as f32);
for (i, l) in effectcraft_color::Label::ALL.iter().enumerate() {
let [r, g, b] = p.label_rgb(*l);
t.labels[i] = Color32::from_rgb(r, g, b);
}
t
}
/// Lighten (b > 0) or darken (b < 0) the interface greys, like After Effects' brightness
/// slider. Accent colours, labels and keyframe colours are kept.
pub fn with_brightness(mut self, b: f32) -> Tokens {
let b = b.clamp(-1.0, 1.0);
if b.abs() < 1e-3 {
return self;
}
let adj = |c: &mut Color32| {
let f = |v: u8| -> u8 {
let v = v as f32;
(if b > 0.0 { v + (110.0 - v).max(0.0) * b * 0.8 + 10.0 * b } else { v * (1.0 + b * 0.6) }).round().clamp(0.0, 255.0) as u8
};
*c = Color32::from_rgb(f(c.r()), f(c.g()), f(c.b()));
};
for c in [
&mut self.app_bg,
&mut self.header_bg,
&mut self.panel_bg,
&mut self.hover,
&mut self.pressed,
&mut self.field_bg,
&mut self.field_border,
&mut self.separator,
&mut self.row,
&mut self.row_alt,
&mut self.row_selected,
&mut self.tl_bg,
&mut self.tl_ruler_bg,
&mut self.pasteboard,
&mut self.work_area,
] {
adj(c);
}
self
}
}
fn default_labels() -> [Color32; 17] {
effectcraft_color::Label::ALL.map(|l| {
let [r, g, b] = l.rgb();
Color32::from_rgb(r, g, b)
}
})
}
static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");

View File

@ -125,3 +125,9 @@ the JSON control channel and the MCP server all dispatch by id, so anything a pe
menu, an agent can do too. Every interactive widget registers an automation id, so agents can also
inspect and click the interface. See [agents.md](agents.md) and
[control-protocol.md](control-protocol.md).
Settings (`engine::prefs`), keyboard shortcut presets (`engine::shortcuts`) and auto-save /
crash recovery (`engine::autosave`) live in the engine too; they persist through a
`ConfigStore` the frontend provides (a directory on the desktop, `localStorage` on the web).
The shortcut dispatcher and the menus read the active preset. See
[preferences.md](preferences.md).

176
docs/preferences.md Normal file
View File

@ -0,0 +1,176 @@
# Settings, keyboard shortcuts, auto-save and crash recovery
## Settings (Preferences)
EffectCraft ▸ Settings (macOS) or Edit ▸ Preferences (Windows/Linux), `Cmd+Alt+;`. The dialog has
After Effects 2026's pages (General, Startup & Repair, Project, Composition, Previews, Appearance,
Grids & Guides, Labels, Type, Import, Export, Audio, Disk, Memory & CPU, Video, 3D, Scripting &
Expressions) with OK, Cancel, Previous and Next. Older page names still open the right page
(`prefs.open {"page": "Auto-Save"}` opens Project, `Media & Disk Cache` opens Disk, `Memory &
Performance` opens Memory & CPU).
The model is `effectcraft_engine::prefs::Prefs`: serde, versioned (`version`), addressed by dotted
keys (`general.undoLevels`, `autoSave.intervalMinutes`, `labels.3.name`). It is stored as
`prefs.json` through the session's `ConfigStore` (the desktop app uses the platform config
directory: `~/Library/Application Support/EffectCraft` on macOS, `%APPDATA%\EffectCraft` on
Windows, `$XDG_CONFIG_HOME/effectcraft` on Linux; the web app can back the same trait with
`localStorage`). Loading migrates older layouts, keeps unknown keys (from newer versions) and
falls back to defaults for values that don't parse.
Commands (CLI, MCP, control channel):
| Command | Parameters |
|---|---|
| `prefs.get` | `{key?}` (omit for everything) |
| `prefs.set` | `{key, value}` or `{values: {key: value}}` |
| `prefs.reset` | `{page?}` (omit for everything) |
| `prefs.open` | `{page?}` opens the Settings dialog |
| `prefs.pages` | the page schema: every row, its key, type, range and whether it is wired |
### Settings that change behaviour
- `general.undoLevels`: Levels of Undo
- `general.pathPointSize`: Path Point and Handle Size
- `general.recentItems`: Recent Projects Shown
- `general.showToolTips`: Show Tool Tips
- `general.createLayersAtCompStart`: Create Layers at Composition Start Time
- `general.defaultSpatialLinear`: Default Spatial Interpolation to Linear
- `startup.showHomeOnLaunch`: Show Home Screen When Launching
- `startup.offerCrashRecovery`: Offer to Open the Latest Auto-Save After a Crash
- `project.useTemplate`: New Project Loads Template
- `project.templatePath`: Template Project
- `autoSave.enabled`: Automatically Save Projects
- `autoSave.intervalMinutes`: Save Every
- `autoSave.maxVersions`: Maximum Project Versions
- `autoSave.location`: Auto-Save Location
- `autoSave.folder`: Custom Location
- `autoSave.saveOnRenderStart`: Save When Starting Render Queue
- `composition.showRenderingProgress`: Show Rendering Progress in Info Panel and Flowchart
- `previews.adaptiveResolutionLimit`: Adaptive Resolution Limit
- `previews.cacheFramesWhenIdle`: Cache Frames When Idle
- `previews.fastPreviews`: Fast Previews (Draft 3D, Faster Effects)
- `appearance.theme`: Theme
- `appearance.brightness`: Brightness
- `appearance.useLabelColorForHandles`: Use Label Color for Layer Handles and Paths
- `grids.gridColor`: Color
- `grids.gridSpacing`: Gridline Every
- `grids.gridSubdivisions`: Subdivisions
- `grids.guideColor`: Color
- `grids.actionSafe`: Action-safe
- `grids.titleSafe`: Title-safe
- `import.stillFootage`: Still Footage
- `import.stillSeconds`: Still Duration
- `import.sequenceFps`: Sequence Footage
- `audio.outputDevice`: Default Output (device list from the desktop host)
- `audio.outputLeft`: Left (Audio Output Mapping)
- `audio.outputRight`: Right (Audio Output Mapping)
- `memory.layerCacheMb`: Layer Cache
- `memory.mediaCacheMb`: Footage Frame Cache
- `memory.previewCacheMb`: Preview (RAM) Cache
- `threeD.defaultRenderer`: Default 3D Renderer
- `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu,
the timeline, project panel, render queue and viewer handles
### Not wired yet (TODO)
These rows are in the dialog for After Effects parity and are stored, but nothing reads them yet.
`prefs.pages` reports them with `"live": false`. A test keeps this list in step with the schema.
- `general.switchesAffectNestedComps`: Switches Affect Nested Comps
- `general.preserveConstantVertexCount`: Preserve Constant Vertex and Feather Point Count when Editing Masks
- `general.syncTimeRelatedItems`: Synchronize Time of All Related Items
- `general.expressionPickWhipCompact`: Expression Pick Whip Writes Compact English
- `general.createSplitLayersAbove`: Create Split Layers Above Original Layer
- `general.useSystemColorPicker`: Use System Color Picker
- `startup.showHomeOnOpenProject`: Show Home Screen When Opening a Project
- `composition.motionPath`: Motion Path
- `composition.motionPathSeconds`: Seconds
- `composition.motionPathKeyframes`: Keyframes
- `composition.hardwareAcceleratePanels`: Hardware Accelerate Composition, Layer and Footage Panels
- `previews.showInternalWireframes`: Show Internal Wireframes
- `previews.zoomQuality`: Viewer Zoom Quality
- `appearance.useLabelColorForTabs`: Use Label Color for Related Tabs
- `appearance.cycleMaskColors`: Cycle Mask Colors
- `appearance.useGradients`: Use Gradients
- `grids.gridStyle`: Style
- `grids.proportionalHorizontal`: Horizontal
- `grids.proportionalVertical`: Vertical
- `grids.guideStyle`: Style
- `type.textEngine`: Text Engine
- `type.fontPreview`: Show Font Preview
- `type.recentFonts`: Number of Recent Fonts to Display
- `type.fontNamesInEnglish`: Show Font Names in English
- `import.reportMissingFrames`: Report Missing Frames
- `import.unlabeledAlpha`: Interpret Unlabeled Alpha As
- `import.dragImportAs`: Default Drag Import As
- `export.defaultOutputFolder`: Default Output Folder
- `export.segmentSequences`: Segment Sequences
- `export.segmentSequenceFiles`: Files per Segment
- `export.segmentMovies`: Segment Movie Files
- `export.segmentMovieMb`: Segment Size
- `export.appendBitsToName`: Append Bit Depth to File Name
- `audio.previewSampleRate`: Preview Sample Rate
- `disk.diskCacheEnabled`: Enable Disk Cache
- `disk.diskCacheMaxGb`: Maximum Disk Cache Size
- `disk.diskCacheFolder`: Disk Cache Folder
- `disk.mediaCacheFolder`: Database and Cache Folder
- `disk.conformedMediaFolder`: Conformed Audio Folder
- `memory.ramReservedGb`: RAM Reserved for Other Applications
- `memory.reduceCacheWhenLow`: Reduce Cache Size When System Is Low on Memory
- `video.enableOutput`: Enable Video Preview Output
- `video.device`: Video Device
- `video.outputDuringPlayback`: Video Output During Playback
- `video.mirrorOnMonitor`: Mirror on Computer Monitor
- `video.disableWhenBackground`: Disable Video Output When in Background
- `threeD.showReferenceAxes`: Show 3D Reference Axes
- `threeD.extendedViewer`: Extended Viewer
- `threeD.realtimeShadows`: Realtime Shadows in Draft
- `scripting.allowScriptsWriteFiles`: Allow Scripts to Write Files and Access Network
- `scripting.warnExecutingFiles`: Warn User When Executing Files
- `scripting.enableJsDebugger`: Enable JavaScript Debugger
- `scripting.editorFontSize`: Font Size
- `scripting.syntaxHighlighting`: Syntax Highlighting
- `scripting.lineNumbers`: Line Numbers
- `scripting.autoComplete`: Auto-complete
- `scripting.bracketMatching`: Bracket Matching
- `scripting.wordWrap`: Word Wrap
- `scripting.errorBanner`: Show Expression Error Banner
## Keyboard shortcuts
Edit ▸ Keyboard Shortcuts (`Cmd+Alt+'`) opens the shortcut editor: an on-screen keyboard showing
which keys are assigned for the held modifiers (purple: application-wide, green: panel-specific,
both: split), modifier toggles, a searchable list of every command (all engine commands, menu
entries with bound parameters and the frontend's tool / panel commands), and a preset menu.
- **Presets.** "EffectCraft Default" is After Effects' default layout and is read-only; editing
it creates a "Custom" copy. Custom presets store only their differences from the default and
can be duplicated, renamed, deleted, exported and imported (JSON). The active preset is what
the menus show and what the shortcut dispatcher runs.
- **Assigning.** Select a command, then press keys in the shortcut field (or type them), or
click a key on the keyboard with the modifiers toggled. Conflicts with other commands in an
overlapping scope are listed before you assign.
- **Scope.** Menu and engine commands are application-wide; timeline commands work in the
Timeline and viewer commands in the Composition panel.
Commands: `shortcuts.list {query?, keys?, assigned?}`, `shortcuts.set {command, params?, keys}`,
`shortcuts.reset {command?}`, `shortcuts.preset {op: select|new|duplicate|delete|rename, name?,
from?, newName?}`, `shortcuts.export {preset?, path?}`, `shortcuts.import {path? | preset?}`,
`shortcuts.conflicts`.
## Auto-save and crash recovery
- Every *n* minutes (Settings ▸ Project ▸ Auto-Save) a project with unsaved changes is written
to an `EffectCraft Auto-Save` folder next to it (or the custom folder) as
`<name> auto-save N.ecproj`. Slots rotate through 1…maximum versions, overwriting the oldest.
Untitled projects go to the custom folder, or to `Auto-Save` in the config directory.
- Every project and auto-save write is atomic: a temporary file is written and flushed, then
renamed over the target, so a crash mid-write leaves the previous file intact.
- While the app runs, `session.lock` in the config directory records the open project and its
latest auto-save. A clean quit removes it. If it is still there at the next launch, the app
offers to open the latest auto-save (Settings ▸ Startup & Repair can turn this off).
- File ▸ Open Recent lists recent projects (stored in the settings), File ▸ Revert reloads the
saved project, File ▸ Increment and Save saves `Intro.ecproj` as `Intro 2.ecproj`.
Commands: `file.autoSave` (now), `file.recoveryInfo`, `file.openRecent {index? | path?}`,
`file.clearRecent`, `file.revert`, `file.incrementAndSave`.