mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 22:42:24 -06:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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, _| {
|
||||
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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, ¶ms);
|
||||
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),
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user