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:
commit
22346f2949
|
|
@ -180,7 +180,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
|
|||
|
||||
EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is
|
||||
done and what comes next is in the [ROADMAP](ROADMAP.md). Not there yet: motion tracking, puppet,
|
||||
paint and roto tools, Lottie, preferences and a shortcut editor, and GPU compositing. The web build
|
||||
paint and roto tools, Lottie and GPU compositing. The web build
|
||||
([docs/web.md](docs/web.md)) runs the full app in the browser, single-threaded for now. Projects
|
||||
are saved as `.ecproj`, readable versioned JSON; After Effects `.aep` files cannot be opened.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ once.
|
|||
| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done |
|
||||
| M11 | Animation tools: audio playback, meters and waveforms (done); presets, Motion Sketch, Wiggler, Lottie | In progress |
|
||||
| M12 | Performance: layer cache and parallel compositing (done), GPU compositing, disk cache, motion tracking | In progress |
|
||||
| M13+ | Puppet, paint, roto, motion tracking, preferences and shortcut editor, a plugin API | Planned |
|
||||
| M13+ | Puppet, paint, roto, motion tracking, a plugin API | Planned |
|
||||
| M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded |
|
||||
| M14 | Built for agents: MCP server, command-line tool, control channel | Done |
|
||||
| M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done |
|
||||
|
||||
## How far from full parity
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! Audio preview output through cpal (the default output device).
|
||||
//! Audio preview output through cpal (the device and channel mapping chosen in Settings ▸ Audio;
|
||||
//! the default output device when none is set or it is gone).
|
||||
//!
|
||||
//! cpal streams are not `Send` on every platform, so each preview runs its stream on a small
|
||||
//! dedicated thread that owns it until stopped. The callback pulls interleaved stereo from the
|
||||
|
|
@ -12,21 +13,41 @@ use std::thread::JoinHandle;
|
|||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{FromSample, SizedSample};
|
||||
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed};
|
||||
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed, AudioOutput};
|
||||
|
||||
pub struct CpalOut {
|
||||
out: AudioOutput,
|
||||
rate: u32,
|
||||
latency: Arc<AtomicU64>,
|
||||
stop: Option<mpsc::Sender<()>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
/// Open the default output device (`None` without one).
|
||||
pub fn open() -> Option<Box<dyn AudioDevice>> {
|
||||
/// Names of the output devices (Settings ▸ Audio).
|
||||
pub fn devices() -> Vec<String> {
|
||||
let host = cpal::default_host();
|
||||
let dev = host.default_output_device()?;
|
||||
#[allow(deprecated)]
|
||||
host.output_devices().map(|d| d.filter_map(|d| d.name().ok()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The named output device, or the default one.
|
||||
fn device(name: &str) -> Option<cpal::Device> {
|
||||
let host = cpal::default_host();
|
||||
if !name.is_empty() {
|
||||
#[allow(deprecated)]
|
||||
let found = host.output_devices().ok().and_then(|mut d| d.find(|d| d.name().ok().as_deref() == Some(name)));
|
||||
if found.is_some() {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
host.default_output_device()
|
||||
}
|
||||
|
||||
/// Open the output device (`None` without one).
|
||||
pub fn open(out: &AudioOutput) -> Option<Box<dyn AudioDevice>> {
|
||||
let dev = device(&out.device)?;
|
||||
let cfg = dev.default_output_config().ok()?;
|
||||
Some(Box::new(CpalOut { rate: cfg.sample_rate().0, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None }))
|
||||
Some(Box::new(CpalOut { out: out.clone(), rate: cfg.sample_rate().0, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None }))
|
||||
}
|
||||
|
||||
fn run<T: SizedSample + FromSample<f32>>(
|
||||
|
|
@ -34,8 +55,11 @@ fn run<T: SizedSample + FromSample<f32>>(
|
|||
cfg: &cpal::StreamConfig,
|
||||
feed: Arc<AudioFeed>,
|
||||
latency: Arc<AtomicU64>,
|
||||
map: (usize, usize),
|
||||
) -> Result<cpal::Stream, String> {
|
||||
let ch = cfg.channels as usize;
|
||||
// Output mapping: device channels (0-based) for left and right; out of range → 1 and 2.
|
||||
let (left, right) = if map.0 < ch && map.1 < ch { map } else { (0, 1) };
|
||||
let rate = cfg.sample_rate.0 as f64;
|
||||
let mut stereo: Vec<f32> = Vec::new();
|
||||
dev.build_output_stream(
|
||||
|
|
@ -46,11 +70,15 @@ fn run<T: SizedSample + FromSample<f32>>(
|
|||
feed.pull(&mut stereo);
|
||||
for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.chunks_exact(2)) {
|
||||
for (c, o) in f.iter_mut().enumerate() {
|
||||
let v = match (ch, c) {
|
||||
(1, _) => (s[0] + s[1]) * 0.5,
|
||||
(_, 0) => s[0],
|
||||
(_, 1) => s[1],
|
||||
_ => 0.0,
|
||||
// Mono devices, or left and right mapped to the same channel, get the mix.
|
||||
let v = if ch == 1 || (c == left && c == right) {
|
||||
(s[0] + s[1]) * 0.5
|
||||
} else if c == left {
|
||||
s[0]
|
||||
} else if c == right {
|
||||
s[1]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
*o = T::from_sample(v.clamp(-1.0, 1.0));
|
||||
}
|
||||
|
|
@ -77,11 +105,12 @@ impl AudioDevice for CpalOut {
|
|||
let (ready_tx, ready_rx) = mpsc::channel::<Result<(), String>>();
|
||||
let latency = self.latency.clone();
|
||||
let rate = self.rate;
|
||||
let name = self.out.device.clone();
|
||||
let map = ((self.out.left.max(1) - 1) as usize, (self.out.right.max(1) - 1) as usize);
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("ec-audio-out".into())
|
||||
.spawn(move || {
|
||||
let host = cpal::default_host();
|
||||
let Some(dev) = host.default_output_device() else {
|
||||
let Some(dev) = device(&name) else {
|
||||
let _ = ready_tx.send(Err("no audio output device".into()));
|
||||
return;
|
||||
};
|
||||
|
|
@ -95,10 +124,10 @@ impl AudioDevice for CpalOut {
|
|||
let mut cfg: cpal::StreamConfig = sup.config();
|
||||
cfg.sample_rate = cpal::SampleRate(rate);
|
||||
let stream = match sup.sample_format() {
|
||||
cpal::SampleFormat::F32 => run::<f32>(&dev, &cfg, feed, latency),
|
||||
cpal::SampleFormat::I16 => run::<i16>(&dev, &cfg, feed, latency),
|
||||
cpal::SampleFormat::U16 => run::<u16>(&dev, &cfg, feed, latency),
|
||||
cpal::SampleFormat::I32 => run::<i32>(&dev, &cfg, feed, latency),
|
||||
cpal::SampleFormat::F32 => run::<f32>(&dev, &cfg, feed, latency, map),
|
||||
cpal::SampleFormat::I16 => run::<i16>(&dev, &cfg, feed, latency, map),
|
||||
cpal::SampleFormat::U16 => run::<u16>(&dev, &cfg, feed, latency, map),
|
||||
cpal::SampleFormat::I32 => run::<i32>(&dev, &cfg, feed, latency, map),
|
||||
f => Err(format!("unsupported sample format {f:?}")),
|
||||
};
|
||||
let stream = match stream.and_then(|s| s.play().map(|_| s).map_err(|e| e.to_string())) {
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ fn main() -> eframe::Result {
|
|||
let mut control_port: Option<u16> = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut demo = true;
|
||||
let mut home = false;
|
||||
let mut home: Option<bool> = None;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--demo" => demo = true,
|
||||
"--empty" => demo = false,
|
||||
"--home" => home = true,
|
||||
"--home" => home = Some(true),
|
||||
"--version" => {
|
||||
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
|
|
@ -46,6 +46,13 @@ fn main() -> eframe::Result {
|
|||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut session = effectcraft_host::session();
|
||||
// Settings, shortcut presets and the crash-recovery sentinel live in the platform
|
||||
// config directory. Agent-driven runs (`--control`) skip crash recovery.
|
||||
if let Some(dir) = config_dir() {
|
||||
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
|
||||
}
|
||||
session.load_settings();
|
||||
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
|
||||
let project = files.iter().find(|f| f.ends_with(".ecproj")).cloned();
|
||||
if let Some(p) = project {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
|
|
@ -60,8 +67,12 @@ fn main() -> eframe::Result {
|
|||
{
|
||||
eprintln!("effectcraft: {e}");
|
||||
}
|
||||
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.ui.start_screen = home;
|
||||
app.ui.start_screen = show_home;
|
||||
if let Some(r) = recovery {
|
||||
app.offer_recovery(r);
|
||||
}
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
|
||||
}));
|
||||
|
|
@ -71,6 +82,11 @@ fn main() -> eframe::Result {
|
|||
app.hooks.pick_open_project =
|
||||
Some(Box::new(|| rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).pick_file().map(|p| p.to_string_lossy().to_string())));
|
||||
app.hooks.audio_device = Some(Box::new(audio_out::open));
|
||||
app.hooks.audio_devices = Some(Box::new(audio_out::devices));
|
||||
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
|
||||
app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| {
|
||||
rfd::FileDialog::new().add_filter(ext, &[ext]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
if let Some(port) = control_port {
|
||||
disable_app_nap();
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
|
|
@ -81,6 +97,24 @@ fn main() -> eframe::Result {
|
|||
)
|
||||
}
|
||||
|
||||
/// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides):
|
||||
/// `~/Library/Application Support/EffectCraft` (macOS), `%APPDATA%\EffectCraft` (Windows),
|
||||
/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others).
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
use std::path::PathBuf;
|
||||
if let Some(d) = std::env::var_os("EFFECTCRAFT_CONFIG_DIR") {
|
||||
return Some(PathBuf::from(d));
|
||||
}
|
||||
let home = || std::env::var_os("HOME").map(PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
return home().map(|h| h.join("Library/Application Support/EffectCraft"));
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft"));
|
||||
}
|
||||
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft"))
|
||||
}
|
||||
|
||||
/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard
|
||||
/// focus stays where it is (macOS; winit activates ignoring other apps by default).
|
||||
fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -451,6 +451,12 @@ impl Inner {
|
|||
}
|
||||
|
||||
impl FootageSource for MediaPool {
|
||||
fn set_cache_budget(&self, bytes: usize) {
|
||||
self.set_budget(bytes);
|
||||
}
|
||||
fn cache_budget(&self) -> Option<usize> {
|
||||
Some(self.budget())
|
||||
}
|
||||
fn frame(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
if footage.missing {
|
||||
return None;
|
||||
|
|
|
|||
|
|
@ -112,6 +112,29 @@ impl LayerCache {
|
|||
}
|
||||
}
|
||||
|
||||
/// The memory budget in bytes.
|
||||
pub fn budget(&self) -> usize {
|
||||
self.inner.lock().map(|g| g.budget).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Change the memory budget (evicts least-recently used entries when shrinking).
|
||||
pub fn set_budget(&self, budget: usize) {
|
||||
let Ok(mut g) = self.inner.lock() else { return };
|
||||
g.budget = budget;
|
||||
if g.bytes > budget {
|
||||
let mut by_age: Vec<(u64, u64)> = g.map.iter().map(|(k, e)| (e.last_use, *k)).collect();
|
||||
by_age.sort_unstable();
|
||||
for (_, k) in by_age {
|
||||
if g.bytes <= budget {
|
||||
break;
|
||||
}
|
||||
if let Some(e) = g.map.remove(&k) {
|
||||
g.bytes -= e.bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
if let Ok(mut g) = self.inner.lock() {
|
||||
g.map.clear();
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ pub trait FootageSource: Send + Sync {
|
|||
fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
/// Set the decoded-frame cache budget in bytes (Settings ▸ Memory & CPU); sources without
|
||||
/// a cache ignore it.
|
||||
fn set_cache_budget(&self, _bytes: usize) {}
|
||||
/// The decoded-frame cache budget in bytes, if the source has a cache.
|
||||
fn cache_budget(&self) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]).
|
||||
|
|
|
|||
|
|
@ -38,7 +38,22 @@ pub trait AudioDevice: Send {
|
|||
}
|
||||
|
||||
/// Opens the default output device (`None` when there is none).
|
||||
pub type AudioDeviceFactory = Box<dyn Fn() -> Option<Box<dyn AudioDevice>>>;
|
||||
pub type AudioDeviceFactory = Box<dyn Fn(&AudioOutput) -> Option<Box<dyn AudioDevice>>>;
|
||||
|
||||
/// Which output to open (Settings ▸ Audio): device name (empty = the system default) and the
|
||||
/// 1-based device channels for left and right.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AudioOutput {
|
||||
pub device: String,
|
||||
pub left: u32,
|
||||
pub right: u32,
|
||||
}
|
||||
|
||||
impl AudioOutput {
|
||||
pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> AudioOutput {
|
||||
AudioOutput { device: p.audio.output_device.clone(), left: p.audio.output_left, right: p.audio.output_right }
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock-protected sample queue between the feeder thread and the device callback.
|
||||
#[derive(Default)]
|
||||
|
|
|
|||
|
|
@ -129,6 +129,24 @@ impl Frames {
|
|||
v
|
||||
}
|
||||
|
||||
/// Change the RAM preview cache budget (Settings ▸ Memory & CPU), evicting the oldest
|
||||
/// frames when it shrinks.
|
||||
pub fn set_budget(&self, bytes: usize) {
|
||||
if let Ok(mut c) = self.cache.lock() {
|
||||
c.budget = bytes;
|
||||
while c.bytes > c.budget {
|
||||
let Some(old) = c.order.pop_front() else { break };
|
||||
if let Some(i) = c.map.remove(&old) {
|
||||
c.bytes -= i.pixels.len() * 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn budget(&self) -> usize {
|
||||
self.cache.lock().map(|c| c.budget).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
if let Ok(mut c) = self.cache.lock() {
|
||||
c.map.clear();
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ pub enum Dialog {
|
|||
TrackTarget,
|
||||
/// Tracker ▸ Apply (Transform / Stabilize): Apply Dimensions.
|
||||
TrackApply,
|
||||
/// Crash recovery: offer the latest auto-save (`EffectcraftApp::recovery`).
|
||||
Recovery,
|
||||
}
|
||||
|
||||
/// Host hooks provided by the native app (file pickers etc.).
|
||||
|
|
@ -73,6 +75,12 @@ pub struct Hooks {
|
|||
pub pick_open_project: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
/// Opens the audio output for preview playback (the desktop app uses cpal).
|
||||
pub audio_device: Option<audio::AudioDeviceFactory>,
|
||||
/// Lists audio output devices (Settings ▸ Audio).
|
||||
pub audio_devices: Option<Box<dyn Fn() -> Vec<String>>>,
|
||||
/// Picks a folder (Settings paths).
|
||||
pub pick_folder: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
/// Save dialog for other file kinds: (default name, extension).
|
||||
pub pick_save_file: Option<Box<dyn Fn(&str, &str) -> Option<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -128,6 +136,10 @@ pub struct EffectcraftApp {
|
|||
pub(crate) waveforms: panels::waveform::Cache,
|
||||
/// Last reveal shortcut and when (double-press shortcuts such as LL).
|
||||
pub(crate) last_reveal: Option<(String, f64)>,
|
||||
/// Settings revision applied to the theme, tooltips and caches.
|
||||
applied_prefs: Option<u64>,
|
||||
/// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`).
|
||||
pub recovery: Option<effectcraft_engine::autosave::Recovery>,
|
||||
}
|
||||
|
||||
impl EffectcraftApp {
|
||||
|
|
@ -164,7 +176,53 @@ impl EffectcraftApp {
|
|||
meter: Default::default(),
|
||||
waveforms: Default::default(),
|
||||
last_reveal: None,
|
||||
applied_prefs: None,
|
||||
recovery: None,
|
||||
}
|
||||
.with_ui_commands()
|
||||
}
|
||||
|
||||
/// Offer the frontend's commands (tools, timeline navigation…) for keyboard shortcuts.
|
||||
fn with_ui_commands(mut self) -> Self {
|
||||
let cmds = menus::UI_COMMANDS
|
||||
.iter()
|
||||
.map(|c| effectcraft_engine::shortcuts::UiCommand { id: c.id.into(), label: c.label.into(), shortcut: c.shortcut.map(str::to_string) })
|
||||
.collect();
|
||||
self.session.set_ui_commands(cmds);
|
||||
self
|
||||
}
|
||||
|
||||
/// Show the crash-recovery dialog for a previous run that didn't exit cleanly.
|
||||
pub fn offer_recovery(&mut self, r: effectcraft_engine::autosave::Recovery) {
|
||||
if r.autosave.is_some() {
|
||||
self.recovery = Some(r);
|
||||
self.dialog = Some(Dialog::Recovery);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply changed settings: theme and brightness, label colours, tool tips, preview caches.
|
||||
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
|
||||
if self.applied_prefs == Some(self.session.prefs_revision) {
|
||||
return;
|
||||
}
|
||||
self.applied_prefs = Some(self.session.prefs_revision);
|
||||
let p = &self.session.prefs;
|
||||
self.ui.theme = theme::ThemeKind::from_name(&p.appearance.theme).unwrap_or_default();
|
||||
self.tokens = Tokens::from_prefs(p);
|
||||
theme::apply_visuals(ctx, &self.tokens);
|
||||
let tips = p.general.show_tool_tips;
|
||||
ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY });
|
||||
self.ui.cache_when_idle = p.previews.cache_frames_when_idle;
|
||||
self.ui.viewer.fast_preview = p.previews.fast_previews;
|
||||
self.frames.set_budget(p.preview_cache_bytes());
|
||||
}
|
||||
|
||||
/// Change settings from the UI (applied next frame and saved).
|
||||
pub fn set_pref(&mut self, key: &str, value: serde_json::Value) -> Result<(), String> {
|
||||
self.session.prefs.set(key, value)?;
|
||||
self.session.prefs_changed();
|
||||
self.session.save_prefs();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||||
|
|
@ -173,9 +231,13 @@ impl EffectcraftApp {
|
|||
}
|
||||
|
||||
pub fn set_theme(&mut self, ctx: &egui::Context, k: theme::ThemeKind) {
|
||||
self.ui.theme = k;
|
||||
self.tokens = Tokens::for_kind(k);
|
||||
theme::apply_visuals(ctx, &self.tokens);
|
||||
let name = match k {
|
||||
theme::ThemeKind::Dark => "dark",
|
||||
theme::ThemeKind::Darker => "darker",
|
||||
theme::ThemeKind::Light => "light",
|
||||
};
|
||||
let _ = self.set_pref("appearance.theme", json!(name));
|
||||
self.apply_prefs(ctx);
|
||||
}
|
||||
|
||||
pub fn set_workspace(&mut self, name: &str) {
|
||||
|
|
@ -209,8 +271,10 @@ impl EffectcraftApp {
|
|||
/// The render scale used by the viewer right now.
|
||||
pub fn viewer_scale(&self, zoom: f32, ppp: f32) -> f64 {
|
||||
if self.playback.playing && self.ui.viewer.res == state::Resolution::Auto {
|
||||
// Keep previews snappy: never more than the displayed size.
|
||||
return self.ui.viewer.res.scale(zoom, ppp).min(0.5);
|
||||
// Keep previews snappy: half the displayed size, but no lower than Settings ▸
|
||||
// Previews ▸ Adaptive Resolution Limit.
|
||||
let full = self.ui.viewer.res.scale(zoom, ppp);
|
||||
return full.min((full * 0.5).max(self.session.prefs.adaptive_limit()));
|
||||
}
|
||||
self.ui.viewer.res.scale(zoom, ppp)
|
||||
}
|
||||
|
|
@ -291,7 +355,8 @@ impl EffectcraftApp {
|
|||
if !effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) {
|
||||
return;
|
||||
}
|
||||
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f()) else { return };
|
||||
let out = audio::AudioOutput::from_prefs(&self.session.prefs);
|
||||
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return };
|
||||
match audio::AudioPlayback::start(dev, self.render_source(), cid, t, c.work_area.0, c.work_area.1, self.ui.preview_loop) {
|
||||
Ok(a) => self.audio = Some(a),
|
||||
Err(e) => log::warn!("audio preview: {e}"),
|
||||
|
|
@ -546,7 +611,9 @@ impl EffectcraftApp {
|
|||
self.session.poll_track();
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
}
|
||||
self.apply_prefs(&ctx);
|
||||
self.handle_events(&ctx);
|
||||
self.tick_autosave(&ctx);
|
||||
if let Some(rx) = self.command_inbox.take() {
|
||||
while let Ok(id) = rx.try_recv() {
|
||||
if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) {
|
||||
|
|
@ -664,6 +731,17 @@ impl EffectcraftApp {
|
|||
}
|
||||
|
||||
impl EffectcraftApp {
|
||||
/// Auto-save a dirty project when the interval has passed (Settings ▸ Project ▸ Auto-Save).
|
||||
fn tick_autosave(&mut self, ctx: &egui::Context) {
|
||||
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0);
|
||||
if let Some(Err(e)) = self.session.autosave_tick(now) {
|
||||
self.ui.status = e.to_string();
|
||||
}
|
||||
if self.session.prefs.auto_save.enabled {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call
|
||||
/// [`eframe::App::raw_input_hook`] (the headless test harness) feed these in themselves.
|
||||
pub fn take_synthetic_input(&mut self) -> Vec<egui::Event> {
|
||||
|
|
@ -672,6 +750,11 @@ impl EffectcraftApp {
|
|||
}
|
||||
|
||||
impl eframe::App for EffectcraftApp {
|
||||
fn on_exit(&mut self) {
|
||||
// Clean exit: no crash recovery next launch.
|
||||
self.session.end_recovery();
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
if !self.synthetic.is_empty() {
|
||||
// Pointer events go one per frame so egui sees press → moves → release as a real drag.
|
||||
|
|
|
|||
|
|
@ -194,7 +194,11 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
}
|
||||
"view.safeMargins" => app.ui.viewer.safe_margins = !app.ui.viewer.safe_margins,
|
||||
"view.transparencyGrid" => app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid,
|
||||
"view.fastPreviews" => app.ui.viewer.fast_preview = !app.ui.viewer.fast_preview,
|
||||
"view.fastPreviews" => {
|
||||
let on = !app.ui.viewer.fast_preview;
|
||||
app.set_pref("previews.fastPreviews", json!(on))?;
|
||||
app.ui.viewer.fast_preview = on;
|
||||
}
|
||||
"timeline.zoomIn" | "timeline.zoomOut" => {
|
||||
let k = if id == "timeline.zoomIn" { 1.5 } else { 1.0 / 1.5 };
|
||||
crate::panels::timeline::zoom(app, ctx, k);
|
||||
|
|
@ -311,8 +315,21 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
|||
Value::Null
|
||||
}
|
||||
"app.settings" => {
|
||||
app.dialog_state.settings_page = p.get("page").and_then(Value::as_str).unwrap_or("general").to_string();
|
||||
app.dialog = Some(crate::Dialog::Settings);
|
||||
let page = p.get("page").and_then(Value::as_str).unwrap_or("general");
|
||||
crate::panels::settings::open(app, effectcraft_engine::prefs::page_id(page).unwrap_or("general"));
|
||||
Value::Null
|
||||
}
|
||||
"app.gpuInfo" => {
|
||||
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
crate::panels::dialogs::info(
|
||||
app,
|
||||
"GPU Information",
|
||||
&format!(
|
||||
"Compositing: CPU, {threads} threads (pure Rust, rayon).\nDisplay: egui on wgpu.\nLayer cache: {} MB • Preview cache: {} MB.",
|
||||
app.session.layer_cache.budget() >> 20,
|
||||
app.frames.budget() >> 20
|
||||
),
|
||||
);
|
||||
Value::Null
|
||||
}
|
||||
"app.hide" => {
|
||||
|
|
@ -330,7 +347,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
|||
Value::Null
|
||||
}
|
||||
"app.keyboardShortcuts" => {
|
||||
app.dialog = Some(crate::Dialog::Shortcuts);
|
||||
crate::panels::shortcut_editor::open(app);
|
||||
Value::Null
|
||||
}
|
||||
"app.templates" => {
|
||||
|
|
@ -350,7 +367,11 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
|||
app.toggle_play(now);
|
||||
json!({"playing": app.playback.playing})
|
||||
}
|
||||
"playback.cacheWhenIdle" => toggle(&mut app.ui.cache_when_idle, &p),
|
||||
"playback.cacheWhenIdle" => {
|
||||
let r = toggle(&mut app.ui.cache_when_idle, &p);
|
||||
app.set_pref("previews.cacheFramesWhenIdle", r.clone())?;
|
||||
r
|
||||
}
|
||||
"playback.audio" => {
|
||||
let r = toggle(&mut app.ui.preview_audio, &p);
|
||||
if !app.ui.preview_audio {
|
||||
|
|
@ -571,10 +592,15 @@ fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String {
|
|||
match e.command.as_str() {
|
||||
"edit.undo" => app.session.history.undo.last().map(|u| format!("Undo {}", u.0)).unwrap_or_else(|| "Can't Undo".into()),
|
||||
"edit.redo" => app.session.history.redo.last().map(|u| format!("Redo {}", u.0)).unwrap_or_else(|| "Can't Redo".into()),
|
||||
_ => e.label.clone(),
|
||||
_ => effectcraft_engine::menus::entry_label(&app.session, e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The entry's shortcut in the active keyboard shortcut preset.
|
||||
fn entry_shortcut(app: &EffectcraftApp, e: &MenuEntry) -> Option<String> {
|
||||
app.session.shortcuts().shortcut_of(&e.command, &e.params).map(str::to_string)
|
||||
}
|
||||
|
||||
/// Check-mark state of frontend toggles (engine state is answered by the engine).
|
||||
fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option<bool> {
|
||||
let v = &app.ui.viewer;
|
||||
|
|
@ -623,7 +649,7 @@ pub fn menu_items(app: &EffectcraftApp) -> Vec<MenuItem> {
|
|||
id: e.command.clone(),
|
||||
label: entry_label(app, e),
|
||||
path,
|
||||
shortcut: e.shortcut.clone(),
|
||||
shortcut: entry_shortcut(app, e),
|
||||
enabled: entry_enabled(app, e),
|
||||
checked: entry_checked(app, e),
|
||||
params: e.params.clone(),
|
||||
|
|
@ -718,36 +744,17 @@ fn shifted_alias(k: egui::Key) -> Option<egui::Key> {
|
|||
/// A key binding: modifiers, key, command id and bound params.
|
||||
pub type Binding = (egui::Modifiers, egui::Key, String, Value);
|
||||
|
||||
/// All active bindings, most modifiers first.
|
||||
pub fn bindings() -> Vec<Binding> {
|
||||
/// All bindings of the active keyboard shortcut preset, most modifiers first.
|
||||
pub fn bindings(session: &effectcraft_engine::Session) -> Vec<Binding> {
|
||||
let mut v: Vec<Binding> = Vec::new();
|
||||
let mut push = |sc: &str, id: &str, params: Value| {
|
||||
for (sc, b) in session.shortcuts().bindings() {
|
||||
if let Some((m, k)) = parse_shortcut(sc) {
|
||||
if m.shift
|
||||
&& let Some(alias) = shifted_alias(k)
|
||||
{
|
||||
v.push((m, alias, id.to_string(), params.clone()));
|
||||
v.push((m, alias, b.command.clone(), b.params.clone()));
|
||||
}
|
||||
v.push((m, k, id.to_string(), params));
|
||||
}
|
||||
};
|
||||
// Menu entries (their shortcuts may bind params), then command defaults not in the menus.
|
||||
let entries = effectcraft_engine::menus::entries();
|
||||
for (_, e) in &entries {
|
||||
if let Some(sc) = &e.shortcut {
|
||||
push(sc, &e.command, e.params.clone());
|
||||
}
|
||||
}
|
||||
for c in effectcraft_engine::command_specs() {
|
||||
if let Some(sc) = c.shortcut
|
||||
&& !entries.iter().any(|(_, e)| e.command == c.id)
|
||||
{
|
||||
push(sc, c.id, Value::Null);
|
||||
}
|
||||
}
|
||||
for c in UI_COMMANDS {
|
||||
if let Some(sc) = c.shortcut {
|
||||
push(sc, c.id, Value::Null);
|
||||
v.push((m, k, b.command.clone(), b.params.clone()));
|
||||
}
|
||||
}
|
||||
v.sort_by_key(|(m, ..)| std::cmp::Reverse(m.command as u8 + m.shift as u8 + m.alt as u8 + m.ctrl as u8));
|
||||
|
|
@ -779,10 +786,21 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let binds = bindings();
|
||||
let binds = bindings(&app.session);
|
||||
for (key, mods) in events {
|
||||
// Escape closes dialogs.
|
||||
if key == egui::Key::Escape && app.dialog.is_some() {
|
||||
// Escape while recording a shortcut cancels the recording, not the editor.
|
||||
if crate::panels::shortcut_editor::recording(app) {
|
||||
continue;
|
||||
}
|
||||
// Escape in Settings is Cancel: restore the settings from when it opened.
|
||||
if app.dialog == Some(crate::Dialog::Settings)
|
||||
&& let Some(p) = app.dialog_state.prefs_snapshot.take()
|
||||
{
|
||||
app.session.prefs = p;
|
||||
app.session.prefs_changed();
|
||||
}
|
||||
app.dialog = None;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -834,6 +852,27 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c
|
|||
MenuNode::Separator => {
|
||||
ui.separator();
|
||||
}
|
||||
MenuNode::Submenu { label, children } if label == "Open Recent" => {
|
||||
// File ▸ Open Recent: the recent projects (Settings), then the static entries.
|
||||
ui.menu_button((gutter(false), label.as_str()), |ui| {
|
||||
ui.set_min_width(320.0);
|
||||
let recent = app.session.prefs.recent_projects.clone();
|
||||
if recent.is_empty() {
|
||||
ui.add_enabled(false, egui::Button::new((gutter(false), "No Recent Projects")));
|
||||
}
|
||||
for (i, path) in recent.iter().enumerate() {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
|
||||
let r = ui.add(egui::Button::new((gutter(false), name))).on_hover_text(path);
|
||||
app.auto.add(&format!("menu.openRecent.{i}"), r.rect, path);
|
||||
if r.clicked() {
|
||||
*clicked = Some(("file.openRecent".into(), json!({"index": i})));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
menu_nodes(app, ui, children, clicked);
|
||||
});
|
||||
}
|
||||
MenuNode::Submenu { label, children } => {
|
||||
ui.menu_button((gutter(false), label.as_str()), |ui| {
|
||||
ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 });
|
||||
|
|
@ -862,8 +901,8 @@ fn gutter(checked: bool) -> egui::Atom<'static> {
|
|||
fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> bool {
|
||||
let label = entry_label(app, e);
|
||||
let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), label));
|
||||
if let Some(s) = &e.shortcut {
|
||||
b = b.shortcut_text(shortcut_text(s));
|
||||
if let Some(s) = entry_shortcut(app, e) {
|
||||
b = b.shortcut_text(shortcut_text(&s));
|
||||
}
|
||||
ui.add_enabled(entry_enabled(app, e), b).clicked()
|
||||
}
|
||||
|
|
@ -888,8 +927,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn ui_and_menu_shortcuts_do_not_collide() {
|
||||
let app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let mut seen: std::collections::BTreeMap<(String, String), (String, String)> = Default::default();
|
||||
for (m, k, id, params) in bindings() {
|
||||
for (m, k, id, params) in bindings(&app.session) {
|
||||
let key = (format!("{m:?}"), format!("{k:?}"));
|
||||
let target = (id.clone(), params.to_string());
|
||||
if let Some(prev) = seen.get(&key) {
|
||||
|
|
@ -900,6 +940,36 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_uses_the_active_custom_preset() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let find = |app: &EffectcraftApp, id: &str| bindings(&app.session).into_iter().filter(|b| b.2 == id).map(|b| (b.0, b.1)).collect::<Vec<_>>();
|
||||
let (m, k) = parse_shortcut("Cmd+Alt+Shift+Y").unwrap();
|
||||
assert!(find(&app, "layer.newNull").contains(&(m, k)));
|
||||
// A UI command rebound in a custom preset.
|
||||
app.session.execute("shortcuts.set", json!({"command": "tool.hand", "keys": "Ctrl+Alt+H"})).unwrap();
|
||||
assert_eq!(app.session.keymaps.active, "Custom");
|
||||
let (m2, k2) = parse_shortcut("Ctrl+Alt+H").unwrap();
|
||||
assert_eq!(find(&app, "tool.hand"), vec![(m2, k2)]);
|
||||
app.session.execute("shortcuts.set", json!({"command": "layer.newNull", "keys": "F6"})).unwrap();
|
||||
assert_eq!(find(&app, "layer.newNull"), vec![(egui::Modifiers::NONE, egui::Key::F6)]);
|
||||
// The menus show the preset's shortcut.
|
||||
let item = menu_items(&app).into_iter().find(|i| i.id == "layer.newNull").unwrap();
|
||||
assert_eq!(item.shortcut.as_deref(), Some("F6"));
|
||||
// Back to the default preset.
|
||||
app.session.execute("shortcuts.preset", json!({"op": "select", "name": effectcraft_engine::shortcuts::DEFAULT_PRESET})).unwrap();
|
||||
assert!(find(&app, "layer.newNull").contains(&(m, k)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renamed_label_shows_in_the_label_menu() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.set_pref("labels.1.name", json!("Sunflower")).unwrap();
|
||||
let labels: Vec<String> = menu_items(&app).into_iter().filter(|i| i.id == "edit.label").map(|i| i.label).collect();
|
||||
assert!(labels.contains(&"Sunflower".to_string()), "{labels:?}");
|
||||
assert!(!labels.contains(&"Yellow".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_items_cover_the_tree() {
|
||||
assert_eq!(menus().last(), Some(&"Help"));
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ pub struct DialogState {
|
|||
pub palette_sel: usize,
|
||||
/// Settings dialog page id (`general`, `appearance`…).
|
||||
pub settings_page: String,
|
||||
/// Settings when the dialog opened (Cancel restores them).
|
||||
pub prefs_snapshot: Option<effectcraft_engine::prefs::Prefs>,
|
||||
/// Keyboard Shortcuts editor.
|
||||
pub shortcuts: super::shortcut_editor::EditorState,
|
||||
/// The open parameter form.
|
||||
pub form: super::forms::Form,
|
||||
/// Message box (title, body).
|
||||
|
|
@ -101,8 +105,9 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
Dialog::NewComp | Dialog::CompSettings => comp_settings(app, ctx, &t, d == Dialog::CompSettings),
|
||||
Dialog::SolidSettings => solid(app, ctx, &t),
|
||||
Dialog::CommandPalette => palette(app, ctx, &t),
|
||||
Dialog::Settings => super::forms::show_settings(app, ctx, &t),
|
||||
Dialog::Shortcuts => super::forms::show_shortcuts(app, ctx, &t),
|
||||
Dialog::Settings => super::settings::show(app, ctx, &t),
|
||||
Dialog::Shortcuts => super::shortcut_editor::show(app, ctx, &t),
|
||||
Dialog::Recovery => super::settings::recovery(app, ctx, &t),
|
||||
Dialog::Form => super::forms::show_form(app, ctx, &t),
|
||||
Dialog::Info => super::forms::show_info(app, ctx, &t),
|
||||
Dialog::ViewOptions => super::forms::show_view_options(app, ctx, &t),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Menu dialogs: a generic parameter form (numeric Transform dialogs, Mask Feather/Opacity/
|
||||
//! Expansion, Auto-Orient, Go to Time, Add Guide, Sequence Layers, Interpret Footage, Project
|
||||
//! Settings, placeholders…), Settings (Preferences), Keyboard Shortcuts, View Options and simple
|
||||
//! message boxes. Every form runs an engine command with the collected parameters, so whatever a
|
||||
//! Settings, placeholders…), View Options and simple message boxes (Settings: `settings`;
|
||||
//! Keyboard Shortcuts: `shortcut_editor`). Every form runs an engine command with the collected parameters, so whatever a
|
||||
//! dialog does an agent can do with one `engine.execute`.
|
||||
|
||||
use egui::{Color32, vec2};
|
||||
|
|
@ -359,147 +359,6 @@ pub fn show_info(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
|||
}
|
||||
}
|
||||
|
||||
pub const SETTINGS_PAGES: [(&str, &str); 17] = [
|
||||
("general", "General"),
|
||||
("startup", "Startup & Repair"),
|
||||
("project", "Project"),
|
||||
("composition", "Composition"),
|
||||
("previews", "Previews"),
|
||||
("appearance", "Appearance"),
|
||||
("grids", "Grids & Guides"),
|
||||
("labels", "Labels"),
|
||||
("type", "Type"),
|
||||
("import", "Import"),
|
||||
("export", "Export"),
|
||||
("audio", "Audio"),
|
||||
("disk", "Disk"),
|
||||
("memory", "Memory & CPU"),
|
||||
("video", "Video"),
|
||||
("3d", "3D"),
|
||||
("scripting", "Scripting & Expressions"),
|
||||
];
|
||||
|
||||
pub fn show_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut page = app.dialog_state.settings_page.clone();
|
||||
let mut close = false;
|
||||
let mut theme = None;
|
||||
super::dialogs::modal(ctx, "Settings", vec2(640.0, 440.0), t, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(170.0);
|
||||
for (id, label) in SETTINGS_PAGES {
|
||||
if ui.selectable_label(page == id, label).clicked() {
|
||||
page = id.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
let title = SETTINGS_PAGES.iter().find(|p| p.0 == page).map(|p| p.1).unwrap_or("General");
|
||||
ui.label(egui::RichText::new(title).font(Tokens::semibold(13.0)));
|
||||
ui.add_space(8.0);
|
||||
match page.as_str() {
|
||||
"general" => {
|
||||
ui.checkbox(&mut app.ui.show_menu_bar, "Show the in-window menu bar");
|
||||
ui.checkbox(&mut app.ui.snapping, "Snapping");
|
||||
}
|
||||
"appearance" => {
|
||||
for (k, name) in [("dark", "Dark"), ("darker", "Darker"), ("light", "Light")] {
|
||||
if ui.button(name).clicked() {
|
||||
theme = crate::theme::ThemeKind::from_name(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
"previews" => {
|
||||
ui.checkbox(&mut app.ui.cache_when_idle, "Cache frames when idle");
|
||||
ui.checkbox(&mut app.ui.viewer.fast_preview, "Fast previews");
|
||||
}
|
||||
"grids" => {
|
||||
ui.checkbox(&mut app.ui.viewer.grid, "Show grid");
|
||||
ui.checkbox(&mut app.ui.viewer.guides, "Show guides");
|
||||
ui.checkbox(&mut app.ui.viewer.safe_margins, "Show title/action safe");
|
||||
}
|
||||
"labels" => {
|
||||
for l in effectcraft_engine::color::Label::ALL.into_iter().skip(1) {
|
||||
ui.horizontal(|ui| {
|
||||
let [r, g, b] = l.rgb();
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(26.0, 14.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(r, g, b));
|
||||
ui.label(l.name());
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
ui.label(egui::RichText::new("No options on this page yet.").color(t.text_dim));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
app.dialog_state.settings_page = page;
|
||||
if let Some(k) = theme {
|
||||
app.set_theme(ctx, k);
|
||||
}
|
||||
if close {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut q = app.dialog_state.palette_query.clone();
|
||||
let mut close = false;
|
||||
// (shortcut, where, label)
|
||||
let mut rows: Vec<(String, String, String)> = vec![];
|
||||
for (path, e) in effectcraft_engine::menus::entries() {
|
||||
if let Some(sc) = &e.shortcut {
|
||||
rows.push((crate::menus::shortcut_text(sc), path.join(" ▸ "), e.label.clone()));
|
||||
}
|
||||
}
|
||||
for c in effectcraft_engine::command_specs().iter().filter(|c| c.menu.is_empty()) {
|
||||
if let Some(sc) = c.shortcut
|
||||
&& !rows.iter().any(|r| r.2 == c.label)
|
||||
{
|
||||
rows.push((crate::menus::shortcut_text(sc), "Commands".into(), c.label.to_string()));
|
||||
}
|
||||
}
|
||||
for c in crate::menus::UI_COMMANDS {
|
||||
if let Some(sc) = c.shortcut {
|
||||
rows.push((crate::menus::shortcut_text(sc), "Panels & Tools".into(), c.label.to_string()));
|
||||
}
|
||||
}
|
||||
super::dialogs::modal(ctx, "Keyboard Shortcuts", vec2(640.0, 520.0), t, |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search commands or shortcuts").desired_width(f32::INFINITY));
|
||||
ui.add_space(8.0);
|
||||
let ql = q.to_lowercase();
|
||||
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
|
||||
egui::Grid::new("shortcut-grid").num_columns(3).striped(true).spacing([18.0, 4.0]).show(ui, |ui| {
|
||||
for (sc, place, label) in rows.iter().filter(|r| ql.is_empty() || format!("{} {} {}", r.0, r.1, r.2).to_lowercase().contains(&ql)) {
|
||||
ui.label(egui::RichText::new(sc).monospace());
|
||||
ui.label(label);
|
||||
ui.label(egui::RichText::new(place).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
app.dialog_state.palette_query = q;
|
||||
if close {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_view_options(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut close = false;
|
||||
super::dialogs::modal(ctx, "View Options", vec2(360.0, 330.0), t, |ui| {
|
||||
|
|
|
|||
|
|
@ -83,8 +83,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
ly += 18.0;
|
||||
}
|
||||
}
|
||||
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
|
||||
p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("Render: {ms:.0} ms • UI {:.0} fps", app.fps), Tokens::ui(11.5), t.text_faint);
|
||||
// Settings ▸ Composition ▸ Show Rendering Progress in Info Panel.
|
||||
if app.session.prefs.composition.show_rendering_progress {
|
||||
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
|
||||
let rq = app.session.render_job.as_ref().map(|_| " • Rendering queue…").unwrap_or("");
|
||||
p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("Render: {ms:.0} ms • UI {:.0} fps{rq}", app.fps), Tokens::ui(11.5), t.text_faint);
|
||||
}
|
||||
if !app.session.state.selected_keys.is_empty() {
|
||||
p.text(
|
||||
pos2(x0, ly + 18.0),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ pub mod project;
|
|||
pub mod properties;
|
||||
pub mod puppet_tool;
|
||||
pub mod render_queue;
|
||||
pub mod settings;
|
||||
pub mod shortcut_editor;
|
||||
pub mod text_panels;
|
||||
pub mod timeline;
|
||||
pub mod tracker;
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
Tokens::ui(12.0),
|
||||
if selected { Color32::WHITE } else { t.text },
|
||||
);
|
||||
lp.rect_filled(Rect::from_center_size(pos2(r.min.x + name_w + 14.0, r.center().y), vec2(10.0, 10.0)), 2.0, Tokens::label(it.label));
|
||||
lp.rect_filled(Rect::from_center_size(pos2(r.min.x + name_w + 14.0, r.center().y), vec2(10.0, 10.0)), 2.0, t.label(it.label));
|
||||
lp.text(pos2(col_type, r.center().y), Align2::LEFT_CENTER, it.type_name(), Tokens::ui(11.5), t.text_dim);
|
||||
if let Some(b) = file_size(&it) {
|
||||
lp.text(pos2(col_size, r.center().y), Align2::LEFT_CENTER, fmt_size(b), Tokens::ui(11.5), t.text_dim);
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" });
|
||||
let comp_item = app.session.project.item(it.comp);
|
||||
let label_col = comp_item.map(|i| Tokens::label(i.label)).unwrap_or(t.text_faint);
|
||||
let label_col = comp_item.map(|i| t.label(i.label)).unwrap_or(t.text_faint);
|
||||
lp.rect_filled(Rect::from_center_size(pos2(cx_label + 6.0, row.center().y), vec2(10.0, 10.0)), 2.0, label_col);
|
||||
lp.text(pos2(cx_num + 2.0, row.center().y), Align2::LEFT_CENTER, n.to_string(), Tokens::ui(12.0), t.text_dim);
|
||||
let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,411 @@
|
|||
//! The Settings dialog (After Effects' Preferences): the page list on the left, the page on the
|
||||
//! right, OK / Cancel / Previous / Next. Pages are built from the engine's schema
|
||||
//! (`effectcraft_engine::prefs::pages`), so the dialog, `prefs.pages` and the docs agree. Edits
|
||||
//! apply live; Cancel restores the settings from when the dialog opened, OK saves them.
|
||||
//!
|
||||
//! Automation ids: `settings.page.<id>`, `settings.<key>` (e.g. `settings.general.undoLevels`),
|
||||
//! `settings.labels.<n>.name` / `.color`, `settings.button.<label>`, `settings.ok`,
|
||||
//! `settings.cancel`, `settings.previous`, `settings.next`.
|
||||
|
||||
use effectcraft_engine::prefs::{Item, Kind, Page, pages};
|
||||
use egui::{Color32, RichText, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
/// Open the dialog on a page, remembering the settings for Cancel.
|
||||
pub fn open(app: &mut EffectcraftApp, page: &str) {
|
||||
if app.dialog != Some(Dialog::Settings) {
|
||||
app.dialog_state.prefs_snapshot = Some(app.session.prefs.clone());
|
||||
}
|
||||
app.dialog_state.settings_page = page.to_string();
|
||||
app.dialog = Some(Dialog::Settings);
|
||||
}
|
||||
|
||||
enum Act {
|
||||
Set(String, Value),
|
||||
Run(String, Value),
|
||||
PickFolder(String),
|
||||
PickProject(String),
|
||||
}
|
||||
|
||||
fn hex(c: [u8; 3]) -> String {
|
||||
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
|
||||
}
|
||||
|
||||
fn reg(app: &mut EffectcraftApp, id: &str, r: &egui::Response, label: &str) {
|
||||
app.auto.add(id, r.rect, label);
|
||||
}
|
||||
|
||||
/// One page's rows.
|
||||
fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value, t: &Tokens, acts: &mut Vec<Act>) {
|
||||
let get = |key: &str| cur.pointer(&format!("/{}", key.replace('.', "/"))).cloned().unwrap_or(Value::Null);
|
||||
let label_w = 250.0;
|
||||
for item in &page.items {
|
||||
match *item {
|
||||
Item::Section(title) => {
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new(title).font(Tokens::semibold(12.5)).color(t.tab_text_active));
|
||||
ui.separator();
|
||||
}
|
||||
Item::Note(text) => {
|
||||
ui.label(RichText::new(text).color(t.text_dim));
|
||||
}
|
||||
Item::Button { label, command, params } => {
|
||||
let r = ui.button(label);
|
||||
reg(app, &format!("settings.button.{}", label.trim_end_matches('.')), &r, label);
|
||||
if r.clicked() {
|
||||
acts.push(Act::Run(command.into(), serde_json::from_str(params).unwrap_or(json!({}))));
|
||||
}
|
||||
}
|
||||
Item::Labels => labels_ui(app, ui, cur, acts),
|
||||
Item::AudioDevices { key } => {
|
||||
let devices: Vec<String> = app.hooks.audio_devices.as_ref().map(|f| f()).unwrap_or_default();
|
||||
let sel = get(key).as_str().unwrap_or("").to_string();
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(label_w, 18.0), egui::Label::new("Default Output:"));
|
||||
let shown = if sel.is_empty() { "System Default".to_string() } else { sel.clone() };
|
||||
let r = egui::ComboBox::from_id_salt(("settings", key)).selected_text(shown).width(260.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(sel.is_empty(), "System Default").clicked() {
|
||||
acts.push(Act::Set(key.into(), json!("")));
|
||||
}
|
||||
for d in &devices {
|
||||
if ui.selectable_label(*d == sel, d).clicked() {
|
||||
acts.push(Act::Set(key.into(), json!(d)));
|
||||
}
|
||||
}
|
||||
});
|
||||
reg(app, &format!("settings.{key}"), &r.response, "Default Output");
|
||||
});
|
||||
if devices.is_empty() {
|
||||
ui.label(RichText::new("No device list from this host; the system default output is used.").color(t.text_dim));
|
||||
}
|
||||
}
|
||||
Item::Setting { key, label, kind, live } => {
|
||||
let v = get(key);
|
||||
let id = format!("settings.{key}");
|
||||
let tip = (!live).then_some("Stored, but not used by EffectCraft yet");
|
||||
let mut resp: Option<egui::Response> = None;
|
||||
match kind {
|
||||
Kind::Bool => {
|
||||
let mut b = v.as_bool().unwrap_or(false);
|
||||
let r = ui.checkbox(&mut b, label);
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(key.into(), json!(b)));
|
||||
}
|
||||
resp = Some(r);
|
||||
}
|
||||
_ => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(label_w, 18.0), egui::Label::new(format!("{label}:")).truncate());
|
||||
let r = match kind {
|
||||
Kind::Int(lo, hi, unit) => {
|
||||
let mut x = v.as_i64().unwrap_or(0);
|
||||
let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25));
|
||||
if !unit.is_empty() {
|
||||
ui.label(RichText::new(unit).color(t.text_dim));
|
||||
}
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(key.into(), json!(x)));
|
||||
}
|
||||
r
|
||||
}
|
||||
Kind::Float(lo, hi, unit) => {
|
||||
let mut x = v.as_f64().unwrap_or(0.0);
|
||||
let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3));
|
||||
if !unit.is_empty() {
|
||||
ui.label(RichText::new(unit).color(t.text_dim));
|
||||
}
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(key.into(), json!(x)));
|
||||
}
|
||||
r
|
||||
}
|
||||
Kind::Slider(lo, hi) => {
|
||||
let mut x = v.as_f64().unwrap_or(0.0);
|
||||
ui.label(RichText::new("Darker").color(t.text_dim));
|
||||
let r = ui.add(egui::Slider::new(&mut x, lo..=hi).show_value(false));
|
||||
ui.label(RichText::new("Lighter").color(t.text_dim));
|
||||
if ui.small_button("Default").clicked() {
|
||||
acts.push(Act::Set(key.into(), json!(0.0)));
|
||||
}
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(key.into(), json!(x)));
|
||||
}
|
||||
r
|
||||
}
|
||||
Kind::Choice(opts) => {
|
||||
let curv = v.as_str().unwrap_or("").to_string();
|
||||
let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string();
|
||||
egui::ComboBox::from_id_salt(("settings", key))
|
||||
.selected_text(shown)
|
||||
.width(220.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (l, val) in opts {
|
||||
if ui.selectable_label(*val == curv, *l).clicked() {
|
||||
acts.push(Act::Set(key.into(), json!(val)));
|
||||
}
|
||||
}
|
||||
})
|
||||
.response
|
||||
}
|
||||
Kind::Text | Kind::Path => {
|
||||
let mut s = v.as_str().unwrap_or("").to_string();
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(if kind == Kind::Path { 230.0 } else { 260.0 }));
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(key.into(), json!(s)));
|
||||
}
|
||||
if kind == Kind::Path {
|
||||
let b = ui.button("Choose...");
|
||||
reg(app, &format!("{id}.choose"), &b, "Choose...");
|
||||
if b.clicked() {
|
||||
acts.push(if key.ends_with("Path") { Act::PickProject(key.into()) } else { Act::PickFolder(key.into()) });
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
Kind::Color => {
|
||||
let c = crate::panels::viewer::hex_rgb(v.as_str().unwrap_or("")).unwrap_or([128, 128, 128]);
|
||||
let mut rgb = c;
|
||||
let r = ui.color_edit_button_srgb(&mut rgb);
|
||||
ui.label(RichText::new(hex(rgb)).monospace().color(t.text_dim));
|
||||
if rgb != c {
|
||||
acts.push(Act::Set(key.into(), json!(hex(rgb))));
|
||||
}
|
||||
r
|
||||
}
|
||||
Kind::Bool => unreachable!(),
|
||||
};
|
||||
resp = Some(r);
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(r) = resp {
|
||||
let r = match tip {
|
||||
Some(tip) => r.on_hover_text(tip),
|
||||
None => r,
|
||||
};
|
||||
reg(app, &id, &r, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mut Vec<Act>) {
|
||||
ui.label("Label Colors and Names:");
|
||||
ui.add_space(4.0);
|
||||
let labels = cur.get("labels").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
egui::Grid::new("settings-labels").num_columns(4).spacing([10.0, 4.0]).show(ui, |ui| {
|
||||
for (i, l) in labels.iter().enumerate() {
|
||||
let c = crate::panels::viewer::hex_rgb(l["color"].as_str().unwrap_or("")).unwrap_or([128, 128, 128]);
|
||||
let mut rgb = c;
|
||||
let r = ui.color_edit_button_srgb(&mut rgb);
|
||||
app.auto.add(&format!("settings.labels.{i}.color"), r.rect, "label colour");
|
||||
if rgb != c {
|
||||
acts.push(Act::Set(format!("labels.{i}.color"), json!(hex(rgb))));
|
||||
}
|
||||
let mut name = l["name"].as_str().unwrap_or("").to_string();
|
||||
let r = ui.add_sized(vec2(160.0, 20.0), egui::TextEdit::singleline(&mut name));
|
||||
app.auto.add(&format!("settings.labels.{i}.name"), r.rect, "label name");
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(format!("labels.{i}.name"), json!(name)));
|
||||
}
|
||||
if i % 2 == 1 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let r = ui.button("Reset Labels");
|
||||
app.auto.add("settings.button.Reset Labels", r.rect, "Reset Labels");
|
||||
if r.clicked() {
|
||||
acts.push(Act::Run("prefs.reset".into(), json!({"page": "labels"})));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let all = pages();
|
||||
let mut page = app.dialog_state.settings_page.clone();
|
||||
if !all.iter().any(|p| p.id == page) {
|
||||
page = "general".into();
|
||||
}
|
||||
let idx = all.iter().position(|p| p.id == page).unwrap_or(0);
|
||||
let cur = serde_json::to_value(&app.session.prefs).unwrap_or_default();
|
||||
let mut acts: Vec<Act> = vec![];
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
super::dialogs::modal(ctx, "Settings", vec2(820.0, 600.0), t, |ui| {
|
||||
let body_h = 470.0;
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), body_h), egui::Layout::left_to_right(egui::Align::Min), |ui| {
|
||||
ui.set_height(body_h);
|
||||
// Page list (left, like After Effects).
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(180.0);
|
||||
for p in &all {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(176.0, 24.0), egui::Sense::click());
|
||||
let sel = page == p.id;
|
||||
if sel {
|
||||
ui.painter().rect_filled(r, 3.0, t.accent);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(10.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
p.title,
|
||||
Tokens::ui(12.5),
|
||||
if sel { Color32::WHITE } else { t.text },
|
||||
);
|
||||
app.auto.add(&format!("settings.page.{}", p.id), r, p.title);
|
||||
if resp.clicked() {
|
||||
page = p.id.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(590.0);
|
||||
let p = &all[idx];
|
||||
ui.label(RichText::new(p.title).font(Tokens::semibold(14.0)).color(t.tab_text_active));
|
||||
ui.add_space(6.0);
|
||||
egui::ScrollArea::vertical().max_height(body_h - 30.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
page_ui(app, ui, p, &cur, t, &mut acts);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add_enabled(idx > 0, egui::Button::new("Previous"));
|
||||
app.auto.add("settings.previous", r.rect, "Previous");
|
||||
if r.clicked() {
|
||||
page = all[idx - 1].id.to_string();
|
||||
}
|
||||
let r = ui.add_enabled(idx + 1 < all.len(), egui::Button::new("Next"));
|
||||
app.auto.add("settings.next", r.rect, "Next");
|
||||
if r.clicked() {
|
||||
page = all[idx + 1].id.to_string();
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.add(egui::Button::new(RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
|
||||
app.auto.add("settings.ok", r.rect, "OK");
|
||||
ok |= r.clicked();
|
||||
let r = ui.button("Cancel");
|
||||
app.auto.add("settings.cancel", r.rect, "Cancel");
|
||||
cancel |= r.clicked();
|
||||
});
|
||||
});
|
||||
});
|
||||
app.dialog_state.settings_page = page;
|
||||
for a in acts {
|
||||
match a {
|
||||
Act::Set(k, v) => {
|
||||
if let Err(e) = app.session.prefs.set(&k, v) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
app.session.prefs_changed();
|
||||
}
|
||||
Act::Run(cmd, params) => {
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, &cmd, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
// Buttons such as Reset Settings change the baseline Cancel returns to.
|
||||
if cmd.starts_with("prefs.") {
|
||||
app.dialog_state.prefs_snapshot = Some(app.session.prefs.clone());
|
||||
}
|
||||
if app.dialog != Some(Dialog::Settings) && app.dialog != Some(Dialog::Info) {
|
||||
app.dialog = Some(Dialog::Settings);
|
||||
}
|
||||
}
|
||||
Act::PickFolder(k) => {
|
||||
if let Some(f) = app.hooks.pick_folder.as_ref().and_then(|f| f()) {
|
||||
let _ = app.session.prefs.set(&k, json!(f));
|
||||
app.session.prefs_changed();
|
||||
}
|
||||
}
|
||||
Act::PickProject(k) => {
|
||||
if let Some(f) = app.hooks.pick_open_project.as_ref().and_then(|f| f()) {
|
||||
let _ = app.session.prefs.set(&k, json!(f));
|
||||
app.session.prefs_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel {
|
||||
if let Some(p) = app.dialog_state.prefs_snapshot.take() {
|
||||
app.session.prefs = p;
|
||||
app.session.prefs_changed();
|
||||
}
|
||||
app.dialog = None;
|
||||
} else if ok {
|
||||
app.dialog_state.prefs_snapshot = None;
|
||||
app.session.save_prefs();
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Crash recovery: the previous session didn't exit cleanly; offer its latest auto-save.
|
||||
pub fn recovery(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let Some(r) = app.recovery.clone() else {
|
||||
app.dialog = None;
|
||||
return;
|
||||
};
|
||||
let mut choice: Option<&str> = None;
|
||||
super::dialogs::modal(ctx, "Recover Project", vec2(560.0, 230.0), t, |ui| {
|
||||
ui.label("EffectCraft didn't quit normally last time.");
|
||||
ui.add_space(6.0);
|
||||
if let Some(p) = &r.project {
|
||||
ui.label(format!("Project: {p}"));
|
||||
}
|
||||
if let Some(a) = &r.autosave {
|
||||
let when = std::fs::metadata(a).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).map(|d| {
|
||||
let m = d.as_secs() / 60;
|
||||
if m < 1 {
|
||||
"less than a minute ago".to_string()
|
||||
} else if m < 120 {
|
||||
format!("{m} minutes ago")
|
||||
} else {
|
||||
format!("{} hours ago", m / 60)
|
||||
}
|
||||
});
|
||||
ui.label(format!("Latest auto-save: {a}"));
|
||||
if let Some(w) = when {
|
||||
ui.label(RichText::new(format!("Saved {w}")).color(t.text_dim));
|
||||
}
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let b = ui.add(egui::Button::new(RichText::new(" Open Auto-Save ").color(Color32::WHITE)).fill(t.accent));
|
||||
app.auto.add("recovery.openAutoSave", b.rect, "Open Auto-Save");
|
||||
if b.clicked() {
|
||||
choice = Some("autosave");
|
||||
}
|
||||
if r.project.is_some() {
|
||||
let b = ui.button("Open Last Saved Project");
|
||||
app.auto.add("recovery.openProject", b.rect, "Open Last Saved Project");
|
||||
if b.clicked() {
|
||||
choice = Some("project");
|
||||
}
|
||||
}
|
||||
let b = ui.button("Don't Recover");
|
||||
app.auto.add("recovery.dismiss", b.rect, "Don't Recover");
|
||||
if b.clicked() {
|
||||
choice = Some("none");
|
||||
}
|
||||
});
|
||||
});
|
||||
let Some(c) = choice else { return };
|
||||
app.dialog = None;
|
||||
app.recovery = None;
|
||||
let path = match c {
|
||||
"autosave" => r.autosave,
|
||||
"project" => r.project,
|
||||
_ => None,
|
||||
};
|
||||
if let Some(p) = path
|
||||
&& let Err(e) = crate::menus::invoke(app, ctx, "file.open", json!({"path": p}))
|
||||
{
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,590 @@
|
|||
//! Edit ▸ Keyboard Shortcuts: After Effects' visual shortcut editor.
|
||||
//!
|
||||
//! Top: the preset menu (EffectCraft Default = After Effects' defaults, read-only; custom presets
|
||||
//! can be duplicated, renamed, deleted, imported and exported) and the search field. Middle: an
|
||||
//! on-screen keyboard for the toggled (or held) modifiers, keys coloured by what they run in
|
||||
//! that combination: purple for application-wide commands, green for panel-specific ones, both
|
||||
//! when a key has each. Clicking a key lists everything bound to it. Bottom: every bindable
|
||||
//! command with its shortcuts; select one, then record keys (press them in the shortcut field)
|
||||
//! or assign the key selected on the keyboard. Conflicts are shown before assigning.
|
||||
//!
|
||||
//! All changes go through the `shortcuts.*` engine commands, so agents can do the same.
|
||||
//! Automation ids: `shortcuts.preset`, `shortcuts.search`, `shortcuts.mod.<ctrl|cmd|alt|shift>`,
|
||||
//! `shortcuts.key.<Key>`, `shortcuts.row.<n>`, `shortcuts.record`, `shortcuts.assign`,
|
||||
//! `shortcuts.removeKey.<n>`, `shortcuts.resetCommand`, `shortcuts.duplicate`,
|
||||
//! `shortcuts.rename`, `shortcuts.delete`, `shortcuts.import`, `shortcuts.export`,
|
||||
//! `shortcuts.resetPreset`, `shortcuts.close`.
|
||||
|
||||
use effectcraft_engine::shortcuts::{APP_SCOPE, Bindable, DEFAULT_PRESET, normalize};
|
||||
use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
/// Editor state (in `DialogState`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct EditorState {
|
||||
pub query: String,
|
||||
/// Binding key of the selected command.
|
||||
pub selected: Option<String>,
|
||||
/// Keyboard key selected on the on-screen keyboard (shortcut key name).
|
||||
pub key: Option<String>,
|
||||
/// Modifier toggles: Ctrl, Cmd, Alt, Shift.
|
||||
pub mods: [bool; 4],
|
||||
/// Recording a shortcut from the keyboard.
|
||||
pub recording: bool,
|
||||
/// Recorded (or typed) shortcut waiting to be assigned.
|
||||
pub pending: String,
|
||||
/// Rename field for the active preset.
|
||||
pub rename: Option<String>,
|
||||
}
|
||||
|
||||
const APP_COLOR: Color32 = Color32::from_rgb(0x8e, 0x6c, 0xe8);
|
||||
const PANEL_COLOR: Color32 = Color32::from_rgb(0x3f, 0xa8, 0x6c);
|
||||
|
||||
pub fn open(app: &mut EffectcraftApp) {
|
||||
app.dialog_state.shortcuts = EditorState::default();
|
||||
app.dialog = Some(Dialog::Shortcuts);
|
||||
}
|
||||
|
||||
/// `true` while the editor records keys (the dispatcher must not close the dialog on Escape).
|
||||
pub fn recording(app: &EffectcraftApp) -> bool {
|
||||
app.dialog == Some(Dialog::Shortcuts) && app.dialog_state.shortcuts.recording
|
||||
}
|
||||
|
||||
/// Shortcut text of a key event (`Cmd+Shift+K`).
|
||||
pub fn combo_text(m: egui::Modifiers, k: egui::Key) -> Option<String> {
|
||||
use egui::Key::*;
|
||||
let key = match k {
|
||||
Semicolon => ";",
|
||||
Quote => "'",
|
||||
Comma => ",",
|
||||
Period => ".",
|
||||
Slash => "/",
|
||||
Backslash => "\\",
|
||||
Equals => "=",
|
||||
Minus => "-",
|
||||
Backtick => "`",
|
||||
OpenBracket => "[",
|
||||
CloseBracket => "]",
|
||||
Plus => "+",
|
||||
Colon => ";",
|
||||
_ => k.name(),
|
||||
};
|
||||
let mut s = String::new();
|
||||
if cfg!(target_os = "macos") {
|
||||
if m.ctrl {
|
||||
s.push_str("Ctrl+");
|
||||
}
|
||||
if m.mac_cmd || m.command {
|
||||
s.push_str("Cmd+");
|
||||
}
|
||||
} else if m.ctrl || m.command {
|
||||
s.push_str("Cmd+");
|
||||
}
|
||||
if m.alt {
|
||||
s.push_str("Alt+");
|
||||
}
|
||||
if m.shift {
|
||||
s.push_str("Shift+");
|
||||
}
|
||||
if key == "+" {
|
||||
// `Cmd++` names the plus key.
|
||||
return normalize(&format!("{s}+"));
|
||||
}
|
||||
s.push_str(key);
|
||||
normalize(&s)
|
||||
}
|
||||
|
||||
/// Split normalized shortcut text into (ctrl, cmd, alt, shift) and the key.
|
||||
fn split(sc: &str) -> ([bool; 4], String) {
|
||||
let (head, key) = match sc.strip_suffix("++") {
|
||||
Some(h) => (h.to_string(), "+".to_string()),
|
||||
None if sc == "+" => (String::new(), "+".into()),
|
||||
None => match sc.rsplit_once('+') {
|
||||
Some((h, k)) => (h.to_string(), k.to_string()),
|
||||
None => (String::new(), sc.to_string()),
|
||||
},
|
||||
};
|
||||
let has = |m: &str| head.split('+').any(|p| p == m);
|
||||
([has("Ctrl"), has("Cmd"), has("Alt"), has("Shift")], key)
|
||||
}
|
||||
|
||||
fn mods_text(m: [bool; 4]) -> String {
|
||||
let mut s = String::new();
|
||||
for (on, n) in m.iter().zip(["Ctrl+", "Cmd+", "Alt+", "Shift+"]) {
|
||||
if *on {
|
||||
s.push_str(n);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The on-screen keyboard: rows of (label, key name, width in key units).
|
||||
fn keyboard_rows() -> Vec<Vec<(&'static str, &'static str, f32)>> {
|
||||
let mut rows: Vec<Vec<(&str, &str, f32)>> = vec![];
|
||||
rows.push(vec![
|
||||
("Esc", "Escape", 1.0),
|
||||
("", "", 0.5),
|
||||
("F1", "F1", 1.0),
|
||||
("F2", "F2", 1.0),
|
||||
("F3", "F3", 1.0),
|
||||
("F4", "F4", 1.0),
|
||||
("", "", 0.3),
|
||||
("F5", "F5", 1.0),
|
||||
("F6", "F6", 1.0),
|
||||
("F7", "F7", 1.0),
|
||||
("F8", "F8", 1.0),
|
||||
("", "", 0.3),
|
||||
("F9", "F9", 1.0),
|
||||
("F10", "F10", 1.0),
|
||||
("F11", "F11", 1.0),
|
||||
("F12", "F12", 1.0),
|
||||
]);
|
||||
let mut r: Vec<(&str, &str, f32)> = vec![("`", "`", 1.0)];
|
||||
for d in ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"] {
|
||||
r.push((d, d, 1.0));
|
||||
}
|
||||
r.extend([("-", "-", 1.0), ("=", "=", 1.0), ("Delete", "Backspace", 1.6)]);
|
||||
rows.push(r);
|
||||
let mut r: Vec<(&str, &str, f32)> = vec![("Tab", "Tab", 1.5)];
|
||||
for c in ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"] {
|
||||
r.push((c, c, 1.0));
|
||||
}
|
||||
r.extend([("[", "[", 1.0), ("]", "]", 1.0), ("\\", "\\", 1.1)]);
|
||||
rows.push(r);
|
||||
let mut r: Vec<(&str, &str, f32)> = vec![("Caps", "", 1.8)];
|
||||
for c in ["A", "S", "D", "F", "G", "H", "J", "K", "L"] {
|
||||
r.push((c, c, 1.0));
|
||||
}
|
||||
r.extend([(";", ";", 1.0), ("'", "'", 1.0), ("Return", "Enter", 1.8)]);
|
||||
rows.push(r);
|
||||
let mut r: Vec<(&str, &str, f32)> = vec![("Shift", "#shift", 2.3)];
|
||||
for c in ["Z", "X", "C", "V", "B", "N", "M"] {
|
||||
r.push((c, c, 1.0));
|
||||
}
|
||||
r.extend([(",", ",", 1.0), (".", ".", 1.0), ("/", "/", 1.0), ("Shift", "#shift", 2.3)]);
|
||||
rows.push(r);
|
||||
let (cmd, alt) = if cfg!(target_os = "macos") { ("⌘ Cmd", "⌥ Opt") } else { ("Ctrl", "Alt") };
|
||||
rows.push(vec![
|
||||
("Ctrl", if cfg!(target_os = "macos") { "#ctrl" } else { "#cmd" }, 1.4),
|
||||
(alt, "#alt", 1.4),
|
||||
(cmd, "#cmd", 1.6),
|
||||
("Space", "Space", 6.0),
|
||||
(cmd, "#cmd", 1.6),
|
||||
(alt, "#alt", 1.4),
|
||||
]);
|
||||
rows
|
||||
}
|
||||
|
||||
/// Navigation and numeric keypad block: (label, key name, column, row).
|
||||
const SIDE_KEYS: &[(&str, &str, f32, f32)] = &[
|
||||
("Home", "Home", 0.0, 1.0),
|
||||
("PgUp", "PageUp", 1.0, 1.0),
|
||||
("Del", "Delete", 0.0, 2.0),
|
||||
("End", "End", 1.0, 2.0),
|
||||
("PgDn", "PageDown", 2.0, 2.0),
|
||||
("↑", "ArrowUp", 1.0, 4.0),
|
||||
("←", "ArrowLeft", 0.0, 5.0),
|
||||
("↓", "ArrowDown", 1.0, 5.0),
|
||||
("→", "ArrowRight", 2.0, 5.0),
|
||||
];
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut st = std::mem::take(&mut app.dialog_state.shortcuts);
|
||||
let mut run: Vec<(String, Value)> = vec![];
|
||||
let mut close = false;
|
||||
// Record keys while the shortcut field listens.
|
||||
if st.recording {
|
||||
let got = ctx.input(|i| {
|
||||
i.events.iter().find_map(|e| match e {
|
||||
egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let Some((k, m)) = got {
|
||||
st.recording = false;
|
||||
if k != egui::Key::Escape
|
||||
&& let Some(txt) = combo_text(m, k)
|
||||
{
|
||||
st.pending = txt;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Modifiers: toggles, or the ones held on the keyboard.
|
||||
let held = ctx.input(|i| i.modifiers);
|
||||
let held = [held.ctrl && cfg!(target_os = "macos"), held.command, held.alt, held.shift];
|
||||
let mods = if held.iter().any(|m| *m) && !st.recording { held } else { st.mods };
|
||||
|
||||
let table = app.session.shortcuts().clone();
|
||||
let presets = app.session.keymaps.names();
|
||||
let active = app.session.keymaps.active.clone();
|
||||
// key name → (app?, panel?) for the current modifiers.
|
||||
let mut keymap: std::collections::HashMap<String, (bool, bool)> = Default::default();
|
||||
for (sc, b) in table.bindings() {
|
||||
let (m, k) = split(sc);
|
||||
if m == mods {
|
||||
let e = keymap.entry(k).or_default();
|
||||
if b.scope == APP_SCOPE {
|
||||
e.0 = true;
|
||||
} else {
|
||||
e.1 = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super::dialogs::modal(ctx, "Keyboard Shortcuts", vec2(1000.0, 720.0), t, |ui| {
|
||||
// ---- preset bar
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Preset:");
|
||||
let r = egui::ComboBox::from_id_salt("sc-preset").selected_text(&active).width(220.0).show_ui(ui, |ui| {
|
||||
for n in &presets {
|
||||
if ui.selectable_label(*n == active, n).clicked() {
|
||||
run.push(("shortcuts.preset".into(), json!({"op": "select", "name": n})));
|
||||
}
|
||||
}
|
||||
});
|
||||
app.auto.add("shortcuts.preset", r.response.rect, "Preset");
|
||||
let custom = active != DEFAULT_PRESET;
|
||||
let b = ui.button("Duplicate");
|
||||
app.auto.add("shortcuts.duplicate", b.rect, "Duplicate");
|
||||
if b.clicked() {
|
||||
run.push(("shortcuts.preset".into(), json!({"op": "duplicate", "from": active, "name": format!("{active} copy")})));
|
||||
}
|
||||
if let Some(name) = &mut st.rename {
|
||||
let r = ui.add(egui::TextEdit::singleline(name).desired_width(140.0));
|
||||
app.auto.add("shortcuts.renameField", r.rect, "New name");
|
||||
if ui.button("Rename").clicked() || (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
|
||||
run.push(("shortcuts.preset".into(), json!({"op": "rename", "name": active, "newName": name})));
|
||||
st.rename = None;
|
||||
}
|
||||
} else {
|
||||
let b = ui.add_enabled(custom, egui::Button::new("Rename"));
|
||||
app.auto.add("shortcuts.rename", b.rect, "Rename");
|
||||
if b.clicked() {
|
||||
st.rename = Some(active.clone());
|
||||
}
|
||||
}
|
||||
let b = ui.add_enabled(custom, egui::Button::new("Delete"));
|
||||
app.auto.add("shortcuts.delete", b.rect, "Delete");
|
||||
if b.clicked() {
|
||||
run.push(("shortcuts.preset".into(), json!({"op": "delete", "name": active})));
|
||||
}
|
||||
let b = ui.add_enabled(custom, egui::Button::new("Reset Preset"));
|
||||
app.auto.add("shortcuts.resetPreset", b.rect, "Reset Preset");
|
||||
if b.clicked() {
|
||||
run.push(("shortcuts.reset".into(), json!({})));
|
||||
}
|
||||
let b = ui.button("Import...");
|
||||
app.auto.add("shortcuts.import", b.rect, "Import");
|
||||
if b.clicked()
|
||||
&& let Some(p) = app.hooks.pick_files.as_ref().and_then(|f| f(&["json"]).into_iter().next())
|
||||
{
|
||||
run.push(("shortcuts.import".into(), json!({"path": p})));
|
||||
}
|
||||
let b = ui.button("Export...");
|
||||
app.auto.add("shortcuts.export", b.rect, "Export");
|
||||
if b.clicked()
|
||||
&& let Some(p) = app.hooks.pick_save_file.as_ref().and_then(|f| f(&format!("{active}.json"), "json"))
|
||||
{
|
||||
run.push(("shortcuts.export".into(), json!({"preset": active, "path": p})));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
// ---- modifiers + legend
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Modifiers:");
|
||||
let names = if cfg!(target_os = "macos") { ["⌃ Ctrl", "⌘ Cmd", "⌥ Opt", "⇧ Shift"] } else { ["", "Ctrl", "Alt", "Shift"] };
|
||||
for (i, (n, id)) in names.iter().zip(["ctrl", "cmd", "alt", "shift"]).enumerate() {
|
||||
if n.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let r = ui.add(egui::Button::selectable(mods[i], *n));
|
||||
app.auto.add(&format!("shortcuts.mod.{id}"), r.rect, n);
|
||||
if r.clicked() {
|
||||
st.mods[i] = !st.mods[i];
|
||||
}
|
||||
}
|
||||
ui.add_space(20.0);
|
||||
for (c, l) in [(APP_COLOR, "Application-wide"), (PANEL_COLOR, "Panel-specific")] {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, 2.0, c);
|
||||
ui.label(RichText::new(l).color(t.text_dim));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
// ---- keyboard
|
||||
let unit = 40.0;
|
||||
let gap = 3.0;
|
||||
let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), unit * 6.0 + gap * 6.0 + 4.0), Sense::hover());
|
||||
let p = ui.painter_at(area);
|
||||
let draw_key = |app: &mut EffectcraftApp, r: Rect, label: &str, name: &str, st: &mut EditorState| {
|
||||
let mod_idx = match name {
|
||||
"#ctrl" => Some(0),
|
||||
"#cmd" => Some(1),
|
||||
"#alt" => Some(2),
|
||||
"#shift" => Some(3),
|
||||
_ => None,
|
||||
};
|
||||
let (a, pn) = keymap.get(name).copied().unwrap_or((false, false));
|
||||
let fill = match mod_idx {
|
||||
Some(i) if mods[i] => t.accent,
|
||||
_ => t.field_bg,
|
||||
};
|
||||
p.rect_filled(r, 4.0, fill);
|
||||
if a && pn {
|
||||
let half = Rect::from_min_max(r.min, pos2(r.center().x, r.max.y));
|
||||
p.rect_filled(half, 4.0, APP_COLOR);
|
||||
p.rect_filled(Rect::from_min_max(pos2(r.center().x, r.min.y), r.max), 4.0, PANEL_COLOR);
|
||||
} else if a {
|
||||
p.rect_filled(r, 4.0, APP_COLOR);
|
||||
} else if pn {
|
||||
p.rect_filled(r, 4.0, PANEL_COLOR);
|
||||
}
|
||||
let sel = st.key.as_deref() == Some(name) && !name.is_empty();
|
||||
p.rect_stroke(r, 4.0, Stroke::new(if sel { 2.0 } else { 1.0 }, if sel { Color32::WHITE } else { t.field_border }), StrokeKind::Inside);
|
||||
p.text(
|
||||
r.left_top() + vec2(5.0, 4.0),
|
||||
Align2::LEFT_TOP,
|
||||
label,
|
||||
Tokens::ui(11.0),
|
||||
if a || pn || mod_idx.is_some_and(|i| mods[i]) { Color32::WHITE } else { t.text },
|
||||
);
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
let id = format!("shortcuts.key.{}", name.trim_start_matches('#'));
|
||||
app.auto.add(&id, r, label);
|
||||
let resp = ui.interact(r, egui::Id::new(("sc-key", name, r.min.x as i32)), Sense::click());
|
||||
if resp.clicked() {
|
||||
if let Some(i) = mod_idx {
|
||||
st.mods[i] = !st.mods[i];
|
||||
} else {
|
||||
st.key = Some(name.to_string());
|
||||
st.pending = format!("{}{}", mods_text(mods), name);
|
||||
}
|
||||
}
|
||||
if !name.starts_with('#') {
|
||||
let who: Vec<String> =
|
||||
table.bindings().into_iter().filter(|(sc, _)| split(sc) == (mods, name.to_string())).map(|(_, b)| b.label.clone()).collect();
|
||||
if !who.is_empty() {
|
||||
resp.on_hover_text(who.join("\n"));
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut y = area.min.y + 2.0;
|
||||
for row in keyboard_rows() {
|
||||
let mut x = area.min.x + 2.0;
|
||||
for (label, name, w) in row {
|
||||
let r = Rect::from_min_size(pos2(x, y), vec2(unit * w - gap, unit - gap));
|
||||
if !(label.is_empty() && name.is_empty()) {
|
||||
draw_key(app, r, label, name, &mut st);
|
||||
}
|
||||
x += unit * w;
|
||||
}
|
||||
y += unit + gap;
|
||||
}
|
||||
let side_x = area.min.x + unit * 16.0;
|
||||
for (label, name, c, r) in SIDE_KEYS {
|
||||
let rect = Rect::from_min_size(pos2(side_x + c * unit, area.min.y + 2.0 + r * (unit + gap)), vec2(unit - gap, unit - gap));
|
||||
draw_key(app, rect, label, name, &mut st);
|
||||
}
|
||||
// Key details: everything bound to the selected key.
|
||||
let info_x = side_x + unit * 3.5;
|
||||
let info = Rect::from_min_max(pos2(info_x, area.min.y + 2.0), pos2(area.max.x - 2.0, area.max.y - 2.0));
|
||||
p.rect_filled(info, 4.0, t.row);
|
||||
let p = ui.painter_at(info.shrink(2.0));
|
||||
if let Some(k) = &st.key {
|
||||
p.text(info.left_top() + vec2(8.0, 6.0), Align2::LEFT_TOP, format!("Key: {k}"), Tokens::semibold(12.0), t.tab_text_active);
|
||||
let mut yy = info.min.y + 26.0;
|
||||
let mut list: Vec<(String, String, String)> =
|
||||
table.bindings().into_iter().filter(|(sc, _)| split(sc).1 == *k).map(|(sc, b)| (sc.to_string(), b.label.clone(), b.scope.clone())).collect();
|
||||
list.sort();
|
||||
if list.is_empty() {
|
||||
p.text(pos2(info.min.x + 8.0, yy), Align2::LEFT_TOP, "Not assigned", Tokens::ui(11.5), t.text_dim);
|
||||
}
|
||||
for (sc, label, scope) in list.into_iter().take(9) {
|
||||
let col = if scope == APP_SCOPE { APP_COLOR } else { PANEL_COLOR };
|
||||
p.text(pos2(info.min.x + 8.0, yy), Align2::LEFT_TOP, crate::menus::shortcut_text(&sc), Tokens::ui(11.5), col);
|
||||
p.text(pos2(info.min.x + 78.0, yy), Align2::LEFT_TOP, label, Tokens::ui(11.5), t.text);
|
||||
yy += 17.0;
|
||||
}
|
||||
} else {
|
||||
p.text(info.left_top() + vec2(8.0, 6.0), Align2::LEFT_TOP, "Click a key to see its commands.", Tokens::ui(11.5), t.text_dim);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
// ---- search + command list + editor
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut st.query).hint_text("Search commands or shortcuts").desired_width(360.0));
|
||||
app.auto.add("shortcuts.search", r.rect, "Search");
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let q = st.query.to_lowercase();
|
||||
let rows: Vec<&Bindable> = table
|
||||
.bindables
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
if q.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let keys = table.keys.get(&b.key).map(|k| k.join(" ")).unwrap_or_default();
|
||||
let hay = format!("{} {} {} {}", b.label, b.command, b.path.join(" "), keys).to_lowercase();
|
||||
q.split_whitespace().all(|w| hay.contains(w))
|
||||
})
|
||||
.collect();
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 270.0), egui::Layout::left_to_right(egui::Align::Min), |ui| {
|
||||
ui.set_height(270.0);
|
||||
// Command list.
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(620.0);
|
||||
let row_h = 20.0;
|
||||
egui::ScrollArea::vertical().id_salt("sc-rows").max_height(230.0).auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, range| {
|
||||
for i in range {
|
||||
let b = rows[i];
|
||||
let keys = table.keys.get(&b.key).cloned().unwrap_or_default();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
|
||||
let sel = st.selected.as_deref() == Some(b.key.as_str());
|
||||
if sel {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
} else if i % 2 == 1 {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_alt);
|
||||
}
|
||||
let pp = ui.painter();
|
||||
pp.text(pos2(r.min.x + 6.0, r.center().y), Align2::LEFT_CENTER, &b.label, Tokens::ui(11.5), t.text);
|
||||
pp.text(pos2(r.min.x + 230.0, r.center().y), Align2::LEFT_CENTER, b.path.join(" > "), Tokens::ui(10.5), t.text_faint);
|
||||
let ks: Vec<String> = keys.iter().map(|k| crate::menus::shortcut_text(k)).collect();
|
||||
let col = if b.scope == APP_SCOPE { APP_COLOR } else { PANEL_COLOR };
|
||||
pp.text(
|
||||
pos2(r.max.x - 8.0, r.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
ks.join(", "),
|
||||
Tokens::ui(11.5),
|
||||
if keys.is_empty() { t.text_faint } else { col },
|
||||
);
|
||||
app.auto.add(&format!("shortcuts.row.{i}"), r, &b.label);
|
||||
if resp.clicked() {
|
||||
st.selected = Some(b.key.clone());
|
||||
st.pending.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.label(RichText::new(format!("{} of {} commands", rows.len(), table.bindables.len())).color(t.text_faint));
|
||||
});
|
||||
ui.separator();
|
||||
// Selected command editor.
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(310.0);
|
||||
let Some(b) = st.selected.as_ref().and_then(|k| table.find(k)).cloned() else {
|
||||
ui.label(RichText::new("Select a command to change its shortcut.").color(t.text_dim));
|
||||
return;
|
||||
};
|
||||
ui.label(RichText::new(&b.label).font(Tokens::semibold(13.0)).color(t.tab_text_active));
|
||||
ui.label(RichText::new(format!("{} • {}", b.command, b.scope)).color(t.text_faint));
|
||||
ui.add_space(6.0);
|
||||
let keys = table.keys.get(&b.key).cloned().unwrap_or_default();
|
||||
if keys.is_empty() {
|
||||
ui.label(RichText::new("No shortcut").color(t.text_dim));
|
||||
}
|
||||
for (n, k) in keys.iter().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(crate::menus::shortcut_text(k)).monospace());
|
||||
let r = ui.small_button("Remove");
|
||||
app.auto.add(&format!("shortcuts.removeKey.{n}"), r.rect, "Remove");
|
||||
if r.clicked() {
|
||||
let rest: Vec<&String> = keys.iter().filter(|x| *x != k).collect();
|
||||
run.push(("shortcuts.set".into(), json!({"command": b.command, "params": b.params, "keys": rest})));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let hint = if st.recording { "Press keys…" } else { "Click, then press keys" };
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut st.pending).hint_text(hint).desired_width(170.0));
|
||||
app.auto.add("shortcuts.record", r.rect, "Shortcut");
|
||||
if r.gained_focus() || (r.clicked() && !st.recording) {
|
||||
st.recording = true;
|
||||
}
|
||||
if r.lost_focus() {
|
||||
st.recording = false;
|
||||
}
|
||||
let norm = normalize(&st.pending);
|
||||
let b2 = ui.add_enabled(norm.is_some(), egui::Button::new("Assign"));
|
||||
app.auto.add("shortcuts.assign", b2.rect, "Assign");
|
||||
if b2.clicked()
|
||||
&& let Some(k) = norm
|
||||
{
|
||||
let mut all = keys.clone();
|
||||
if !all.contains(&k) {
|
||||
all.push(k);
|
||||
}
|
||||
run.push(("shortcuts.set".into(), json!({"command": b.command, "params": b.params, "keys": all})));
|
||||
st.pending.clear();
|
||||
}
|
||||
});
|
||||
if let Some(k) = normalize(&st.pending) {
|
||||
let conflicts = table.conflicts(&b, &k);
|
||||
if !conflicts.is_empty() {
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(format!("⚠ {} is used by:", crate::menus::shortcut_text(&k))).color(t.warning));
|
||||
for c in conflicts.iter().take(4) {
|
||||
ui.label(RichText::new(format!(" {} ({})", c.label, c.scope)).color(t.warning));
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let r = ui.add_enabled(keys != b.defaults, egui::Button::new("Reset to Default"));
|
||||
app.auto.add("shortcuts.resetCommand", r.rect, "Reset to Default");
|
||||
if r.clicked() {
|
||||
run.push(("shortcuts.reset".into(), json!({"command": b.command, "params": b.params})));
|
||||
}
|
||||
if !b.defaults.is_empty() {
|
||||
let d: Vec<String> = b.defaults.iter().map(|k| crate::menus::shortcut_text(k)).collect();
|
||||
ui.label(RichText::new(format!("Default: {}", d.join(", "))).color(t.text_faint));
|
||||
}
|
||||
if active == DEFAULT_PRESET {
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new("Changing a shortcut creates a custom preset; the default stays as it is.").color(t.text_faint).small());
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.add(egui::Button::new(RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
|
||||
app.auto.add("shortcuts.close", r.rect, "OK");
|
||||
close |= r.clicked();
|
||||
});
|
||||
});
|
||||
app.dialog_state.shortcuts = st;
|
||||
for (cmd, params) in run {
|
||||
if let Err(e) = app.session.execute(&cmd, params) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
if close {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn combo_text_round_trips_through_the_parser() {
|
||||
let cases = [
|
||||
(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::K),
|
||||
(egui::Modifiers::ALT, egui::Key::OpenBracket),
|
||||
(egui::Modifiers::NONE, egui::Key::F9),
|
||||
];
|
||||
for (m, k) in cases {
|
||||
let txt = combo_text(m, k).unwrap();
|
||||
let (pm, pk) = crate::menus::parse_shortcut(&txt).unwrap();
|
||||
assert_eq!(pk, k, "{txt}");
|
||||
assert_eq!((pm.command, pm.alt, pm.shift), (m.command, m.alt, m.shift), "{txt}");
|
||||
}
|
||||
assert_eq!(split("Cmd+Shift+K"), ([false, true, false, true], "K".into()));
|
||||
assert_eq!(split("Cmd++"), ([false, true, false, false], "+".into()));
|
||||
assert_eq!(split("F9"), ([false; 4], "F9".into()));
|
||||
}
|
||||
}
|
||||
|
|
@ -635,7 +635,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let x = tm.x(m.time.seconds());
|
||||
pr.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(x - 4.0, ruler.max.y - 10.0), pos2(x + 4.0, ruler.max.y - 10.0), pos2(x, ruler.max.y - 4.0)],
|
||||
Tokens::label(m.label),
|
||||
t.label(m.label),
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
|
|
@ -763,11 +763,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
// Label swatch.
|
||||
let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0));
|
||||
lp.rect_filled(lr, 2.0, Tokens::label(layer.label));
|
||||
lp.rect_filled(lr, 2.0, t.label(layer.label));
|
||||
let lresp = ui.interact(lr, egui::Id::new(("label", layer.id.0)), Sense::click());
|
||||
lresp.context_menu(|ui| {
|
||||
for lab in effectcraft_engine::color::Label::ALL {
|
||||
if ui.button(lab.name()).clicked() {
|
||||
let name = if lab == effectcraft_engine::color::Label::None { lab.name().to_string() } else { app.session.prefs.label_name(lab) };
|
||||
if ui.button(name).clicked() {
|
||||
actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()})));
|
||||
ui.close();
|
||||
}
|
||||
|
|
@ -940,7 +941,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let x_in = tm.x(layer.in_point.seconds());
|
||||
let x_out = tm.x(layer.out_point.seconds());
|
||||
let bar = Rect::from_min_max(pos2(x_in, r.min.y + 3.0), pos2(x_out, r.max.y - 3.0));
|
||||
let lc = Tokens::label(layer.label);
|
||||
let lc = t.label(layer.label);
|
||||
let fill = if is_sel { lc.gamma_multiply(0.85) } else { lc.gamma_multiply(0.55) };
|
||||
gp.rect_filled(bar, 2.0, fill);
|
||||
if is_sel {
|
||||
|
|
|
|||
|
|
@ -331,7 +331,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
|
||||
// Guides.
|
||||
if app.ui.viewer.safe_margins {
|
||||
for (k, a) in [(0.9, 120u8), (0.8, 160)] {
|
||||
let g = &app.session.prefs.grids;
|
||||
let (act, title) = (1.0 - g.action_safe as f32 / 100.0, 1.0 - g.title_safe as f32 / 100.0);
|
||||
for (k, a) in [(act, 120u8), (title, 160)] {
|
||||
let r = Rect::from_center_size(comp_rect.center(), comp_rect.size() * k);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_white_alpha(a)), StrokeKind::Middle);
|
||||
}
|
||||
|
|
@ -340,22 +342,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
painter.line_segment([c - vec2(0.0, 10.0), c + vec2(0.0, 10.0)], Stroke::new(1.0, Color32::from_white_alpha(140)));
|
||||
}
|
||||
if app.ui.viewer.grid {
|
||||
let step = 100.0 * zoom;
|
||||
// Settings ▸ Grids & Guides: gridline spacing, subdivisions and colour.
|
||||
let g = &app.session.prefs.grids;
|
||||
let [r, gg, b] = hex_rgb(&g.grid_color).unwrap_or([120, 160, 255]);
|
||||
let major = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 90));
|
||||
let minor = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 35));
|
||||
let step = (g.grid_spacing as f32 * zoom).max(2.0);
|
||||
let sub = g.grid_subdivisions.max(1) as f32;
|
||||
let sstep = step / sub;
|
||||
let draw_minor = sstep >= 4.0;
|
||||
let mut i = 0u32;
|
||||
let mut gx = comp_rect.min.x;
|
||||
while gx <= comp_rect.max.x {
|
||||
painter.line_segment([pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 160, 255, 50)));
|
||||
gx += step;
|
||||
let is_major = i.is_multiple_of(g.grid_subdivisions.max(1));
|
||||
if is_major || draw_minor {
|
||||
painter.line_segment([pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], if is_major { major } else { minor });
|
||||
}
|
||||
gx += sstep;
|
||||
i += 1;
|
||||
}
|
||||
let mut i = 0u32;
|
||||
let mut gy = comp_rect.min.y;
|
||||
while gy <= comp_rect.max.y {
|
||||
painter.line_segment([pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 160, 255, 50)));
|
||||
gy += step;
|
||||
let is_major = i.is_multiple_of(g.grid_subdivisions.max(1));
|
||||
if is_major || draw_minor {
|
||||
painter.line_segment([pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], if is_major { major } else { minor });
|
||||
}
|
||||
gy += sstep;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Comp guides (View ▸ Show Guides) and the region of interest.
|
||||
if app.ui.viewer.guides {
|
||||
let stroke = Stroke::new(1.0, Color32::from_rgb(0x3c, 0xc8, 0xf0));
|
||||
let [r, gg, b] = hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]);
|
||||
let stroke = Stroke::new(1.0, Color32::from_rgb(r, gg, b));
|
||||
for g in &comp.guides {
|
||||
if g.vertical {
|
||||
let x = comp_rect.min.x + g.position as f32 * zoom;
|
||||
|
|
@ -384,8 +405,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
ui.data_mut(|d| d.remove::<(LayerId, u64)>(pen_id()));
|
||||
}
|
||||
if app.ui.viewer.show_layer_controls {
|
||||
// Settings ▸ General ▸ Path Point and Handle Size; Appearance ▸ Use Label Color for
|
||||
// Layer Handles and Paths.
|
||||
let hs = app.session.prefs.general.path_point_size as f32 + 2.0;
|
||||
let vs = app.session.prefs.general.path_point_size as f32 + 1.0;
|
||||
let label_handles = app.session.prefs.appearance.use_label_color_for_handles;
|
||||
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) {
|
||||
let col = Tokens::label(l.label);
|
||||
let col = if label_handles { t.label(l.label) } else { t.accent };
|
||||
// Motion path of animated position.
|
||||
if let Some(pos) = l.transform().and_then(|tr| tr.get("position"))
|
||||
&& pos.keys.len() > 1
|
||||
|
|
@ -450,10 +476,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
painter.circle_filled(h, 3.0, mc);
|
||||
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: m });
|
||||
}
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, mc);
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, mc);
|
||||
} else {
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, Color32::from_black_alpha(160));
|
||||
painter.rect_stroke(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160));
|
||||
painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
|
||||
}
|
||||
app.auto.add(&format!("viewer.mask.{}.vertex.{i}", g.uid), Rect::from_center_size(s, vec2(8.0, 8.0)), &g.name);
|
||||
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: s, tangent: None, vertex: *v, l2c: m });
|
||||
|
|
@ -479,7 +505,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
painter.add(egui::Shape::closed_line(sq.clone(), Stroke::new(1.0, col)));
|
||||
for i in 0..8 {
|
||||
let hp = if i < 4 { sq[i] } else { sq[i - 4] + (sq[(i - 3) % 4] - sq[i - 4]) * 0.5 };
|
||||
let hr = Rect::from_center_size(hp, vec2(HANDLE, HANDLE));
|
||||
let hr = Rect::from_center_size(hp, vec2(hs, hs));
|
||||
painter.rect_filled(hr, 0.0, col);
|
||||
painter.rect_stroke(hr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(120)), StrokeKind::Outside);
|
||||
handle_hits.push((l.id, i, hp));
|
||||
|
|
@ -1019,7 +1045,7 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap,
|
|||
};
|
||||
for l in ectx.comp.layers.iter().filter(|l| (l.is_camera() || l.is_light()) && l.is_active_at(ectx.time)) {
|
||||
let sel = selected.contains(&l.id);
|
||||
let col = Tokens::label(l.label).gamma_multiply(if sel { 1.0 } else { 0.6 });
|
||||
let col = app.tokens.label(l.label).gamma_multiply(if sel { 1.0 } else { 0.6 });
|
||||
let stroke = Stroke::new(if sel { 1.5 } else { 1.0 }, col);
|
||||
let (eye, fwd, down) = three_d::camera::layer_frame(ectx, l);
|
||||
let poi = |l: &Layer| -> Option<Vec3> {
|
||||
|
|
@ -1372,3 +1398,13 @@ fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
app.auto.add("viewer.empty.import", b2, "New Composition From Footage");
|
||||
}
|
||||
|
||||
/// `#rrggbb` → sRGB bytes.
|
||||
pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> {
|
||||
let h = s.trim().trim_start_matches('#');
|
||||
if h.len() != 6 || !h.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
|
||||
Some([p(0)?, p(2)?, p(4)?])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ pub struct Tokens {
|
|||
pub timecode: Color32,
|
||||
pub danger: Color32,
|
||||
pub warning: Color32,
|
||||
/// Label colours (`Label::ALL` order), from Settings ▸ Labels.
|
||||
pub labels: [Color32; 17],
|
||||
pub radius: f32,
|
||||
pub radius_sm: f32,
|
||||
pub gap: f32,
|
||||
|
|
@ -112,6 +114,7 @@ impl Tokens {
|
|||
timecode: Color32::from_rgb(0x3d, 0x8f, 0xf5),
|
||||
danger: Color32::from_rgb(0xe0, 0x4a, 0x3c),
|
||||
warning: Color32::from_rgb(0xe8, 0x9a, 0x2c),
|
||||
labels: default_labels(),
|
||||
radius: 6.0,
|
||||
radius_sm: 3.0,
|
||||
gap: 4.0,
|
||||
|
|
@ -172,11 +175,65 @@ impl Tokens {
|
|||
pub fn medium(size: f32) -> FontId {
|
||||
FontId::new(size, FontFamily::Name("medium".into()))
|
||||
}
|
||||
/// sRGB colour of an item/layer label.
|
||||
pub fn label(l: effectcraft_color::Label) -> Color32 {
|
||||
/// sRGB colour of an item/layer label (Settings ▸ Labels).
|
||||
pub fn label(&self, l: effectcraft_color::Label) -> Color32 {
|
||||
let i = effectcraft_color::Label::ALL.iter().position(|x| *x == l).unwrap_or(0);
|
||||
self.labels[i]
|
||||
}
|
||||
|
||||
/// Theme tokens for the current settings: theme, UI brightness and label colours.
|
||||
pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> Tokens {
|
||||
let kind = ThemeKind::from_name(&p.appearance.theme).unwrap_or_default();
|
||||
let mut t = Tokens::for_kind(kind).with_brightness(p.appearance.brightness as f32);
|
||||
for (i, l) in effectcraft_color::Label::ALL.iter().enumerate() {
|
||||
let [r, g, b] = p.label_rgb(*l);
|
||||
t.labels[i] = Color32::from_rgb(r, g, b);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Lighten (b > 0) or darken (b < 0) the interface greys, like After Effects' brightness
|
||||
/// slider. Accent colours, labels and keyframe colours are kept.
|
||||
pub fn with_brightness(mut self, b: f32) -> Tokens {
|
||||
let b = b.clamp(-1.0, 1.0);
|
||||
if b.abs() < 1e-3 {
|
||||
return self;
|
||||
}
|
||||
let adj = |c: &mut Color32| {
|
||||
let f = |v: u8| -> u8 {
|
||||
let v = v as f32;
|
||||
(if b > 0.0 { v + (110.0 - v).max(0.0) * b * 0.8 + 10.0 * b } else { v * (1.0 + b * 0.6) }).round().clamp(0.0, 255.0) as u8
|
||||
};
|
||||
*c = Color32::from_rgb(f(c.r()), f(c.g()), f(c.b()));
|
||||
};
|
||||
for c in [
|
||||
&mut self.app_bg,
|
||||
&mut self.header_bg,
|
||||
&mut self.panel_bg,
|
||||
&mut self.hover,
|
||||
&mut self.pressed,
|
||||
&mut self.field_bg,
|
||||
&mut self.field_border,
|
||||
&mut self.separator,
|
||||
&mut self.row,
|
||||
&mut self.row_alt,
|
||||
&mut self.row_selected,
|
||||
&mut self.tl_bg,
|
||||
&mut self.tl_ruler_bg,
|
||||
&mut self.pasteboard,
|
||||
&mut self.work_area,
|
||||
] {
|
||||
adj(c);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn default_labels() -> [Color32; 17] {
|
||||
effectcraft_color::Label::ALL.map(|l| {
|
||||
let [r, g, b] = l.rgb();
|
||||
Color32::from_rgb(r, g, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
||||
|
|
|
|||
|
|
@ -125,3 +125,9 @@ the JSON control channel and the MCP server all dispatch by id, so anything a pe
|
|||
menu, an agent can do too. Every interactive widget registers an automation id, so agents can also
|
||||
inspect and click the interface. See [agents.md](agents.md) and
|
||||
[control-protocol.md](control-protocol.md).
|
||||
|
||||
Settings (`engine::prefs`), keyboard shortcut presets (`engine::shortcuts`) and auto-save /
|
||||
crash recovery (`engine::autosave`) live in the engine too; they persist through a
|
||||
`ConfigStore` the frontend provides (a directory on the desktop, `localStorage` on the web).
|
||||
The shortcut dispatcher and the menus read the active preset. See
|
||||
[preferences.md](preferences.md).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
# Settings, keyboard shortcuts, auto-save and crash recovery
|
||||
|
||||
## Settings (Preferences)
|
||||
|
||||
EffectCraft ▸ Settings (macOS) or Edit ▸ Preferences (Windows/Linux), `Cmd+Alt+;`. The dialog has
|
||||
After Effects 2026's pages (General, Startup & Repair, Project, Composition, Previews, Appearance,
|
||||
Grids & Guides, Labels, Type, Import, Export, Audio, Disk, Memory & CPU, Video, 3D, Scripting &
|
||||
Expressions) with OK, Cancel, Previous and Next. Older page names still open the right page
|
||||
(`prefs.open {"page": "Auto-Save"}` opens Project, `Media & Disk Cache` opens Disk, `Memory &
|
||||
Performance` opens Memory & CPU).
|
||||
|
||||
The model is `effectcraft_engine::prefs::Prefs`: serde, versioned (`version`), addressed by dotted
|
||||
keys (`general.undoLevels`, `autoSave.intervalMinutes`, `labels.3.name`). It is stored as
|
||||
`prefs.json` through the session's `ConfigStore` (the desktop app uses the platform config
|
||||
directory: `~/Library/Application Support/EffectCraft` on macOS, `%APPDATA%\EffectCraft` on
|
||||
Windows, `$XDG_CONFIG_HOME/effectcraft` on Linux; the web app can back the same trait with
|
||||
`localStorage`). Loading migrates older layouts, keeps unknown keys (from newer versions) and
|
||||
falls back to defaults for values that don't parse.
|
||||
|
||||
Commands (CLI, MCP, control channel):
|
||||
|
||||
| Command | Parameters |
|
||||
|---|---|
|
||||
| `prefs.get` | `{key?}` (omit for everything) |
|
||||
| `prefs.set` | `{key, value}` or `{values: {key: value}}` |
|
||||
| `prefs.reset` | `{page?}` (omit for everything) |
|
||||
| `prefs.open` | `{page?}` opens the Settings dialog |
|
||||
| `prefs.pages` | the page schema: every row, its key, type, range and whether it is wired |
|
||||
|
||||
### Settings that change behaviour
|
||||
|
||||
- `general.undoLevels`: Levels of Undo
|
||||
- `general.pathPointSize`: Path Point and Handle Size
|
||||
- `general.recentItems`: Recent Projects Shown
|
||||
- `general.showToolTips`: Show Tool Tips
|
||||
- `general.createLayersAtCompStart`: Create Layers at Composition Start Time
|
||||
- `general.defaultSpatialLinear`: Default Spatial Interpolation to Linear
|
||||
- `startup.showHomeOnLaunch`: Show Home Screen When Launching
|
||||
- `startup.offerCrashRecovery`: Offer to Open the Latest Auto-Save After a Crash
|
||||
- `project.useTemplate`: New Project Loads Template
|
||||
- `project.templatePath`: Template Project
|
||||
- `autoSave.enabled`: Automatically Save Projects
|
||||
- `autoSave.intervalMinutes`: Save Every
|
||||
- `autoSave.maxVersions`: Maximum Project Versions
|
||||
- `autoSave.location`: Auto-Save Location
|
||||
- `autoSave.folder`: Custom Location
|
||||
- `autoSave.saveOnRenderStart`: Save When Starting Render Queue
|
||||
- `composition.showRenderingProgress`: Show Rendering Progress in Info Panel and Flowchart
|
||||
- `previews.adaptiveResolutionLimit`: Adaptive Resolution Limit
|
||||
- `previews.cacheFramesWhenIdle`: Cache Frames When Idle
|
||||
- `previews.fastPreviews`: Fast Previews (Draft 3D, Faster Effects)
|
||||
- `appearance.theme`: Theme
|
||||
- `appearance.brightness`: Brightness
|
||||
- `appearance.useLabelColorForHandles`: Use Label Color for Layer Handles and Paths
|
||||
- `grids.gridColor`: Color
|
||||
- `grids.gridSpacing`: Gridline Every
|
||||
- `grids.gridSubdivisions`: Subdivisions
|
||||
- `grids.guideColor`: Color
|
||||
- `grids.actionSafe`: Action-safe
|
||||
- `grids.titleSafe`: Title-safe
|
||||
- `import.stillFootage`: Still Footage
|
||||
- `import.stillSeconds`: Still Duration
|
||||
- `import.sequenceFps`: Sequence Footage
|
||||
- `audio.outputDevice`: Default Output (device list from the desktop host)
|
||||
- `audio.outputLeft`: Left (Audio Output Mapping)
|
||||
- `audio.outputRight`: Right (Audio Output Mapping)
|
||||
- `memory.layerCacheMb`: Layer Cache
|
||||
- `memory.mediaCacheMb`: Footage Frame Cache
|
||||
- `memory.previewCacheMb`: Preview (RAM) Cache
|
||||
- `threeD.defaultRenderer`: Default 3D Renderer
|
||||
- `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu,
|
||||
the timeline, project panel, render queue and viewer handles
|
||||
|
||||
### Not wired yet (TODO)
|
||||
|
||||
These rows are in the dialog for After Effects parity and are stored, but nothing reads them yet.
|
||||
`prefs.pages` reports them with `"live": false`. A test keeps this list in step with the schema.
|
||||
|
||||
- `general.switchesAffectNestedComps`: Switches Affect Nested Comps
|
||||
- `general.preserveConstantVertexCount`: Preserve Constant Vertex and Feather Point Count when Editing Masks
|
||||
- `general.syncTimeRelatedItems`: Synchronize Time of All Related Items
|
||||
- `general.expressionPickWhipCompact`: Expression Pick Whip Writes Compact English
|
||||
- `general.createSplitLayersAbove`: Create Split Layers Above Original Layer
|
||||
- `general.useSystemColorPicker`: Use System Color Picker
|
||||
- `startup.showHomeOnOpenProject`: Show Home Screen When Opening a Project
|
||||
- `composition.motionPath`: Motion Path
|
||||
- `composition.motionPathSeconds`: Seconds
|
||||
- `composition.motionPathKeyframes`: Keyframes
|
||||
- `composition.hardwareAcceleratePanels`: Hardware Accelerate Composition, Layer and Footage Panels
|
||||
- `previews.showInternalWireframes`: Show Internal Wireframes
|
||||
- `previews.zoomQuality`: Viewer Zoom Quality
|
||||
- `appearance.useLabelColorForTabs`: Use Label Color for Related Tabs
|
||||
- `appearance.cycleMaskColors`: Cycle Mask Colors
|
||||
- `appearance.useGradients`: Use Gradients
|
||||
- `grids.gridStyle`: Style
|
||||
- `grids.proportionalHorizontal`: Horizontal
|
||||
- `grids.proportionalVertical`: Vertical
|
||||
- `grids.guideStyle`: Style
|
||||
- `type.textEngine`: Text Engine
|
||||
- `type.fontPreview`: Show Font Preview
|
||||
- `type.recentFonts`: Number of Recent Fonts to Display
|
||||
- `type.fontNamesInEnglish`: Show Font Names in English
|
||||
- `import.reportMissingFrames`: Report Missing Frames
|
||||
- `import.unlabeledAlpha`: Interpret Unlabeled Alpha As
|
||||
- `import.dragImportAs`: Default Drag Import As
|
||||
- `export.defaultOutputFolder`: Default Output Folder
|
||||
- `export.segmentSequences`: Segment Sequences
|
||||
- `export.segmentSequenceFiles`: Files per Segment
|
||||
- `export.segmentMovies`: Segment Movie Files
|
||||
- `export.segmentMovieMb`: Segment Size
|
||||
- `export.appendBitsToName`: Append Bit Depth to File Name
|
||||
- `audio.previewSampleRate`: Preview Sample Rate
|
||||
- `disk.diskCacheEnabled`: Enable Disk Cache
|
||||
- `disk.diskCacheMaxGb`: Maximum Disk Cache Size
|
||||
- `disk.diskCacheFolder`: Disk Cache Folder
|
||||
- `disk.mediaCacheFolder`: Database and Cache Folder
|
||||
- `disk.conformedMediaFolder`: Conformed Audio Folder
|
||||
- `memory.ramReservedGb`: RAM Reserved for Other Applications
|
||||
- `memory.reduceCacheWhenLow`: Reduce Cache Size When System Is Low on Memory
|
||||
- `video.enableOutput`: Enable Video Preview Output
|
||||
- `video.device`: Video Device
|
||||
- `video.outputDuringPlayback`: Video Output During Playback
|
||||
- `video.mirrorOnMonitor`: Mirror on Computer Monitor
|
||||
- `video.disableWhenBackground`: Disable Video Output When in Background
|
||||
- `threeD.showReferenceAxes`: Show 3D Reference Axes
|
||||
- `threeD.extendedViewer`: Extended Viewer
|
||||
- `threeD.realtimeShadows`: Realtime Shadows in Draft
|
||||
- `scripting.allowScriptsWriteFiles`: Allow Scripts to Write Files and Access Network
|
||||
- `scripting.warnExecutingFiles`: Warn User When Executing Files
|
||||
- `scripting.enableJsDebugger`: Enable JavaScript Debugger
|
||||
- `scripting.editorFontSize`: Font Size
|
||||
- `scripting.syntaxHighlighting`: Syntax Highlighting
|
||||
- `scripting.lineNumbers`: Line Numbers
|
||||
- `scripting.autoComplete`: Auto-complete
|
||||
- `scripting.bracketMatching`: Bracket Matching
|
||||
- `scripting.wordWrap`: Word Wrap
|
||||
- `scripting.errorBanner`: Show Expression Error Banner
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
Edit ▸ Keyboard Shortcuts (`Cmd+Alt+'`) opens the shortcut editor: an on-screen keyboard showing
|
||||
which keys are assigned for the held modifiers (purple: application-wide, green: panel-specific,
|
||||
both: split), modifier toggles, a searchable list of every command (all engine commands, menu
|
||||
entries with bound parameters and the frontend's tool / panel commands), and a preset menu.
|
||||
|
||||
- **Presets.** "EffectCraft Default" is After Effects' default layout and is read-only; editing
|
||||
it creates a "Custom" copy. Custom presets store only their differences from the default and
|
||||
can be duplicated, renamed, deleted, exported and imported (JSON). The active preset is what
|
||||
the menus show and what the shortcut dispatcher runs.
|
||||
- **Assigning.** Select a command, then press keys in the shortcut field (or type them), or
|
||||
click a key on the keyboard with the modifiers toggled. Conflicts with other commands in an
|
||||
overlapping scope are listed before you assign.
|
||||
- **Scope.** Menu and engine commands are application-wide; timeline commands work in the
|
||||
Timeline and viewer commands in the Composition panel.
|
||||
|
||||
Commands: `shortcuts.list {query?, keys?, assigned?}`, `shortcuts.set {command, params?, keys}`,
|
||||
`shortcuts.reset {command?}`, `shortcuts.preset {op: select|new|duplicate|delete|rename, name?,
|
||||
from?, newName?}`, `shortcuts.export {preset?, path?}`, `shortcuts.import {path? | preset?}`,
|
||||
`shortcuts.conflicts`.
|
||||
|
||||
## Auto-save and crash recovery
|
||||
|
||||
- Every *n* minutes (Settings ▸ Project ▸ Auto-Save) a project with unsaved changes is written
|
||||
to an `EffectCraft Auto-Save` folder next to it (or the custom folder) as
|
||||
`<name> auto-save N.ecproj`. Slots rotate through 1…maximum versions, overwriting the oldest.
|
||||
Untitled projects go to the custom folder, or to `Auto-Save` in the config directory.
|
||||
- Every project and auto-save write is atomic: a temporary file is written and flushed, then
|
||||
renamed over the target, so a crash mid-write leaves the previous file intact.
|
||||
- While the app runs, `session.lock` in the config directory records the open project and its
|
||||
latest auto-save. A clean quit removes it. If it is still there at the next launch, the app
|
||||
offers to open the latest auto-save (Settings ▸ Startup & Repair can turn this off).
|
||||
- File ▸ Open Recent lists recent projects (stored in the settings), File ▸ Revert reloads the
|
||||
saved project, File ▸ Increment and Save saves `Intro.ecproj` as `Intro 2.ecproj`.
|
||||
|
||||
Commands: `file.autoSave` (now), `file.recoveryInfo`, `file.openRecent {index? | path?}`,
|
||||
`file.clearRecent`, `file.revert`, `file.incrementAndSave`.
|
||||
Loading…
Reference in New Issue