M0.37: reconcile shared docking and qualify migration follow-up

This commit is contained in:
skylar taylor-barrick
2026-10-26 06:47:07 +01:00
116 changed files with 4149 additions and 3606 deletions
+4 -1
View File
@@ -16,6 +16,9 @@ concurrency:
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
# Track shared CI improvements on main.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with:
timeout-minutes: 120
fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
secrets: inherit
Generated
+566 -192
View File
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -11,8 +11,9 @@ rust-version = "1.95"
repository = "https://github.com/storytold/effectcraft"
[workspace.dependencies]
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["read"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", version = "=0.1.0", default-features = false, features = ["read"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", features = ["egui"] }
# internal
effectcraft-time = { path = "crates/time" }
effectcraft-geom = { path = "crates/geom" }
@@ -102,12 +103,12 @@ symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] }
# gpu (the same wgpu eframe / egui-wgpu use)
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
bytemuck = "1"
pollster = "0.4"
pollster = "1"
# ui
# Shared frontend components; keep this dependency above the engine boundary.
# Shared UI and the complete egui family select reviewed immutable commits below.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", features = ["serde"] }
# `default_fonts` and `monochrome_emoji_fonts` are explicit: the app's `FontDefinitions::default()`
# and its `✔`/`▣` chrome glyphs need both, and kittest's own dev-dependencies do not propagate.
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
@@ -219,8 +220,8 @@ lto = "thin"
codegen-units = 1
[patch.crates-io]
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.2" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.3.0" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", version = "=0.7.2" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", version = "=0.3.0" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+14 -14
View File
@@ -19,7 +19,7 @@ const enc = (k) => encodeURIComponent(k);
const dec = (n) => decodeURIComponent(n);
async function opfs() {
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no OPFS");
if (!navigator.storage?.getDirectory) throw new Error("no OPFS");
const root = await navigator.storage.getDirectory();
const dir = await root.getDirectoryHandle(DIR, { create: true });
// Probe a write: some browsers expose OPFS but only write it from workers.
@@ -116,7 +116,7 @@ export async function storeOpen(prefer) {
try { backend = await make(); break; } catch (e) { console.info(`storage: ${make.name}: ${e.message ?? e}`); }
}
// Ask the browser not to evict the store under storage pressure (granted for installed apps).
try { if (navigator.storage && navigator.storage.persist) await navigator.storage.persist(); } catch {}
try { if (navigator.storage?.persist) await navigator.storage.persist(); } catch {}
return backend.name;
}
@@ -128,7 +128,7 @@ export async function storeDelete(key) { return backend.del(key); }
/// Ask the browser to keep the origin's storage under pressure; resolves with whether it is.
export async function storePersist() {
try {
if (navigator.storage && navigator.storage.persist) return await navigator.storage.persist();
if (navigator.storage?.persist) return await navigator.storage.persist();
} catch {}
return false;
}
@@ -227,7 +227,7 @@ export async function audioStart(workletUrl) {
}
if (gen !== generation) return;
node = new AudioWorkletNode(c, "effectcraft-output", { numberOfInputs: 0, outputChannelCount: [2] });
node.port.onmessage = (e) => { if (e.data && e.data.underrun) underruns += e.data.underrun; };
node.port.onmessage = (e) => { if (e.data?.underrun) underruns += e.data.underrun; };
kind = "worklet";
} catch (e) {
console.warn("audio: AudioWorklet unavailable, using ScriptProcessorNode", e);
@@ -318,11 +318,11 @@ function spawn(base) {
const w = { worker, files: new Map(), ready: null, gpu: null };
w.ready = new Promise((res, rej) => {
worker.onmessage = (e) => {
if (e.data && e.data.type === "gpu") {
if (e.data?.type === "gpu") {
w.gpu = e.data.gpu ? { adapter: e.data.gpu } : { cpu: e.data.error || "no GPU" };
jobGpu = w.gpu;
}
if (e.data && e.data.type === "ready") res();
if (e.data?.type === "ready") res();
};
worker.onerror = (e) => rej(new Error(e.message || "worker failed to start"));
});
@@ -341,7 +341,7 @@ export function workerRun(id, json, files, base, onMessage) {
} catch (e) {
// `new Worker` throws at once when the page's origin is opaque (a sandboxed iframe): the job
// fails like one whose worker didn't start.
setTimeout(() => onMessage("error", String((e && e.message) || e), null, null));
setTimeout(() => onMessage("error", String(e?.message || e), null, null));
return;
}
running.set(id, w);
@@ -416,12 +416,12 @@ export function frameWorkerStart(base, onMessage, gpu) {
// slot stays, and the failure is reported like a worker that died, after the caller has
// recorded the index: frames then render on the page's thread (as with `?noworkers`).
frameWorkers.push(null);
setTimeout(() => onMessage(index, undefined, undefined, String((e && e.message) || e || "frame worker failed")));
setTimeout(() => onMessage(index, undefined, undefined, String(e?.message || e || "frame worker failed")));
return index;
}
worker.onmessage = (e) => {
const m = e.data;
if (m && m.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined);
if (m?.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined);
};
worker.onerror = (e) => {
onMessage(index, undefined, undefined, String(e.message || "frame worker failed"));
@@ -447,7 +447,7 @@ const cacheDirHandles = {};
async function cacheDir(kind = "frames") {
if (cacheDirHandles[kind]) return cacheDirHandles[kind];
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no Origin Private File System");
if (!navigator.storage?.getDirectory) throw new Error("no Origin Private File System");
let d = await navigator.storage.getDirectory();
for (const n of [CACHE_ROOT, "v1", kind]) d = await d.getDirectoryHandle(n, { create: true });
cacheDirHandles[kind] = d;
@@ -544,7 +544,7 @@ export async function cacheClear() {
/// Whether this thread has WebGPU (`navigator.gpu`).
export function hasWebGpu() {
return !!(self.navigator && self.navigator.gpu);
return !!self.navigator?.gpu;
}
/// Post a message ({type: "frame", json} or {type: "file", path, bytes}) to frame worker `index`.
@@ -652,7 +652,7 @@ export async function browseForget(name) {
export async function browseRead(path) {
const rel = path.replace(/^\/Folders\//, "");
const folder = folders.get(rel.split("/")[0]);
const h = folder && folder.files.get(rel);
const h = folder?.files.get(rel);
if (!h) throw new Error(`${path}: its folder is not open (reconnect it)`);
return new Uint8Array(await (await h.getFile()).arrayBuffer());
}
@@ -706,7 +706,7 @@ export function showDeviceLost(message, kept) {
self.effectcraft
.execute("file.save", {})
.catch(() => self.effectcraft.execute("file.saveAs", { path: "/Untitled Project.ecproj" }))
.catch((e) => (text.textContent = `Saving failed: ${(e && e.message) || e}`));
.catch((e) => (text.textContent = `Saving failed: ${e?.message || e}`));
const reload = document.createElement("button");
reload.textContent = "Reload";
reload.onclick = async () => {
@@ -714,7 +714,7 @@ export function showDeviceLost(message, kept) {
try {
await self.effectcraft.flush();
} catch (e) {
text.textContent = `Writing to browser storage failed (${(e && e.message) || e}). Save the project, or reload anyway and lose the changes.`;
text.textContent = `Writing to browser storage failed (${e?.message || e}). Save the project, or reload anyway and lose the changes.`;
reload.textContent = "Reload Anyway";
reload.disabled = false;
reload.onclick = () => location.reload();
+1 -1
View File
@@ -52,7 +52,7 @@ struct State {
static STATE: LazyLock<Mutex<State>> = LazyLock::new(Mutex::default);
fn state() -> std::sync::MutexGuard<'static, State> {
STATE.lock().unwrap_or_else(|e| e.into_inner())
STATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The web app's Media Browser.
+4 -4
View File
@@ -48,14 +48,14 @@ pub fn list() -> Vec<(String, usize)> {
/// The next write of `path` goes to browser storage only (no download).
pub fn save_quietly(path: &str) {
QUIET.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
QUIET.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string());
}
/// A rendered file (from the page's exporter or a worker): kept in the table unpersisted and
/// downloaded at the next [`flush_downloads`].
pub fn add_output(path: &str, data: Arc<[u8]>) {
STORE.put_file(path, data, false);
OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
OUTPUTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string());
}
/// The table path for a picked/dropped file name.
@@ -120,7 +120,7 @@ impl Services for WebServices {
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
put(path, data.into());
let quiet = {
let mut q = QUIET.lock().unwrap_or_else(|e| e.into_inner());
let mut q = QUIET.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let n = q.len();
q.retain(|p| p != path);
q.len() != n
@@ -184,7 +184,7 @@ pub fn export_sink() -> Arc<effectcraft_export::Sink> {
/// Download the render outputs written since the last call.
pub fn flush_downloads() {
let paths = std::mem::take(&mut *OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()));
let paths = std::mem::take(&mut *OUTPUTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
let mut out: Vec<(String, Arc<[u8]>)> = paths.into_iter().filter_map(|p| get(&p).map(|d| (file_name(&p).to_string(), d))).collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out.dedup_by(|a, b| a.0 == b.0);
+8 -8
View File
@@ -6,17 +6,17 @@
//!
//! | Desktop | Web |
//! |---|---|
//! | file system (`FsServices`, media reads, export writes) | a virtual file table ([`files`]) persisted to the Origin Private File System / IndexedDB ([`persist`], [`store`]) |
//! | file system (`FsServices`, media reads, export writes) | a virtual file table (`files`) persisted to the Origin Private File System / IndexedDB (`persist`, [`store`]) |
//! | config directory (settings, shortcuts, recent projects, auto-saves) | the same store ([`store::WebConfig`]) |
//! | rfd file dialogs | `<input type=file>` pickers; drop files on the page |
//! | Media Browser on the file system | browser storage and folders opened with the File System Access API ([`browse`]) |
//! | Media Browser on the file system | browser storage and folders opened with the File System Access API (`browse`) |
//! | written files (Save, Render Queue) | browser downloads (several render files as one `.zip`) |
//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device ([`frames`]); `Frames::pump` on the UI thread without workers |
//! | disk cache folder | frames in the Origin Private File System, written by the frame workers ([`diskcache`]) |
//! | (none) | the storage manager: usage, quota, persistence, clearing ([`storage`]) |
//! | Render Queue / analysis threads | Web Workers running their own engine instance ([`worker`]) |
//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown ([`audio`]) |
//! | TCP control channel / MCP | `window.effectcraft` JavaScript API ([`api`]) |
//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device (`frames`); `Frames::pump` on the UI thread without workers |
//! | disk cache folder | frames in the Origin Private File System, written by the frame workers (`diskcache`) |
//! | (none) | the storage manager: usage, quota, persistence, clearing (`storage`) |
//! | Render Queue / analysis threads | Web Workers running their own engine instance (`worker`) |
//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) |
//! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) |
//!
//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only.
+1 -1
View File
@@ -181,7 +181,7 @@ impl Default for Store {
impl Store {
pub fn lock(&self) -> std::sync::MutexGuard<'_, Mirror> {
self.mirror.lock().unwrap_or_else(|e| e.into_inner())
self.mirror.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn now(&self) -> f64 {
+8 -8
View File
@@ -162,7 +162,7 @@ try {
const info = await js("effectcraft.info()");
report.steps.gpu = { webgpu: info.webgpu, backend: info.backend, ...info.gpu };
if (info.webgpu && info.backend === "BrowserWebGpu") {
check(info.gpu && info.gpu.compositor, "GPU compositor on WebGPU");
check(info.gpu?.compositor, "GPU compositor on WebGPU");
check(info.gpu.viewerOnGpu, "viewer frames on the GPU");
}
@@ -250,7 +250,7 @@ try {
const st = await js(`(async () => { await new Promise((r) => setTimeout(r, 1200)); return effectcraft.execute("storage.info", {}); })()`);
report.steps.storage = st;
check(st.available && st.quota > 0 && st.usage > 0, `storage.info: ${JSON.stringify(st)}`);
check(st.diskCache && st.diskCache.enabled && st.diskCache.writes > 0, `disk cache in OPFS: ${JSON.stringify(st.diskCache)}`);
check(st.diskCache?.enabled && st.diskCache.writes > 0, `disk cache in OPFS: ${JSON.stringify(st.diskCache)}`);
await js(`effectcraft.execute("prefs.open", {page: "disk"})`);
await sleep(800);
report.steps.storage.screenshot = await shot("01c-storage-manager");
@@ -378,14 +378,14 @@ try {
const jobs = gj.workers.jobs || [];
const rj = jobs.filter((j) => j.kind === "Render").pop();
const wj = jobs.filter((j) => j.kind === "Warp").pop();
check(gj.workers.gpu && gj.workers.gpu.adapter, `job workers have a GPU: ${JSON.stringify(gj.workers)}`);
check(gj.workers.gpu?.adapter, `job workers have a GPU: ${JSON.stringify(gj.workers)}`);
check(gj.status.every((st) => st === "Done"), `GPU job render: ${JSON.stringify(gj.status)}`);
check(rj && rj.gpu && rj.readbacks >= 3 && rj.passes >= 6, `Render Queue frames on the job worker's GPU, in passes: ${JSON.stringify(rj)}`);
check(rj?.gpu && rj.readbacks >= 3 && rj.passes >= 6, `Render Queue frames on the job worker's GPU, in passes: ${JSON.stringify(rj)}`);
check(gj.warp.analyzed, `Warp Stabilizer analysed in a job worker: ${JSON.stringify(gj.warp)}`);
check(wj && wj.gpu && wj.readbacks > 0, `Warp Stabilizer input frames on the job worker's GPU: ${JSON.stringify(wj)}`);
check(wj?.gpu && wj.readbacks > 0, `Warp Stabilizer input frames on the job worker's GPU: ${JSON.stringify(wj)}`);
const fj = jobs.filter((j) => j.kind === "ContentFill").pop();
check(!gj.fill.error && gj.fillLayers.length > 0, `Content-Aware Fill in a job worker: ${JSON.stringify(gj.fill)} ${JSON.stringify(gj.fillLayers)}`);
check(fj && fj.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`);
check(fj?.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`);
check(gj.maxGapMs < 400, `event loop blocked ${gj.maxGapMs} ms during the GPU jobs: ${JSON.stringify(gj.gaps)}`);
check(gj.gaps.prepareFill < 50, `event loop blocked ${gj.gaps.prepareFill} ms while editing after the analysis (pre-compose, mask): ${JSON.stringify(gj.gaps)}`);
} else {
@@ -418,7 +418,7 @@ try {
check(mb.path === "/Browser Storage" && mb.entries.some((e) => e.name === "tone.wav" && e.kind === "audio"), `Media Browser: ${JSON.stringify(report.steps.mediaBrowser)}`);
check(mb.actions.some((a) => a.id === "addFiles"), "Media Browser: Add Files…");
const mbImport = await js(`effectcraft.execute("mediaBrowser.import", {paths: [${JSON.stringify(mb.entries.find((e) => e.name === "tone.wav")?.path ?? "/tone.wav")}]})`);
check(!mbImport.pending && mbImport.items && mbImport.items.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`);
check(!mbImport.pending && mbImport.items?.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`);
await js(`effectcraft.request("ui.playback", {action: "stop"})`);
await js(`effectcraft.execute("time.set", {time: 0})`);
await js(`effectcraft.request("ui.playback", {action: "play"})`);
@@ -579,7 +579,7 @@ try {
report.steps.final2 = { screenshot: await shot("03-offline") };
} catch (e) {
failed = true;
report.error = String(e && e.stack || e);
report.error = String(e?.stack || e);
try { report.errorShot = await shot("error"); } catch {}
}
report.console = logs.slice(-40);
+3
View File
@@ -48,3 +48,6 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std",
# PhotoCraft and PdfCraft) registers the handler (Objective-C interop needs unsafe, which our
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
+34 -9
View File
@@ -50,7 +50,7 @@ fn app_icon() -> egui::IconData {
fn is_project_file(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))
}
@@ -128,14 +128,24 @@ fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {
/// answer without pumping events, so the window stops answering the compositor and the desktop
/// offers to force-quit it while the Open dialog is up (#1275). Windows' blocking dialog pumps
/// its own messages, so it keeps [`effectcraft_ui_egui::Hooks::pick_files`] and friends.
fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) {
/// The dialogs asked for in a frame show at its end ([`sheet_dialogs::show_asked`]), where the
/// main window's handle is at hand.
fn install_file_dialogs(app: &mut EffectcraftApp) -> Option<std::sync::mpsc::Receiver<sheet_dialogs::Asked>> {
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
let ctx = ctx.clone();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
let (tx, rx) = std::sync::mpsc::channel::<sheet_dialogs::Asked>();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| {
if tx.send((dialog, done)).is_err() {
log::error!("file dialog: the window is gone");
}
}));
Some(rx)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
let _ = (app, ctx);
{
let _ = app;
None
}
}
/// Run a normal desktop launch: install logging and the panic hook, parse arguments (`--version` exits before the
@@ -143,6 +153,11 @@ fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) {
/// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then
/// reports startup failure through `startup_failed`. Returns the event loop's result.
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::EFFECTCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
// feed the compatibility report.
@@ -268,6 +283,7 @@ fn main() -> eframe::Result {
// reported in a toast and forgotten).
let _ = session.execute("window.restoreScriptPanels", json!({}));
let mut app = EffectcraftApp::new(session);
app.set_startup_update_check(true);
app.set_gpu_failure_bridge(gpu_failures);
app.ui.start_screen = show_home;
if let Some(n) = notice {
@@ -324,7 +340,7 @@ fn main() -> eframe::Result {
}));
// The commands' file dialogs don't block the frame where the platform's backend
// would (#511, #1275); see [`install_file_dialogs`].
install_file_dialogs(&mut app, &cc.egui_ctx);
let file_dialogs = install_file_dialogs(&mut app);
#[cfg(target_os = "macos")]
{
app.hooks.app_action = Some(Box::new(native_menu::app_action));
@@ -342,6 +358,7 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&cc.egui_ctx),
app,
file_dialogs,
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
launch,
drawn: false,
@@ -423,6 +440,8 @@ fn startup_failed(e: &eframe::Error, gl_next: bool) {
/// The desktop app: the egui app plus, on macOS, the system menu bar.
struct Desktop {
app: EffectcraftApp,
/// The commands' file dialogs, shown at the end of the frame that asked for them.
file_dialogs: Option<std::sync::mpsc::Receiver<sheet_dialogs::Asked>>,
#[cfg(target_os = "macos")]
menu: native_menu::NativeBar,
/// Documents opened from Finder, the Dock or `open` (macOS).
@@ -442,10 +461,16 @@ impl eframe::App for Desktop {
self.menu.update(&mut self.app, ctx);
self.app.tick(ctx, frame);
self.app.finish_ime_logic(ctx);
if let Some(asked) = &self.file_dialogs {
sheet_dialogs::show_asked(asked, frame, ctx);
}
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.app.ui(ui, frame);
if let Some(asked) = &self.file_dialogs {
sheet_dialogs::show_asked(asked, frame, ui.ctx());
}
self.window.frame(ui.ctx(), self.app.session.config.as_deref());
if !self.drawn {
self.drawn = true;
@@ -645,10 +670,10 @@ mod tests {
#[test]
fn commands_file_dialogs_do_not_block_the_frame() {
let mut app = effectcraft_ui_egui::EffectcraftApp::new(effectcraft_engine::Session::default());
super::install_file_dialogs(&mut app, &egui::Context::default());
let asked = super::install_file_dialogs(&mut app);
#[cfg(any(target_os = "macos", target_os = "linux"))]
assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame");
assert!(app.hooks.file_dialog_async.is_some() && asked.is_some(), "the commands' file dialogs must not block the frame");
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here");
assert!(app.hooks.file_dialog_async.is_none() && asked.is_none(), "the blocking dialog pumps its own messages here");
}
}
+8 -7
View File
@@ -2,10 +2,10 @@
//! [`effectcraft_ui_egui::native_menu`]'s spec of the engine's After Effects menu tree.
//!
//! Each frame [`NativeBar::update`] runs menu activations (the same command ids as the in-window
//! bar), and when [`native_menu::state_key`] changed it rebuilds the spec and pushes labels,
//! enabled and check state into the existing items; a structural change (Open Recent, a new
//! shortcut preset) rebuilds the menu. Settings ▸ Appearance ▸ Use In-Window Menu Bar on macOS
//! swaps back to the in-window bar.
//! bar), and when [`native_menu::state_key`] or whether a text field has the keyboard changed it
//! rebuilds the spec and pushes labels, enabled and check state into the existing items; a
//! structural change (Open Recent, a new shortcut preset) rebuilds the menu. Settings ▸
//! Appearance ▸ Use In-Window Menu Bar on macOS swaps back to the in-window bar.
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, channel};
@@ -24,7 +24,7 @@ pub struct NativeBar {
items: HashMap<String, CheckMenuItem>,
spec: NativeMenu,
structure: u64,
state: Option<u64>,
state: Option<(u64, bool)>,
events: Receiver<MenuEvent>,
/// The in-window-bar setting last applied (None: not yet).
in_window: Option<bool>,
@@ -67,12 +67,13 @@ impl NativeBar {
if in_window {
return;
}
let key = native_menu::state_key(app);
// While a text field has the keyboard, the Edit items it takes stay enabled.
let key = (native_menu::state_key(app), native_menu::text_field_window(ctx).is_some());
if self.state == Some(key) && self.menu.is_some() {
return;
}
self.state = Some(key);
let spec = native_menu::build(app);
let spec = native_menu::build_now(app, ctx);
let structure = spec.structure();
if self.menu.is_none() || structure != self.structure {
self.install(&spec);
+32 -11
View File
@@ -17,10 +17,24 @@ use std::pin::Pin;
type Answer = Pin<Box<dyn Future<Output = Vec<String>> + Send>>;
/// Show `dialog` (on the main thread: the sheet opens here) and hand its paths to `done` once it
/// closes, then wake the UI.
pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) {
let answer = open(dialog);
/// A command's dialog waiting for [`show_asked`].
pub type Asked = (FileDialog, DialogDone);
/// Show the dialogs the commands asked for in this frame (on the main thread: a sheet opens
/// here) and hand each one's paths to its `done` once it closes, then wake the UI.
///
/// On macOS the sheet goes on the main window (`frame`'s). Without a parent rfd puts it on
/// AppKit's main window, which is a floating panel's OS window after a click in one; that
/// window hides while the app has no focused window, and a sheet whose window goes away ends
/// without answering, with later sheets queued behind it on the same window.
pub fn show_asked(asked: &std::sync::mpsc::Receiver<Asked>, frame: &eframe::Frame, ctx: &egui::Context) {
while let Ok((dialog, done)) = asked.try_recv() {
show(dialog, done, frame, ctx.clone());
}
}
fn show(dialog: FileDialog, done: DialogDone, frame: &eframe::Frame, ctx: egui::Context) {
let answer = open(dialog, frame);
let spawned = std::thread::Builder::new().name("ec-file-dialog".into()).spawn(move || {
done(pollster::block_on(answer));
ctx.request_repaint();
@@ -30,6 +44,13 @@ pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) {
}
}
/// A dialog on the main window. Linux keeps the portal's dialog unparented: rfd exports the
/// window for the portal from the dialog's worker thread there.
fn parented(frame: &eframe::Frame) -> AsyncFileDialog {
let d = AsyncFileDialog::new();
if cfg!(target_os = "macos") { d.set_parent(frame) } else { d }
}
fn paths(files: Option<Vec<FileHandle>>) -> Vec<String> {
files.unwrap_or_default().into_iter().map(|f| f.path().to_string_lossy().to_string()).collect()
}
@@ -39,10 +60,10 @@ fn path(file: Option<FileHandle>) -> Vec<String> {
}
/// The dialog, as the blocking hooks in `main.rs` show it.
fn open(dialog: FileDialog) -> Answer {
fn open(dialog: FileDialog, frame: &eframe::Frame) -> Answer {
match dialog {
FileDialog::Open { exts, many } => {
let d = AsyncFileDialog::new().add_filter("Media", &exts);
let d = parented(frame).add_filter("Media", &exts);
if many {
let f = d.pick_files();
Box::pin(async move { paths(f.await) })
@@ -52,22 +73,22 @@ fn open(dialog: FileDialog) -> Answer {
}
}
FileDialog::OpenProject => {
let f = AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file();
let f = parented(frame).add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file();
Box::pin(async move { path(f.await) })
}
FileDialog::Folder => {
let f = AsyncFileDialog::new().pick_folder();
let f = parented(frame).pick_folder();
Box::pin(async move { path(f.await) })
}
FileDialog::Save { default, ext, project } => {
let at = std::path::Path::new(&default);
let name = at.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut d = if project {
AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj"])
parented(frame).add_filter("EffectCraft Project", &["ecproj"])
} else if ext.is_empty() {
AsyncFileDialog::new()
parented(frame)
} else {
AsyncFileDialog::new().add_filter(&ext, &[ext.as_str()])
parented(frame).add_filter(&ext, &[ext.as_str()])
};
// A default with a folder that exists opens the dialog there.
if let Some(dir) = at.parent().filter(|d| d.is_dir()) {
+1 -1
View File
@@ -177,7 +177,7 @@ impl ColorSpace {
/// Parse an id or label (case-insensitive, loose).
pub fn parse(s: &str) -> Option<ColorSpace> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
match k.as_str() {
"srgb" | "srgbiec6196621" | "srgbiec61966" | "iec6196621" => Some(ColorSpace::Srgb),
"rec709" | "bt709" | "hdtvrec709" | "hdtv" | "709" => Some(ColorSpace::Rec709),
+2 -2
View File
@@ -136,11 +136,11 @@ type Cache<T> = Mutex<HashMap<u64, Arc<T>>>;
fn cached<T>(c: &'static OnceLock<Cache<T>>, json: &str, parse: impl Fn(&str) -> Option<T>) -> Option<Arc<T>> {
let key = fnv(json.as_bytes());
let c = c.get_or_init(Default::default);
if let Some(a) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(a.clone());
}
let a = Arc::new(parse(json)?);
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if m.len() > 8 {
m.clear();
}
+1 -1
View File
@@ -92,7 +92,7 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[
];
/// Whether the GPU kernel of effect `id` covers the instance's current settings (see
/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants and Turbulent Displace's locked
/// `CPU_ONLY_CONTROLS`; plus Cell Pattern's HQ variants and Turbulent Displace's locked
/// pinning).
pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool {
match id {
+1 -1
View File
@@ -359,7 +359,7 @@ pub(crate) fn selection_names(sel: &str) -> Vec<String> {
out
}
/// Parse a selection: comma-separated names (optionally quoted, see [`selection_names`]) or
/// Parse a selection: comma-separated names (optionally quoted, see `selection_names`) or
/// `<hex>` hashes / wildcards.
pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec<u32> {
let mut out = vec![];
+3 -3
View File
@@ -25,7 +25,7 @@ pub(crate) fn luma_601(c: [f32; 3]) -> f32 {
LUMA_601[0] * c[0] + LUMA_601[1] * c[1] + LUMA_601[2] * c[2]
}
/// The Rec. 601 luma weights of [`luma_601`]. The WGSL kernels repeat them as literals; the GPU
/// The Rec. 601 luma weights of `luma_601`. The WGSL kernels repeat them as literals; the GPU
/// test `shader_constants_match_the_cpu` checks that they are these.
pub const LUMA_601: [f32; 3] = [0.299, 0.587, 0.114];
@@ -384,7 +384,7 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 {
/// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid
/// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2),
/// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first.
/// The luminosity weights of [`bw_tint`] (a Color blend's). `fx_color.wgsl` repeats them; the GPU
/// The luminosity weights of `bw_tint` (a Color blend's). `fx_color.wgsl` repeats them; the GPU
/// test `shader_constants_match_the_cpu` checks that they are these.
pub const BW_TINT_LUMA: [f32; 3] = [0.3, 0.59, 0.11];
@@ -755,7 +755,7 @@ impl ColoramaPalette {
}
}
/// Parse "pos:r,g,b[,a] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops.
/// Parse "pos:r,g,b\[,a\] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops.
pub fn parse(s: &str) -> Option<ColoramaPalette> {
let mut stops: Vec<(f32, [f32; 4])> = s
.split_whitespace()
+1 -1
View File
@@ -61,7 +61,7 @@ const CELL_PATTERNS: [&str; 12] = [
"Tubular",
];
/// Shaping used for Cell Pattern option `i` (see [`cell_value`]).
/// Shaping used for Cell Pattern option `i` (see `cell_value`).
pub fn pattern_kind(i: u32) -> u32 {
match i {
0..=5 => i,
+5 -5
View File
@@ -147,8 +147,8 @@ pub(crate) fn paint(img: &mut Image, cov: &Plane, color: [f32; 4], opacity: f32,
});
}
/// Coverage of a plan-drawn generator: round-capped segments rasterised by [`raster_segs`]
/// with `hardness`, or the union of convex polygons ([`raster_convex`]).
/// Coverage of a plan-drawn generator: round-capped segments rasterised by `raster_segs`
/// with `hardness`, or the union of convex polygons (`raster_convex`).
#[derive(Clone, Debug)]
pub enum Coverage {
Segs { segs: Vec<Seg>, hardness: f64 },
@@ -165,7 +165,7 @@ impl Coverage {
}
/// Stroke, Scribble, Vegas and Write-on, shared with the GPU compositor (effectcraft-gpu
/// `fx_gen2`): the coverage and how it is painted. `style` 0–2 as [`paint`] (On Original
/// `fx_gen2`): the coverage and how it is painted. `style` 0–2 as `paint` (On Original
/// Image, On Transparent, Reveal Original Image), 3 = under the layer (Vegas' Composite Under).
#[derive(Clone, Debug)]
pub struct PaintPlan {
@@ -213,7 +213,7 @@ pub fn paint_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option<PaintPlan> {
}
}
/// Scribble's Composite as a [`paint`] style.
/// Scribble's Composite as a `paint` style.
pub fn scribble_style(ctx: &EffectCtx) -> u32 {
match ctx.params.e("composite") {
0 => 0,
@@ -1596,7 +1596,7 @@ fn draw_marks(b: &mut Buf, segs_core: &[Seg], thickness: f64, softness: f64, ins
}
/// Audio Spectrum / Audio Waveform, shared with the GPU compositor (effectcraft-gpu `fx_gen2`):
/// the marks (the audio analysis runs on the CPU) and how [`draw_marks`] colours them.
/// the marks (the audio analysis runs on the CPU) and how `draw_marks` colours them.
#[derive(Clone, Debug)]
pub struct MarksPlan {
pub segs: Vec<Seg>,
+1 -1
View File
@@ -333,7 +333,7 @@ impl Fractal {
}
/// Fractal / Turbulent Noise resolved for the GPU kernel (effectcraft-gpu `fx_noise`): the
/// per-pixel constants and, per octave, the terms [`Fractal::raw`] computes before touching the
/// per-pixel constants and, per octave, the terms `Fractal::raw` computes before touching the
/// pixel, in the same f32 order.
pub struct FractalGpu {
pub kind: u32,
+3 -3
View File
@@ -617,7 +617,7 @@ pub fn load_file(src: &str) -> Option<Arc<FileXform>> {
let parsed = if inline {
parse_file(src, "")
} else {
let ext = std::path::Path::new(src_t).extension().and_then(|e| e.to_str()).unwrap_or("");
let ext = std::path::Path::new(src_t).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("");
std::fs::read_to_string(src_t).ok().and_then(|t| parse_file(&t, ext))
}
.map(Arc::new);
@@ -1055,7 +1055,7 @@ pub enum ColorOp {
/// Negative channels to 0 (the saturation intent).
Max0,
/// A LUT (0 nearest, 1 trilinear, 2 tetrahedral) with an optional per-channel shaper, forward
/// or inverse (as [`FileXform::apply`]).
/// or inverse (as `FileXform::apply`).
Lut { lut: Arc<Lut>, shaper: Option<Arc<[Vec<(f32, f32)>; 3]>>, interp: u32, inverse: bool },
/// `m × (c + pre) + post` (a custom config's MatrixTransform and RangeTransform).
Affine { m: [[f32; 3]; 3], pre: [f32; 3], post: [f32; 3] },
@@ -1322,7 +1322,7 @@ fn curve3(xs: [f64; 3], ys: [f64; 3], v: f64) -> f64 {
}
/// Color Stabilizer's correction of buffer `b` as one piecewise-linear map per channel (points
/// with increasing x; the end segments extend), the same maps [`color_stabilizer`] applies.
/// with increasing x; the end segments extend), the same maps `color_stabilizer` applies.
/// `None` without a host or reference frame (the layer passes through). (The GPU effect.)
pub fn color_stabilizer_maps(ctx: &EffectCtx, b: &Buf) -> Option<[Vec<(f64, f64)>; 3]> {
let reference = ctx.env.host?.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index)?;
+1 -1
View File
@@ -20,7 +20,7 @@
//! * `looks` (`name`, `process_space`, `transform`, `inverse_transform`) and look strings
//! (`+A, -B`, `A | B` = the first one the config has);
//! * transforms: `MatrixTransform` (`matrix` 4×4 row-major, `offset`), `FileTransform` (the LUT,
//! CDL and matrix files [`super::ocio::parse_file`] reads; `interpolation`, `ccc_id`),
//! CDL and matrix files `super::ocio::parse_file` reads; `interpolation`, `ccc_id`),
//! `ExponentTransform` (`value`), `ExponentWithLinearTransform` (`gamma`, `offset`, `style:
//! mirror`; the power curve with a linear toe that the Academy CLF specification calls
//! "moncurve"), `LogTransform` (`base`), `LogAffineTransform` (`base`, `logSideSlope`,
+1 -1
View File
@@ -186,7 +186,7 @@ pub enum Tint {
/// How a sprite layer meets the original layer.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Post {
/// [`combine`] with this mode (0 Composite … 5 sprites only).
/// `combine` with this mode (0 Composite … 5 sprites only).
Combine(u32),
/// The sprites alone.
Replace,
+1 -1
View File
@@ -1586,7 +1586,7 @@ pub struct FoamEnv {
/// What Foam draws this frame over a transparent frame of the layer's size: the bubble sprites,
/// then the User Defined texture's discs, the Environment Map's reflections and (Draft + Flow
/// Map) the flow map under it all, each [`foam_disc`]-composited in bubble order.
/// Map) the flow map under it all, each `foam_disc`-composited in bubble order.
pub struct FoamPlan {
pub sprites: crate::sim::SpritePlan,
pub user: Option<(crate::LayerPixels, Vec<FoamDisc>)>,
+2 -2
View File
@@ -537,7 +537,7 @@ impl<S: Clone> SimCache<S> {
pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, mut step: impl FnMut(&mut S, u64)) -> std::sync::Arc<S> {
use std::sync::Arc;
let start = {
let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1;
g.iter_mut().find(|e| e.key == key).and_then(|e| {
e.used = tick;
@@ -563,7 +563,7 @@ impl<S: Clone> SimCache<S> {
}
let out = Arc::new(st);
fresh.push((target, out.clone()));
let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1;
let idx = match g.iter().position(|e| e.key == key) {
Some(ix) => ix,
+7 -7
View File
@@ -191,11 +191,11 @@ fn analysis_cached(json: &str) -> Option<(u64, Arc<WarpAnalysis>)> {
static C: OnceLock<AnalysisCache> = OnceLock::new();
let key = fnv(json.as_bytes());
let c = C.get_or_init(Default::default);
if let Some(a) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some((key, a.clone()));
}
let a = Arc::new(WarpAnalysis::from_json(json)?);
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if m.len() > 8 {
m.clear();
}
@@ -294,7 +294,7 @@ pub fn store_summary(key: [u64; 2], summary: PlanSummary) {
}
fn keep_summary(key: (u64, u64), summary: Arc<PlanSummary>) {
let mut m = summaries().lock().unwrap_or_else(|e| e.into_inner());
let mut m = summaries().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if m.len() > 32 {
m.clear();
}
@@ -305,7 +305,7 @@ fn keep_summary(key: (u64, u64), summary: Arc<PlanSummary>) {
/// cached (seconds for a long Subspace Warp clip). `None` when not analysed.
pub fn summary(params: &Params) -> Option<([u64; 2], Arc<PlanSummary>)> {
let (key, a, st) = plan_key(params)?;
let cached = summaries().lock().unwrap_or_else(|e| e.into_inner()).get(&key).cloned();
let cached = summaries().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key).cloned();
let sum = match cached {
Some(s) => s,
None => {
@@ -333,7 +333,7 @@ pub fn summary_at(params: &Params, time: f64) -> Option<(usize, Arc<PlanSummary>
pub fn cached_summary_at(params: &Params, time: f64) -> Option<(usize, Arc<PlanSummary>)> {
let (key, a, _) = plan_key(params)?;
let k = a.frame_at(time)?;
let sum = summaries().lock().unwrap_or_else(|e| e.into_inner()).get(&key).cloned();
let sum = summaries().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key).cloned();
let sum = match sum {
Some(s) => s,
None => {
@@ -445,13 +445,13 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
}
}
if !local.is_empty() {
let mut n = need_m.lock().unwrap_or_else(|e| e.into_inner());
let mut n = need_m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
for (x, w) in local {
n[y * ow as usize + x] = w;
}
}
});
let need = need_m.into_inner().unwrap_or_else(|e| e.into_inner());
let need = need_m.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
if synth && need.iter().any(|w| *w > 0.0) {
synthesize(ctx, &a, k, &src_of, &crop, sc, &mut out, &need);
}
+1 -1
View File
@@ -860,7 +860,7 @@ fn original_path(s: &Session) -> Option<String> {
/// importer takes. Anything else (a folder or app bundle, a script, a shortcut) is refused.
pub(crate) fn openable_original(path: &str) -> std::result::Result<(), String> {
let media = |p: &std::path::Path| {
let ext = p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default();
crate::media_browser::kind_of(&p.to_string_lossy()).is_some_and(|k| k != "project") || matches!(ext.as_str(), "pdf" | "ai" | "eps")
};
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
+1 -1
View File
@@ -560,7 +560,7 @@ pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"];
/// A data footage item for `path` (JSON / CSV / TSV), `None` for other files.
pub(crate) fn data_footage(services: &dyn crate::Services, path: &str) -> Option<std::result::Result<effectcraft_project::Footage, String>> {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if !DATA_EXTENSIONS.contains(&ext.as_str()) {
return None;
}
+1 -1
View File
@@ -708,7 +708,7 @@ const BUNDLE_EXTENSIONS: &[&str] = &[
/// Whether the system file manager would open folder `dir` as something other than a folder: a
/// bundle (`Tool.app`), or a Windows folder with a `{CLSID}` extension.
fn opens_as_bundle(dir: &std::path::Path) -> bool {
let ext = dir.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
let ext = dir.extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase();
ext.starts_with('{') || BUNDLE_EXTENSIONS.contains(&ext.as_str())
}
+1 -1
View File
@@ -107,7 +107,7 @@ impl Default for PaintOptions {
}
fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
}
/// Index of `v` in `names` (by number or case/space-insensitive name).
+1 -1
View File
@@ -103,7 +103,7 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
}
let supported = matches!(old.kind, FootageKind::Still | FootageKind::Sequence | FootageKind::Video | FootageKind::Audio)
&& old.layer.is_none() && old.page == 0;
let name = Path::new(&old.path).file_name().and_then(|n| n.to_str()).unwrap_or("");
let name = Path::new(&old.path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("");
let paths = index.get(name).cloned().unwrap_or_default();
let mut candidates = vec![];
let mut accepted = vec![];
+2 -2
View File
@@ -80,10 +80,10 @@ impl MaskMethod {
}
}
pub fn from_name(s: &str) -> Option<MaskMethod> {
let n: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let n: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
MaskMethod::ALL
.into_iter()
.find(|m| m.id().to_ascii_lowercase() == n || m.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == n)
.find(|m| m.id().to_ascii_lowercase() == n || m.label().chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase() == n)
}
pub fn model(self) -> Model {
match self {
+2 -2
View File
@@ -289,10 +289,10 @@ pub struct Inbox(Mutex<Vec<WorkerReply>>);
impl Inbox {
pub fn push(&self, r: WorkerReply) {
self.0.lock().unwrap_or_else(|e| e.into_inner()).push(r);
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(r);
}
pub fn take(&self) -> Vec<WorkerReply> {
std::mem::take(&mut *self.0.lock().unwrap_or_else(|e| e.into_inner()))
std::mem::take(&mut *self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner))
}
}
+1 -1
View File
@@ -110,7 +110,7 @@ pub struct JobShared {
}
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
impl JobShared {
+2 -2
View File
@@ -20,7 +20,7 @@ use crate::render::{FootageSource, RenderOpts};
struct Synthetic;
fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] {
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("");
let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("");
match name {
"red16.png" => [1.0, 0.0, 0.0, 1.0],
"blue8.png" => [0.0, 0.0, 1.0, 1.0],
@@ -43,7 +43,7 @@ fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] {
}
fn size_of(path: &str) -> (u32, u32) {
match std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("") {
match std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("") {
"red16.png" => (16, 16),
"blue8.png" => (8, 8),
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4),
+1 -1
View File
@@ -157,7 +157,7 @@ pub struct TrackShared {
}
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A running (or finished, not yet polled) analysis.
+2 -2
View File
@@ -663,10 +663,10 @@ impl SimProfile {
}
pub fn parse(s: &str) -> Option<SimProfile> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
SimProfile::ALL
.into_iter()
.find(|p| p.id().to_ascii_lowercase() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == k)
.find(|p| p.id().to_ascii_lowercase() == k || p.label().chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase() == k)
.or(match k.as_str() {
"off" => Some(SimProfile::None),
"hdtv" | "bt709" => Some(SimProfile::Rec709),
+1 -1
View File
@@ -13,7 +13,7 @@
//! | AV1 | MP4 (`av01`) | `effectcraft-av1enc` Main 8/10-bit 4:2:0, key + inter frames | no | AAC-LC (FilmCraft) |
//! | WAV / AIFF | RIFF WAVE / AIFF (AIFF-C for float) | — | — | PCM 16/24-bit or 32-bit float, mono/stereo (audio only) |
//!
//! Every frame goes through [`pipeline`]: the Render Settings overrides (Effects, Solo Switches,
//! Every frame goes through the internal `pipeline` module: the Render Settings overrides (Effects, Solo Switches,
//! Guide Layers, Frame Blending, Motion Blur, Color Depth), field rendering (two fields half a
//! frame apart woven line by line, with optional 3:2 pulldown from film frames at 4/5 of the
//! rate), then the Output Module's Crop (edges or the region of interest) and Resize. Channels
+4 -4
View File
@@ -247,7 +247,7 @@ impl<'a> Cx<'a> {
}
let img = self.crop_resize(img);
if let Some(ft) = &self.frame_times {
ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64()));
ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64()));
}
img
}
@@ -327,7 +327,7 @@ impl<'a> Cx<'a> {
for dir in &self.job.options.overflow {
let p = std::path::Path::new(dir).join(&name).to_string_lossy().to_string();
if q.has_room(&p, bytes) {
self.overflowed.lock().unwrap_or_else(|e| e.into_inner()).push(p.clone());
self.overflowed.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(p.clone());
return p;
}
}
@@ -351,7 +351,7 @@ impl<'a> Cx<'a> {
Ok(r) => s.push_str(&format!("Result: Done ({} frames, {}×{}, {} bytes) in {seconds:.2} s\n", r.frames, r.width, r.height, r.bytes)),
Err(e) => s.push_str(&format!("Result: Failed\nError: {e}\n")),
}
let over = self.overflowed.lock().unwrap_or_else(|e| e.into_inner());
let over = self.overflowed.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !over.is_empty() {
s.push_str(&format!("Storage overflow: {} file(s) written to overflow folders (first: {})\n", over.len(), over[0]));
}
@@ -366,7 +366,7 @@ impl<'a> Cx<'a> {
}
}
if let (RenderLog::PlusPerFrameInfo, Some(ft), Some(c)) = (level, &self.frame_times, comp) {
let mut v = ft.lock().unwrap_or_else(|e| e.into_inner()).clone();
let mut v = ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
v.sort_by_key(|(i, _)| *i);
s.push_str("\nFrames:\n");
let f0 = self.job.settings.first_frame(c);
+4 -4
View File
@@ -840,14 +840,14 @@ var __propHandler = {
// A layer-valued property (a Layer Control's Layer) is named as its layer (#809).
if (k === 'name' && t.__info[2] === 'layer') {
var lv = t.value;
if (lv !== null && lv !== undefined && lv.__isLayer) return lv.name;
if (lv?.__isLayer) return lv.name;
}
if (k in t) return t[k];
var v = t.value;
if (v === null || v === undefined) return undefined;
var x = v[k];
// Property groups are callable proxies: hand them over as they are, not bound like methods (#809).
if (x !== null && x !== undefined && x.__isGroup) return x;
if (x?.__isGroup) return x;
return typeof x === 'function' ? x.bind(v) : x;
},
};
@@ -904,8 +904,8 @@ __TextStyle.prototype = {
},
get text() {
var t = this.__d.text;
for (var k = 0; k < this.__ops.length; k++) {
var o = this.__ops[k];
for (const elem of this.__ops) {
var o = elem;
if (o[0] !== 'text') continue;
if (o[2] < 0) t = String(o[1]);
else t = t.slice(0, o[2]) + String(o[1]) + (o[3] < 0 ? '' : t.slice(o[2] + o[3]));
+3 -3
View File
@@ -581,7 +581,7 @@ fn push_piece(out: &mut String, p: Piece) {
/// rewrite (which nests a call per arithmetic operator). The JavaScript parser and compiler
/// recurse per level and a 2 MiB thread overflows its stack (aborting the app) at about 75
/// nested brackets, so deeper expressions are rejected with an error instead. Constructs that
/// nest without brackets count as part of a level ([`too_nested`]).
/// nest without brackets count as part of a level (`too_nested`).
pub const MAX_NESTING: usize = 32;
/// Hard limit on bytes emitted by the array-math source rewrite.
pub const MAX_REWRITTEN_BYTES: usize = 4 * 1024 * 1024;
@@ -608,7 +608,7 @@ const HEAVY: usize = 32;
/// about 3 800 links).
const LIGHT: usize = 6;
/// One bracket level of [`too_nested`]'s walk.
/// One bracket level of `too_nested`'s walk.
#[derive(Default)]
struct Level {
/// `[`: commas separate elements (a loop in the parser), not operands.
@@ -710,7 +710,7 @@ fn too_nested(toks: &[Tok]) -> bool {
too_nested_from(toks, 0)
}
/// [`too_nested`] for code that already sits `base` deep (a template substitution).
/// `too_nested` for code that already sits `base` deep (a template substitution).
fn too_nested_from(toks: &[Tok], base: usize) -> bool {
let mut levels = vec![Level::default()];
let mut prefix = 0usize;
+1 -1
View File
@@ -30,7 +30,7 @@ pub(crate) fn deferred_gpu() -> Option<(&'static Gpu, std::sync::MutexGuard<'sta
g
});
let g = crate::tests::require_gpu("deferred gpu tests", g.as_ref())?;
Some((g, LOCK.lock().unwrap_or_else(|e| e.into_inner())))
Some((g, LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)))
}
/// Footage with GPU effects around a CPU-only one, a solid with Glow, and a precomp whose
+1 -1
View File
@@ -183,7 +183,7 @@ fn picture_layer<'a>(aux: &AuxChannels, layers: &[&'a str]) -> Option<&'a str> {
/// The picture of a multi-layer OpenEXR file without an unnamed RGB layer (Blender's
/// `ViewLayer.Combined.R`, the compositor's `Image.R`, Nuke's `beauty.R`, …), which the image
/// decoder rejects. After Effects shows a file's main RGBA channels and leaves the other layers
/// to EXtractoR; here that is the beauty pass by name ([`BEAUTY`]), else the first colour layer
/// to EXtractoR; here that is the beauty pass by name (`BEAUTY`), else the first colour layer
/// that is not a data pass (never Cryptomatte, depth or normals, #412), else the first channel
/// as grey. Linear, straight RGBA as the file stores it. `None` when it is not a readable EXR.
pub fn layered_image(bytes: &[u8]) -> Option<image::DynamicImage> {
+1 -1
View File
@@ -36,7 +36,7 @@ pub fn vector_doc(
}
fn is_pdf_like(path: &str, bytes: &[u8]) -> bool {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
effectcraft_pdf::sniff(bytes).is_some() && (effectcraft_pdf::EXTENSIONS.contains(&ext.as_str()) || bytes.starts_with(b"%PDF"))
}
+1 -1
View File
@@ -80,7 +80,7 @@ pub fn is_importable(path: &std::path::Path) -> bool {
}
pub(crate) fn ext_of(path: &std::path::Path) -> String {
path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
path.extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase()
}
/// The `image` crate format of a still: from its signature, else from its extension (Targa has
+3 -3
View File
@@ -388,7 +388,7 @@ impl MediaPool {
}
/// The conformed-audio file of `path` at `rate` (named by a hash of the path, size and
/// modification time, so an edited file conforms again, and of [`CONFORM_VERSION`]).
/// modification time, so an edited file conforms again, and of `CONFORM_VERSION`).
pub fn conformed_path(&self, path: &str, rate: u32) -> Option<std::path::PathBuf> {
use std::hash::{Hash, Hasher};
let folder = lock(&self.inner.conform).clone()?;
@@ -756,7 +756,7 @@ impl Inner {
drop(c);
return Ok(Arc::new(self.decode(&loc, footage)?));
}
c = self.done.wait(c).unwrap_or_else(|e| e.into_inner());
c = self.done.wait(c).unwrap_or_else(std::sync::PoisonError::into_inner);
}
self.misses.fetch_add(1, Ordering::Relaxed);
c.inflight.insert(loc.key.clone());
@@ -972,7 +972,7 @@ fn describe_problem(codec: &str, e: &MediaError) -> String {
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
+1 -1
View File
@@ -15,4 +15,4 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
image = { workspace = true }
miniz_oxide = "0.8"
miniz_oxide = "0.9"
+1 -1
View File
@@ -15,4 +15,4 @@ effectcraft-keyframe = { workspace = true }
effectcraft-raster = { workspace = true }
kurbo = { workspace = true }
tiny-skia = { workspace = true }
linesweeper = "0.4"
linesweeper = "0.5"
+1 -1
View File
@@ -5,7 +5,7 @@
//! MIT OR Apache-2.0), reworked to take and return kurbo paths directly.
//!
//! Inputs are closed implicitly (a filled open path is painted as if closed). The sweep splits
//! curves at intersections and y-extrema; [`tidy_segments`] re-joins pieces that came from one
//! curves at intersections and y-extrema; `tidy_segments` re-joins pieces that came from one
//! input curve (exactly) or one smooth run (by least-squares refit), so results carry roughly as
//! few vertices as the inputs. Output contours are simple and consistently oriented (holes wind
//! opposite to their outer contour), so they fill the same under either fill rule.
+1 -1
View File
@@ -6,7 +6,7 @@
//! with "outside" so every contour closes.
//! 2. **Douglas–Peucker** simplification of each closed loop to the Tolerance (pixels).
//! 3. **Bezier fitting**: each simplified span is fitted with one cubic (least squares with fixed
//! end tangents, Newton reparameterisation; [`crate::fit`]). The tangent at each vertex is the
//! end tangents, Newton reparameterisation; `crate::fit`). The tangent at each vertex is the
//! chord direction at Corner Roundness 0 (straight segments, sharp corners) and the smooth
//! (Catmull–Rom) direction at 100 %, blended between.
//!
+1 -1
View File
@@ -14,7 +14,7 @@ craft-pdf.workspace = true
effectcraft-svg = { workspace = true }
kurbo = { workspace = true }
effectcraft-text = { workspace = true }
miniz_oxide = "0.8"
miniz_oxide = "0.9"
# Embedded TrueType / OpenType font programs (MIT OR Apache-2.0).
skrifa = { workspace = true }
# DCT (JPEG) images (MIT OR Apache-2.0 OR Zlib; pure Rust).
+1 -1
View File
@@ -138,7 +138,7 @@ ffmpeg elsewhere) to check the code tables against an independent implementation
## Shared parser setup and bounds
The workspace pins `craft-pdf` to `craft-libs` Git revision
`9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef`, with `default-features = false` and only the explicit `read`
`512b54355cd1418f4e8c56a3fbd7483c3c274bf3`, with `default-features = false` and only the explicit `read`
feature. This excludes the backend's bundled fonts and Adobe CMaps and preserves EffectCraft's
font and asset policies. The shared parser does not change the supported drawing operators,
image codecs, continuously rasterized SVG tree, editable shapes, or EPS interpreter.
+2 -2
View File
@@ -201,7 +201,7 @@ impl TimeDisplayStyle {
}
}
pub fn parse(s: &str) -> Option<TimeDisplayStyle> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
match k.as_str() {
"timecode" => Some(TimeDisplayStyle::Timecode),
"frames" => Some(TimeDisplayStyle::Frames),
@@ -1292,7 +1292,7 @@ impl Footage {
if self.kind != FootageKind::Data {
return None;
}
let ext = std::path::Path::new(&self.path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let ext = std::path::Path::new(&self.path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
Some(match ext.as_str() {
"csv" => "csv",
"tsv" | "tab" => "tsv",
+1 -1
View File
@@ -10,4 +10,4 @@ rust-version.workspace = true
workspace = true
[dependencies]
miniz_oxide = "0.8"
miniz_oxide = "0.9"
+1 -1
View File
@@ -1,5 +1,5 @@
//! Photoshop document (PSD / PSB) reader, implemented from Adobe's public *Photoshop File Formats
//! Specification* (see README.md), and a minimal writer ([`write`]) for fixtures.
//! Specification* (see README.md), and a minimal writer ([`mod@write`]) for fixtures.
//!
//! [`Psd::parse`] reads the structure (header, layer records and their additional information,
//! the layer tree) without decoding pixels; [`Psd::composite`] decodes the merged image and
+1 -1
View File
@@ -19,7 +19,7 @@ effectcraft-raster = { workspace = true }
effectcraft-path = { workspace = true }
effectcraft-text = { workspace = true }
effectcraft-effects = { workspace = true }
lz4_flex = { version = "0.11", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
effectcraft-model = { workspace = true }
kurbo = { workspace = true }
rayon = { workspace = true }
+1 -1
View File
@@ -331,7 +331,7 @@ impl DiskCache {
let Ok(files) = std::fs::read_dir(shard.path()) else { continue };
for f in files.flatten() {
let p = f.path();
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("");
if name.contains(".tmp") {
// A writer that died mid-write; old temporaries are removed.
if f.metadata().and_then(|m| m.modified()).is_ok_and(|t| t.elapsed().is_ok_and(|e| e.as_secs() > 600)) {
+1 -1
View File
@@ -521,7 +521,7 @@ pub struct RenderOpts {
pub motion_blur: bool,
/// Render guide layers (viewer only). Only the top-level comp's own guide layers: nested
/// comps (precomps, collapsed precomps, comps read by effects) never draw theirs, as in
/// After Effects (see [`Renderer::nested_opts`]).
/// After Effects (see `Renderer::nested_opts`).
pub guides: bool,
/// Draft quality: fewer motion-blur samples, bilinear only.
pub draft: bool,
+1 -1
View File
@@ -230,7 +230,7 @@ const PLANE_SLOPE_MAX: f32 = 64.0;
/// Fraction of light reaching `p` through shadow map `m` (1 = lit). `ng` is the
/// receiver's geometric (not normal-mapped) normal facing the viewer.
///
/// PCF over an N×N tap grid (5 ≤ N ≤ [`PCF_MAX_TAPS`]) spread over a reach derived
/// PCF over an N×N tap grid (5 ≤ N ≤ `PCF_MAX_TAPS`) spread over a reach derived
/// from `radius` (the wanted penumbra in output pixels) divided by the receiver's
/// texel footprint, each tap jittered by an integer hash of the receiver's
/// sub-texel cell and the tap index, and each tap comparing against the four
+1 -1
View File
@@ -408,7 +408,7 @@ impl View3D {
}
}
pub fn from_id(s: &str) -> Option<View3D> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
View3D::ALL.into_iter().find(|v| v.id().eq_ignore_ascii_case(&k) || v.label().replace(' ', "").eq_ignore_ascii_case(&k))
}
pub fn is_ortho(self) -> bool {
+2 -2
View File
@@ -703,7 +703,7 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] {
/// A Classic 3D run prepared for an accelerator ([`Renderer::prepare_3d_run`]): every plane
/// with its geometry, material and buffer, ready for the per-pixel depth sort, lighting and
/// ray-cast shadows of [`draw_run`] on another device.
/// ray-cast shadows of `draw_run` on another device.
#[derive(Clone, Debug)]
pub struct Run3d {
/// Drawn planes in stack order, then shadow casters from outside the run (`draw` false).
@@ -971,7 +971,7 @@ pub(crate) fn environment_rotation(ctx: &EvalCtx) -> f32 {
.to_radians() as f32
}
/// An Environment Light Background layer ready to draw (see [`draw_sky`]): the layer's
/// An Environment Light Background layer ready to draw (see `draw_sky`): the layer's
/// buffer in the blending space, its opacity and the camera's view rays. Also handed to an
/// accelerator's compositor (`Renderer::sky_draw`).
#[derive(Clone)]
+1 -1
View File
@@ -2,7 +2,7 @@
//! compositor for runs of 3D layers.
//!
//! The 2D compositor (`Renderer::comp_frame`) walks the layer stack bottom-to-top; each run of
//! consecutive 3D layers is handed to [`compose::draw_run`] as a group (2D layers break 3D
//! consecutive 3D layers is handed to `compose::draw_run` as a group (2D layers break 3D
//! groups, as in After Effects).
pub mod adv;
+34 -34
View File
@@ -420,7 +420,7 @@ function __callable(o) {
delete f.length;
Object.setPrototypeOf(f, Object.getPrototypeOf(o));
var keys = Object.keys(o);
for (var i = 0; i < keys.length; i++) f[keys[i]] = o[keys[i]];
for (const elem of keys) f[elem] = o[elem];
var self = new Proxy(f, {
get: __attr.get,
apply: function (t, thisArg, args) { return self.property(args[0]); },
@@ -473,7 +473,7 @@ function MarkerValue(comment, chapter, url, frameTarget, cuePointName, params) {
}
MarkerValue.prototype.getParameters = function () {
var o = {};
for (var i = 0; i < this.__params.length; i++) o[this.__params[i][0]] = this.__params[i][1];
for (const elem of this.__params) o[elem[0]] = elem[1];
return o;
};
MarkerValue.prototype.setParameters = function (o) {
@@ -513,8 +513,8 @@ TextDocument.prototype = {
set text(v) { this.__text = String(v); this.__textSet = true; },
__style: function (k, d) {
if (k in this.__changed) return this.__changed[k];
if (this.__base && this.__base.first && k in this.__base.first) return this.__base.first[k];
if (this.__base && this.__base.para && k in this.__base.para) return this.__base.para[k];
if (this.__base?.first && k in this.__base.first) return this.__base.first[k];
if (this.__base?.para && k in this.__base.para) return this.__base.para[k];
return d;
},
__set: function (k, v) { this.__changed[k] = v; },
@@ -580,14 +580,14 @@ TextDocument.prototype = {
},
get tsume() { return this.__style("tsume", 0); },
set tsume(v) { this.__set("tsume", __num(v)); },
get boxText() { return !!(this.__base && this.__base.box_size); },
get boxText() { return !!this.__base?.box_size; },
get pointText() { return !this.boxText; },
get boxTextSize() { return this.__base && this.__base.box_size ? this.__base.box_size.slice() : undefined; },
get boxTextSize() { return this.__base?.box_size ? this.__base.box_size.slice() : undefined; },
set boxTextSize(v) {
var p = this.boxTextPos || [0, 0];
this.__set("box", [p[0], p[1], v[0], v[1]]);
},
get boxTextPos() { return this.__base && this.__base.box_size ? this.__base.box_pos.slice() : undefined; },
get boxTextPos() { return this.__base?.box_size ? this.__base.box_pos.slice() : undefined; },
get startIndent() { return this.__style("indentLeft", 0); },
set startIndent(v) { this.__set("indentLeft", __num(v)); },
get endIndent() { return this.__style("indentRight", 0); },
@@ -665,7 +665,7 @@ PropertyBase.prototype = {
get isMask() { return !!this.__info().isMask; },
get selected() {
var c = __get("item", { id: this.__layer.__comp }).selectedProperties;
for (var i = 0; i < c.length; i++) if (c[i][0] === this.__layer.__id && c[i][1] === this.__uid) return true;
for (const elem of c) if (elem[0] === this.__layer.__id && elem[1] === this.__uid) return true;
return false;
},
set selected(v) {
@@ -842,7 +842,7 @@ Property.prototype.property = function () { return null; };
i = __num(i, "keyIndex");
// Only the key asked for: fetching the whole list made every key call linear (#814).
var r = i >= 1 && Math.floor(i) === i ? __get("keys", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid, index: i - 1 }) : null;
if (!r || !r.key) throw __err("keyIndex " + i + " is out of range 1.." + (r ? r.count : this.numKeys));
if (!r?.key) throw __err("keyIndex " + i + " is out of range 1.." + (r ? r.count : this.numKeys));
return r.key;
};
// Script value → engine JSON for `prop.set` / `prop.addKey`.
@@ -853,13 +853,13 @@ Property.prototype.property = function () { return null; };
return __get("textdoc", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid, time: time, attrs: td.__attrs() });
}
if (t === "SHAPE") {
if (!v || !v.vertices) throw __err("value must be a Shape");
if (!v?.vertices) throw __err("value must be a Shape");
var n = v.vertices.length;
function fit(a) { var o = []; for (var i = 0; i < n; i++) o.push(a && a[i] ? [a[i][0], a[i][1]] : [0, 0]); return o; }
function fit(a) { var o = []; for (var i = 0; i < n; i++) o.push(a?.[i] ? [a[i][0], a[i][1]] : [0, 0]); return o; }
return { vertices: fit(v.vertices), in_tangents: fit(v.inTangents), out_tangents: fit(v.outTangents), closed: v.closed !== false };
}
if (t === "LAYER_INDEX") {
var idx = v && v.__id !== undefined ? v.index : __num(v);
var idx = v?.__id !== undefined ? v.index : __num(v);
if (!idx) return null;
return this.__layer.containingComp.layer(idx).__id;
}
@@ -1177,7 +1177,7 @@ Layer.prototype = {
},
get selectedProperties() {
var c = this.containingComp.__info().selectedProperties, out = [];
for (var i = 0; i < c.length; i++) if (c[i][0] === this.__id) out.push(__propOf(this, __get("node", { comp: this.__comp, layer: this.__id, uid: c[i][1] })));
for (const elem of c) if (elem[0] === this.__id) out.push(__propOf(this, __get("node", { comp: this.__comp, layer: this.__id, uid: elem[1] })));
return out;
},
get parent() { var p = this.__info().parent; return p === null ? null : __layer(this.__comp, p); },
@@ -1205,7 +1205,7 @@ Layer.prototype = {
var before = this.containingComp.__info().layers;
__call("edit.duplicate", this.__p());
var after = this.containingComp.__info().layers;
for (var i = 0; i < after.length; i++) if (before.indexOf(after[i]) < 0) return __layer(this.__comp, after[i]);
for (const elem of after) if (before.indexOf(elem) < 0) return __layer(this.__comp, elem);
return null;
},
copyToComp: function (comp) {
@@ -1310,7 +1310,7 @@ AVLayer.prototype.constructor = AVLayer;
// AVLayer.replaceSource). `fixExpressions` is accepted for compatibility: only AV sources are
// replaced, so the property tree and the layer's name stay and expressions keep resolving.
P.replaceSource = function (newSource, fixExpressions) {
if (!newSource || newSource.__id === undefined) throw __err("replaceSource needs a project item");
if (newSource?.__id === undefined) throw __err("replaceSource needs a project item");
__call("layer.replaceSource", { comp: this.__comp, layers: [this.__id], item: newSource.__id, fixExpressions: !!fixExpressions });
return null;
};
@@ -1371,7 +1371,7 @@ LayerCollection.prototype = {
return layer;
},
add: function (item, duration) {
if (!item || item.id === undefined) throw __err("add(): expected an item");
if (item?.id === undefined) throw __err("add(): expected an item");
var l = this.__new(__call("layer.addItem", { comp: this.__comp, item: item.id, time: 0 }));
return this.__dur(l, duration);
},
@@ -1414,15 +1414,15 @@ LayerCollection.prototype = {
},
precompose: function (indices, name, moveAllAttributes) {
var ids = this.__ids(), sel = [];
for (var i = 0; i < indices.length; i++) sel.push(ids[indices[i] - 1]);
for (const elem of indices) sel.push(ids[elem - 1]);
var r = __call("layer.precompose", { comp: this.__comp, layers: sel, name: String(name), mode: moveAllAttributes === false ? "leave" : "move" });
return __item(r.comp);
},
byName: function (name) {
var ids = this.__ids();
for (var i = 0; i < ids.length; i++) {
var l = __get("layer", { comp: this.__comp, layer: ids[i] });
if (l.name === name) return __layer(this.__comp, ids[i]);
for (const elem of ids) {
var l = __get("layer", { comp: this.__comp, layer: elem });
if (l.name === name) return __layer(this.__comp, elem);
}
return null;
},
@@ -1493,7 +1493,7 @@ Item.prototype = {
get label() { return this.__info().label; },
set label(v) { __call("project.setLabel", { items: [this.__id], label: __num(v, "label") }); },
get parentFolder() { return __item(this.__info().parent); },
set parentFolder(f) { __call("project.move", { items: [this.__id], folder: f && f.__id ? f.__id : null }); },
set parentFolder(f) { __call("project.move", { items: [this.__id], folder: f?.__id ? f.__id : null }); },
get selected() { return this.__info().selected; },
set selected(v) {
var selection = __get("project").selection;
@@ -1527,7 +1527,7 @@ AVItem.prototype = Object.create(Item.prototype);
def("hasVideo", function () { return this.__info().hasVideo; });
def("hasAudio", function () { return this.__info().hasAudio; });
def("footageMissing", function () { return !!this.__info().footageMissing; });
def("useProxy", function () { var p = this.__info().proxy; return !!(p && p.enabled); }, function (v) {
def("useProxy", function () { var p = this.__info().proxy; return !!p?.enabled; }, function (v) {
if (!this.__info().proxy) throw __err("\"" + this.name + "\" has no proxy");
__call("file.useProxy", { item: this.__id, on: !!v });
});
@@ -1564,9 +1564,9 @@ CompItem.prototype.constructor = CompItem;
});
def("activeCamera", function () {
var ids = this.__info().layers;
for (var i = 0; i < ids.length; i++) {
var l = __get("layer", { comp: this.__id, layer: ids[i] });
if (l.kind === "camera" && l.enabled && l.active) return __layer(this.__id, ids[i]);
for (const elem of ids) {
var l = __get("layer", { comp: this.__id, layer: elem });
if (l.kind === "camera" && l.enabled && l.active) return __layer(this.__id, elem);
}
return null;
});
@@ -1606,7 +1606,7 @@ CompItem.prototype.constructor = CompItem;
if (a < 1 || a > ids.length || Math.floor(a) !== a) throw __err("layer index " + a + " is out of range 1.." + ids.length);
return __layer(this.__id, ids[a - 1]);
}
if (a && a.__id !== undefined && typeof b === "number") {
if (a?.__id !== undefined && typeof b === "number") {
var j = a.index + b;
return j >= 1 && j <= ids.length ? __layer(this.__id, ids[j - 1]) : null;
}
@@ -1619,9 +1619,9 @@ CompItem.prototype.constructor = CompItem;
P.__controllers = function () {
var out = [];
(function walk(list) {
for (var i = 0; i < list.length; i++) {
if (list[i].kind === "property" || list[i].kind === "mirror" || list[i].kind === "media") out.push(list[i]);
if (list[i].children) walk(list[i].children);
for (const elem of list) {
if (elem.kind === "property" || elem.kind === "mirror" || elem.kind === "media") out.push(elem);
if (elem.children) walk(elem.children);
}
})(this.__eg().controls);
return out;
@@ -1732,7 +1732,7 @@ FolderItem.prototype.constructor = FolderItem;
Object.defineProperty(P, "numItems", { get: function () { return new ItemCollection(this.__id).length; } });
Object.defineProperty(P, "parentFolder", {
get: function () { return this.__id === 0 ? null : __item(this.__info().parent); },
set: function (f) { __call("project.move", { items: [this.__id], folder: f && f.__id ? f.__id : null }); },
set: function (f) { __call("project.move", { items: [this.__id], folder: f?.__id ? f.__id : null }); },
});
P.item = function (i) {
var v = new ItemCollection(this.__id).__at(__num(i, "index"));
@@ -1776,7 +1776,7 @@ RenderQueueItem.prototype = {
constructor: RenderQueueItem,
__info: function () {
var l = __call("renderQueue.list", {}).items;
for (var i = 0; i < l.length; i++) if (l[i].id === this.__id) return l[i];
for (const elem of l) if (elem.id === this.__id) return elem;
throw __err("the render queue item no longer exists");
},
get comp() { return __item(this.__info().comp); },
@@ -1812,7 +1812,7 @@ RenderQueueItem.prototype = {
var before = __call("renderQueue.list", {}).items.map(function (i) { return i.id; });
__call("renderQueue.duplicate", { item: this.__id });
var after = __call("renderQueue.list", {}).items;
for (var i = 0; i < after.length; i++) if (before.indexOf(after[i].id) < 0) return new RenderQueueItem(after[i].id);
for (const elem of after) if (before.indexOf(elem.id) < 0) return new RenderQueueItem(elem.id);
return null;
},
applyTemplate: function () {},
@@ -1945,9 +1945,9 @@ Project.prototype = {
}
}
var r = __call("file.import", args);
if (r.errors && r.errors.length) throw __err(r.errors.join("; "));
if (r.errors?.length) throw __err(r.errors.join("; "));
// Composition imports create several footage items, but callers need the comp.
return __item(r.comps && r.comps.length ? r.comps[0] : r.items[0]);
return __item(r.comps?.length ? r.comps[0] : r.items[0]);
},
importFileWithDialog: function () { return null; },
importPlaceholder: function (name, w, h, rate, dur) {
+1 -1
View File
@@ -710,7 +710,7 @@ pub(crate) fn outcome(output: Vec<String>, result: Result<J, ScriptError>) -> Ou
/// don't block.
///
/// ExtendScript's `#include` / `#includepath` are inlined first, and errors point at the file
/// and line they came from ([`crate::include`]).
/// and line they came from (`crate::include`).
pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome {
let expanded = match crate::include::expand(session, req.code, req.name) {
Ok(x) => x,
+33 -33
View File
@@ -213,7 +213,7 @@ function __uiResPlain(v) {
if (v instanceof Array) return v.map(__uiResPlain);
if (v && typeof v === "object" && v.__object) {
var o = {};
for (var k = 0; k < v.__object.length; k++) o[v.__object[k][0]] = __uiResPlain(v.__object[k][1]);
for (const elem of v.__object) o[elem[0]] = __uiResPlain(elem[1]);
return o;
}
return v;
@@ -222,8 +222,8 @@ function __uiResPlain(v) {
// A resource's own properties and its child elements (in source order).
function __uiResProps(res) {
var props = {}, kids = [];
for (var k = 0; k < res.list.length; k++) {
var e = res.list[k];
for (const elem2 of res.list) {
var e = elem2;
if (e[1] && typeof e[1] === "object" && e[1].__element) kids.push([e[0], { type: e[1].__element, list: e[1].list }]);
else props[e[0]] = __uiResPlain(e[1]);
}
@@ -239,17 +239,17 @@ function __uiApplyRes(c, res) {
// Ranges before values, items before selections.
if (props.minvalue !== undefined) c.minvalue = __num(props.minvalue);
if (props.maxvalue !== undefined) c.maxvalue = __num(props.maxvalue);
if (props.items instanceof Array && c.items) for (var n = 0; n < props.items.length; n++) c.add("item", props.items[n]);
if (props.items instanceof Array && c.items) for (const elem of props.items) c.add("item", elem);
for (var k in props) {
if (__uiResSkip[k]) continue;
var v = props[k];
if (__uiSizes[k] && v instanceof Array) v = __dim(v[0], v[1]);
c[k] = v;
}
for (var j = 0; j < pk.kids.length; j++) {
var child = __uiAddRes(c, pk.kids[j][1]);
child.__key = pk.kids[j][0];
c[pk.kids[j][0]] = child;
for (const elem of pk.kids) {
var child = __uiAddRes(c, elem[1]);
child.__key = elem[0];
c[elem[0]] = child;
}
return c;
}
@@ -268,7 +268,7 @@ Window.alert = function (s) { alert(s); };
Window.confirm = function (s) { return confirm(s); };
Window.prompt = function (m, d) { return prompt(m, d); };
Window.find = function (type, title) {
for (var i = 0; i < __uiWins.length; i++) if (__uiWins[i].text === title) return __uiWins[i];
for (const elem of __uiWins) if (elem.text === title) return elem;
return null;
};
@@ -335,7 +335,7 @@ function __uiColor(c) {
return null;
}
function __uiFontSize(f, g) {
var x = f && f.size !== undefined ? Number(f.size) : g && g.font && g.font.size !== undefined ? Number(g.font.size) : 12;
var x = f?.size !== undefined ? Number(f.size) : g?.font && g.font.size !== undefined ? Number(g.font.size) : 12;
return x > 0 ? x : 12;
}
@@ -371,11 +371,11 @@ function __uiGraphics() {
this.__push({ op: "fill", color: brush ? brush.color : null, path: (path || this.currentPath).slice() });
},
strokePath: function (pen, path) {
this.__push({ op: "stroke", color: pen ? pen.color : null, width: pen && pen.lineWidth !== undefined ? pen.lineWidth : 1, path: (path || this.currentPath).slice() });
this.__push({ op: "stroke", color: pen ? pen.color : null, width: pen?.lineWidth !== undefined ? pen.lineWidth : 1, path: (path || this.currentPath).slice() });
},
drawString: function (text, pen, x, y, font) {
var f = font || this.font;
this.__push({ op: "text", text: String(text), color: pen ? pen.color : null, x: __num(x || 0), y: __num(y || 0), size: __uiFontSize(f, this), style: String(f && f.style !== undefined ? f.style : "") });
this.__push({ op: "text", text: String(text), color: pen ? pen.color : null, x: __num(x || 0), y: __num(y || 0), size: __uiFontSize(f, this), style: String(f?.style !== undefined ? f.style : "") });
},
drawOSControl: function () { this.__push({ op: "os" }); },
drawImage: function (img, x, y, w, h) { this.__push({ op: "image", image: __uiImageJson(img), x: __num(x || 0), y: __num(y || 0), w: w === undefined ? 0 : __num(w), h: h === undefined ? 0 : __num(h) }); },
@@ -384,7 +384,7 @@ function __uiGraphics() {
var size = __uiFontSize(font, this);
var lines = String(s).split("\n");
var cw = size * 0.58, w = 0;
for (var i = 0; i < lines.length; i++) w = Math.max(w, Math.ceil(lines[i].length * cw));
for (const elem of lines) w = Math.max(w, Math.ceil(elem.length * cw));
var n = lines.length;
if (boundaryWidth > 0 && w > boundaryWidth) {
n = Math.ceil(w / boundaryWidth) + lines.length - 1;
@@ -405,14 +405,14 @@ function __uiRunDraws(win) {
})(win);
if (!drawn.length) return;
__uiLayout(win);
for (var k = 0; k < drawn.length; k++) {
var c = drawn[k];
for (const elem of drawn) {
var c = elem;
c.__draw = [];
c.graphics.__rec = c.__draw;
try {
c.onDraw.call(c, { type: "draw", target: c });
} catch (e) {
try { writeLn("onDraw: " + (e && e.message ? e.message : e)); } catch (e2) {}
try { writeLn("onDraw: " + (e?.message ? e.message : e)); } catch (e2) {}
}
c.graphics.__rec = null;
}
@@ -498,7 +498,7 @@ function __uiProto() {
var arg = t === "image" || t === "iconbutton" ? text : typeof text === "object" && text !== null && !(text instanceof Array) ? "" : text;
__uiInit(c, t, this.window, this, bounds, arg, props);
if ((t === "dropdownlist" || t === "listbox") && text instanceof Array) {
for (var i = 0; i < text.length; i++) c.add("item", text[i]);
for (const elem of text) c.add("item", elem);
c.text = "";
}
if (t === "slider" || t === "scrollbar") {
@@ -521,7 +521,7 @@ function __uiProto() {
if (t === "checkbox" || t === "radiobutton") c.value = false;
if (t === "edittext" && c.properties.multiline) c.__multiline = true;
if (t === "edittext" && c.properties.readonly) c.__readonly = true;
if (c.properties && c.properties.name) c.name = c.properties.name;
if (c.properties?.name) c.name = c.properties.name;
this.children.push(c);
if (t === "tab" && this.type === "tabbedpanel" && this.__sel.length === 0) this.__sel = [this.children.length - 1];
return c;
@@ -608,21 +608,21 @@ function __uiProto() {
removeEventListener: function (name, fn, capture) {
var k = String(name).toLowerCase();
var m = capture ? this.__capture : this.__listeners;
if (m && m[k]) m[k] = m[k].filter(function (f) { return f !== fn; });
if (m?.[k]) m[k] = m[k].filter(function (f) { return f !== fn; });
},
dispatchEvent: function (e) { this.__fire("on" + String(e.type || e).charAt(0).toUpperCase() + String(e.type || e).slice(1)); },
__fire: function (name) {
var r;
if (typeof this[name] === "function") r = this[name].call(this);
var l = this.__listeners[name.slice(2).toLowerCase()];
if (l) for (var i = 0; i < l.length; i++) l[i].call(this, { type: name.slice(2).toLowerCase(), target: this });
if (l) for (const elem of l) elem.call(this, { type: name.slice(2).toLowerCase(), target: this });
return r;
},
__handlers: function () {
var out = [];
var names = ["onClick", "onChange", "onChanging", "onDoubleClick", "onClose", "onShow", "onActivate", "onDeactivate", "onResize", "onDraw"];
for (var i = 0; i < names.length; i++) {
var n = names[i];
for (const elem of names) {
var n = elem;
if (typeof this[n] === "function" || (this.__listeners[n.slice(2).toLowerCase()] || []).length) out.push(n);
}
return out;
@@ -634,8 +634,8 @@ function __uiProto() {
(function () {
var all = ["Window", "Panel", "Group", "Button", "IconButton", "StaticText", "EditText", "Checkbox", "RadioButton", "Slider",
"Scrollbar", "Progressbar", "DropDownList", "ListBox", "TabbedPanel", "Tab", "ScriptUIImage"];
for (var i = 0; i < all.length; i++) {
var C = __ecGlobal[all[i]];
for (const elem of all) {
var C = __ecGlobal[elem];
var p = __uiProto();
for (var k in p) C.prototype[k] = p[k];
Object.defineProperty(C.prototype, "bounds", {
@@ -737,8 +737,8 @@ function __uiMouseListeners(c, type, capture) {
// The mouse events that reach a listener at `c`: its own, and its containers' (`inherited`).
function __uiMouseWanted(c, inherited) {
var out = inherited.slice();
for (var i = 0; i < __uiMouseKinds.length; i++) {
var t = __uiMouseKinds[i];
for (const elem of __uiMouseKinds) {
var t = elem;
if (out.indexOf(t) < 0 && (__uiMouseListeners(c, t, false).length || __uiMouseListeners(c, t, true).length)) out.push(t);
}
return out;
@@ -793,7 +793,7 @@ function __uiMouseFire(target, type, v) {
var l = __uiMouseListeners(c, type, capture);
e.currentTarget = c;
e.eventPhase = phase;
for (var i = 0; i < l.length; i++) if (typeof l[i] === "function") l[i].call(c, e);
for (const elem of l) if (typeof elem === "function") elem.call(c, e);
};
var n = path.length - 1;
for (var i = 0; i < n; i++) {
@@ -832,7 +832,7 @@ function __uiJson(c, inherited) {
if (m) o.margins = m;
if (typeof c.spacing === "number") o.spacing = c.spacing;
var kids = c.children;
for (var i = 0; i < kids.length; i++) o.children.push(__uiJson(kids[i], mouse));
for (const elem of kids) o.children.push(__uiJson(elem, mouse));
}
if (c.alignment !== null && c.alignment !== undefined) o.alignment = __uiAlign(c.alignment);
if (c.type === "checkbox" || c.type === "radiobutton") o.checked = !!c.value;
@@ -859,8 +859,8 @@ function __uiJson(c, inherited) {
// The open windows of this script, for the host.
function __uiSnapshot() {
var out = [];
for (var i = 0; i < __uiWins.length; i++) {
var w = __uiWins[i];
for (const elem of __uiWins) {
var w = elem;
if (w.__shown && !w.__closed) __uiRunDraws(w);
out.push({ id: w.__wid, kind: w.__kind, title: String(w.text || ""), visible: !!w.__shown && !w.__closed, modal: !!w.__modal, root: __uiJson(w) });
}
@@ -871,12 +871,12 @@ function __uiSnapshot() {
function __uiOpenCount() {
var n = 0;
for (var i = 0; i < __uiWins.length; i++) if (__uiWins[i].__shown && !__uiWins[i].__closed) n++;
for (const elem of __uiWins) if (elem.__shown && !elem.__closed) n++;
return n;
}
function __uiWin(id) {
for (var i = 0; i < __uiWins.length; i++) if (__uiWins[i].__wid === id) return __uiWins[i];
for (const elem of __uiWins) if (elem.__wid === id) return elem;
return null;
}
function __uiFind(c, id) {
@@ -914,7 +914,7 @@ function __uiDispatch(winId, id, kind, valueJson) {
if (!c || c.enabled === false) return;
var radio = function () {
var sib = c.parent ? c.parent.children : [];
for (var i = 0; i < sib.length; i++) if (sib[i] !== c && sib[i].type === "radiobutton") sib[i].value = false;
for (const elem of sib) if (elem !== c && elem.type === "radiobutton") elem.value = false;
c.value = true;
};
// A mouse click carries the pointer's details ({x, y, button, detail, …keys}).
+4 -4
View File
@@ -89,7 +89,7 @@ mod imp {
Sock::Listener(_) => None,
};
let entry = Arc::new(Entry { sock: Mutex::new(s), wake, closed: AtomicBool::new(false) });
let mut t = table().lock().unwrap_or_else(|e| e.into_inner());
let mut t = table().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let id = t.next;
t.next += 1;
t.socks.insert(id, entry);
@@ -98,12 +98,12 @@ mod imp {
/// The socket `id`, if open. The table is locked only for the lookup.
fn get(id: u32) -> Option<Arc<Entry>> {
table().lock().unwrap_or_else(|e| e.into_inner()).socks.get(&id).cloned()
table().lock().unwrap_or_else(std::sync::PoisonError::into_inner).socks.get(&id).cloned()
}
/// Forget socket `id`; a call blocked on it fails at once.
fn close(id: u32) {
let entry = table().lock().unwrap_or_else(|e| e.into_inner()).socks.remove(&id);
let entry = table().lock().unwrap_or_else(std::sync::PoisonError::into_inner).socks.remove(&id);
if let Some(entry) = entry {
entry.closed.store(true, Ordering::Release);
if let Some(w) = &entry.wake {
@@ -261,7 +261,7 @@ mod imp {
_ => err("the socket is not open"),
};
};
let mut sock = entry.sock.lock().unwrap_or_else(|e| e.into_inner());
let mut sock = entry.sock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// Recheck after locking: close or a failed mode transition may have retired it
// while this caller waited with an Arc obtained from the table.
if entry.closed.load(Ordering::Acquire) {
+1 -1
View File
@@ -10,6 +10,6 @@ rust-version.workspace = true
workspace = true
[dependencies]
miniz_oxide = "0.8"
miniz_oxide = "0.9"
ndarray = { version = "0.17", default-features = false, features = ["std"] }
rayon = { workspace = true }
+3 -1
View File
@@ -150,7 +150,9 @@ impl Reader<'_> {
let mut files: Vec<PathBuf> = rd
.flatten()
.map(|e| e.path())
.filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(".conf") && n.starts_with(|c: char| c.is_ascii_digit())))
.filter(|p| {
p.file_name().and_then(std::ffi::OsStr::to_str).is_some_and(|n| n.ends_with(".conf") && n.starts_with(|c: char| c.is_ascii_digit()))
})
.collect();
files.sort();
for f in files {
+20 -20
View File
@@ -116,7 +116,7 @@ fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec<PathBuf>) {
if depth < 8 {
walk(&p, depth + 1, out);
}
} else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) {
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) {
out.push(p);
}
}
@@ -319,7 +319,7 @@ fn push(db: &mut Db, info: FaceInfo) -> FaceId {
/// added.
pub fn add_source(src: &dyn FontSource) -> usize {
let faces = src.faces();
let mut d = db().write().unwrap_or_else(|e| e.into_inner());
let mut d = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut n = 0;
for f in faces {
let dup = d.faces.iter().any(|g| g.info.family.eq_ignore_ascii_case(&f.family) && g.info.style.eq_ignore_ascii_case(&f.style));
@@ -329,7 +329,7 @@ pub fn add_source(src: &dyn FontSource) -> usize {
}
}
// A new face can belong to one of a script's usual families.
family_faces_cache().write().unwrap_or_else(|e| e.into_inner()).clear();
family_faces_cache().write().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
n
}
@@ -337,9 +337,9 @@ pub fn add_source(src: &dyn FontSource) -> usize {
pub fn add_font_data(data: Vec<u8>) -> Vec<FaceId> {
let data = Arc::new(data);
let names = read_faces_bytes(&data);
let mut d = db().write().unwrap_or_else(|e| e.into_inner());
let mut d = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
let ids = names.into_iter().map(|n| push(&mut d, info_from(n, FaceData::Shared(data.clone()), "user"))).collect();
family_faces_cache().write().unwrap_or_else(|e| e.into_inner()).clear();
family_faces_cache().write().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
ids
}
@@ -353,21 +353,21 @@ pub fn scan_system() -> usize {
if system_scanned() {
return 0;
}
let _scan = SCAN.lock().unwrap_or_else(|e| e.into_inner());
let _scan = SCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if system_scanned() {
return 0;
}
let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) };
db().write().unwrap_or_else(|e| e.into_inner()).scanned = true;
db().write().unwrap_or_else(std::sync::PoisonError::into_inner).scanned = true;
n
}
/// Scan the system font folders again, adding fonts installed since the last scan (faces already
/// known are kept, so font ids stay valid). Returns the number of faces added.
pub fn rescan_system() -> usize {
let _scan = SCAN.lock().unwrap_or_else(|e| e.into_inner());
let _scan = SCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) };
db().write().unwrap_or_else(|e| e.into_inner()).scanned = true;
db().write().unwrap_or_else(std::sync::PoisonError::into_inner).scanned = true;
n
}
@@ -388,12 +388,12 @@ pub fn scan_system_in_background() {
}
pub fn system_scanned() -> bool {
db().read().unwrap_or_else(|e| e.into_inner()).scanned
db().read().unwrap_or_else(std::sync::PoisonError::into_inner).scanned
}
/// A face by id (panics never: unknown ids give the default face).
pub fn face(id: FaceId) -> Arc<Face> {
let d = db().read().unwrap_or_else(|e| e.into_inner());
let d = db().read().unwrap_or_else(std::sync::PoisonError::into_inner);
// The bundled fonts are compiled in and always parse (`tests::bundled_families_and_resolution` checks them), so
// the database is never empty.
#[allow(clippy::expect_used)]
@@ -402,7 +402,7 @@ pub fn face(id: FaceId) -> Arc<Face> {
/// All faces (bundled first).
pub fn all_faces() -> Vec<Arc<Face>> {
db().read().unwrap_or_else(|e| e.into_inner()).faces.clone()
db().read().unwrap_or_else(std::sync::PoisonError::into_inner).faces.clone()
}
/// Family names in the fonts' own languages: English family → native family (only families
@@ -743,15 +743,15 @@ static REQUESTED_CJK_LOCALE: RwLock<Option<String>> = RwLock::new(None);
pub fn set_cjk_locale(tag: &str) {
let tag = tag.trim().to_ascii_lowercase();
let requested = if tag.is_empty() || tag == "c" || tag == "posix" { None } else { Some(tag) };
*REQUESTED_CJK_LOCALE.write().unwrap_or_else(|e| e.into_inner()) = requested;
*REQUESTED_CJK_LOCALE.write().unwrap_or_else(std::sync::PoisonError::into_inner) = requested;
// The order of the CJK families follows the language, so a change of language drops the cached
// fallbacks.
fallback_cache().write().unwrap_or_else(|e| e.into_inner()).clear();
family_faces_cache().write().unwrap_or_else(|e| e.into_inner()).clear();
fallback_cache().write().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
family_faces_cache().write().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
}
fn requested_cjk_locale() -> Option<String> {
REQUESTED_CJK_LOCALE.read().unwrap_or_else(|e| e.into_inner()).clone()
REQUESTED_CJK_LOCALE.read().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
}
/// The CJK locale to use: [`set_cjk_locale`] when the host asked for one, otherwise the locale
@@ -981,7 +981,7 @@ fn fallback_with_locale(c: char, prefer: FaceId, locale: impl FnOnce() -> CjkLoc
}
}
let key = (c as u32 >> 8, prefer);
if let Some(cached) = fallback_cache().read().unwrap_or_else(|e| e.into_inner()).get(&key).copied() {
if let Some(cached) = fallback_cache().read().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key).copied() {
match cached {
Some(id) if face(id).has_char(c) => return id,
Some(_) => {}
@@ -992,7 +992,7 @@ fn fallback_with_locale(c: char, prefer: FaceId, locale: impl FnOnce() -> CjkLoc
scan_system();
}
let found = system_fallback(c, &p.info.style);
fallback_cache().write().unwrap_or_else(|e| e.into_inner()).insert(key, found);
fallback_cache().write().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, found);
found.unwrap_or(prefer)
}
@@ -1000,7 +1000,7 @@ fn fallback_with_locale(c: char, prefer: FaceId, locale: impl FnOnce() -> CjkLoc
/// for `script`, in the order they are tried, each in the style of `prefer`. For CJK, the other CJK
/// languages' families follow in the locale's order.
fn family_faces(script: FallbackScript, prefer: &Face, locale: CjkLocale) -> Arc<Vec<FaceId>> {
if let Some(faces) = family_faces_cache().read().unwrap_or_else(|e| e.into_inner()).get(&(script, prefer.id, locale)) {
if let Some(faces) = family_faces_cache().read().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(script, prefer.id, locale)) {
return faces.clone();
}
let order: Vec<FallbackScript> = match script {
@@ -1016,7 +1016,7 @@ fn family_faces(script: FallbackScript, prefer: &Face, locale: CjkLocale) -> Arc
let style = &prefer.info.style;
let faces: Arc<Vec<FaceId>> =
Arc::new(order.into_iter().flat_map(|s| families_for(s, locale)).filter_map(|fam| resolve_in(fam, style)).map(|r| r.face).collect());
family_faces_cache().write().unwrap_or_else(|e| e.into_inner()).insert((script, prefer.id, locale), faces.clone());
family_faces_cache().write().unwrap_or_else(std::sync::PoisonError::into_inner).insert((script, prefer.id, locale), faces.clone());
faces
}
+4 -4
View File
@@ -399,7 +399,7 @@ enum Form {
pub fn feature_substitutes(face: FaceId, tag: &[u8; 4], c: char) -> bool {
static C: OnceLock<Mutex<HashMap<(FaceId, [u8; 4], char), bool>>> = OnceLock::new();
let cache = C.get_or_init(Default::default);
if let Some(v) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, *tag, c)) {
if let Some(v) = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(face, *tag, c)) {
return *v;
}
let f = fonts::face(face);
@@ -419,7 +419,7 @@ pub fn feature_substitutes(face: FaceId, tag: &[u8; 4], c: char) -> bool {
}
_ => false,
};
let mut m = cache.lock().unwrap_or_else(|e| e.into_inner());
let mut m = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if m.len() > 100_000 {
m.clear();
}
@@ -1123,12 +1123,12 @@ pub fn layout_rich(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[Para
let key = h.finish();
static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let now = CLOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if let Some(e) = cache().lock().unwrap_or_else(|e| e.into_inner()).get_mut(&key) {
if let Some(e) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(&key) {
e.0 = now;
return e.1.clone();
}
let l = Arc::new(layout_rich_uncached(text, runs, paras));
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
let mut c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() >= 512 {
// evict the oldest quarter
let mut ages: Vec<u64> = c.values().map(|v| v.0).collect();
+2 -2
View File
@@ -54,7 +54,7 @@ impl OutlinePen for Pen {
pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
static C: OnceLock<Mutex<HashMap<(FaceId, u32), Option<Arc<BezPath>>>>> = OnceLock::new();
let c = C.get_or_init(Default::default);
if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, gid)) {
if let Some(v) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(face, gid)) {
return v.clone();
}
let f = fonts::face(face);
@@ -64,7 +64,7 @@ pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen).ok()?;
Some(Arc::new(pen.0))
});
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if m.len() > 50_000 {
m.clear();
}
+4 -4
View File
@@ -36,7 +36,7 @@ pub fn intern(coords: &[(String, f32)]) -> u32 {
if key.windows(2).any(|w| w[0].0 == w[1].0) {
return 0;
}
let mut t = table().lock().unwrap_or_else(|e| e.into_inner());
let mut t = table().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(id) = t.by_coords.get(&key) {
return *id;
}
@@ -55,7 +55,7 @@ pub fn coords(id: u32) -> Arc<Vec<(String, f32)>> {
if id == 0 {
return Arc::new(Vec::new());
}
table().lock().unwrap_or_else(|e| e.into_inner()).values.get(id as usize - 1).cloned().unwrap_or_default()
table().lock().unwrap_or_else(std::sync::PoisonError::into_inner).values.get(id as usize - 1).cloned().unwrap_or_default()
}
fn table() -> &'static Mutex<Table> {
@@ -109,7 +109,7 @@ pub fn outline_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Opt
let c = C.get_or_init(Default::default);
// Quantise to 1/100 of a unit so animation frames reuse outlines.
let key: Key = (face, gid, coords.iter().map(|(t, v)| (t.clone(), (v * 100.0).round() as i32)).collect());
if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if let Some(v) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return v.clone();
}
let f = fonts::face(face);
@@ -120,7 +120,7 @@ pub fn outline_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Opt
g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::from(&loc)), &mut pen).ok()?;
Some(Arc::new(pen.0))
});
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if m.len() > 20_000 {
m.clear();
}
+4 -1
View File
@@ -33,6 +33,9 @@ kurbo = { workspace = true }
image = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Floating panels open in OS windows of their own on desktop; the web keeps them in the page.
craft-ui = { workspace = true, features = ["native-windows"] }
craft-update = { workspace = true }
sys-locale = { workspace = true }
[dev-dependencies]
@@ -40,7 +43,7 @@ sys-locale = { workspace = true }
# here documents the requirement (kittest's own dev-dependencies do not propagate to consumers).
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
pollster = "0.4"
pollster = "1"
effectcraft-host = { workspace = true }
exr = { workspace = true }
+1 -1
View File
@@ -404,7 +404,7 @@ fn panel(app: &mut EffectcraftApp, method: &str, p: &Value) -> Outcome {
if method == "ui.panel.show" {
app.show_panel(panel);
} else {
app.ui.dock.close(panel);
app.close_panel(panel);
}
ok(Value::Null)
}
+386 -1421
View File
File diff suppressed because it is too large Load Diff
-312
View File
@@ -1,312 +0,0 @@
//! Convert the persisted workspace schema to shared docking transactions.
use craft_ui::docking::{Action, Floating as SharedFloating, Layout as SharedLayout, Node, Placement, StackEntry as SharedStackEntry};
use craft_ui::layout::{SplitAxis, SplitSize as SharedSplitSize};
use crate::dock::{DockNode, Floating, Layout, PanelKind, SplitSize, StackEntry};
const MAX_DEPTH: usize = 32;
const MAX_NODES: usize = 4096;
const MAX_PANELS: usize = 128;
fn encode_node(node: &DockNode, depth: usize, count: &mut usize) -> Result<Node<PanelKind>, String> {
*count = count.saturating_add(1);
if depth > MAX_DEPTH || *count > MAX_NODES {
return Err("workspace dock tree exceeds supported limits".into());
}
Ok(match node {
DockNode::Tabs { panels, active } => {
if panels.len() > MAX_PANELS {
return Err("too many workspace tabs".into());
}
Node::Tabs { panels: panels.clone(), active: *active }
}
DockNode::Stack { entries } => {
if entries.len() > MAX_PANELS {
return Err("too many workspace stack entries".into());
}
if entries.iter().any(|entry| entry.panels.is_empty() || entry.active >= entry.panels.len() || entry.panels.len() > MAX_PANELS) {
return Err("invalid stacked tab selection".into());
}
Node::Stack {
entries: entries
.iter()
.flat_map(|entry| entry.panels.iter().map(move |panel| SharedStackEntry { panel: *panel, open: entry.open, height: entry.height }))
.collect(),
}
}
DockNode::Split { vertical, size, a, b } => Node::Split {
axis: if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal },
size: match *size {
SplitSize::Ratio(value) => SharedSplitSize::Ratio(value),
SplitSize::FixedA(value) => SharedSplitSize::FixedFirst(value),
SplitSize::FixedB(value) => SharedSplitSize::FixedSecond(value),
},
first: Box::new(encode_node(a, depth.saturating_add(1), count)?),
second: Box::new(encode_node(b, depth.saturating_add(1), count)?),
},
})
}
fn decode_node(node: Node<PanelKind>) -> DockNode {
match node {
Node::Tabs { panels, active } => DockNode::Tabs { panels, active },
Node::Stack { entries } => {
DockNode::Stack { entries: entries.into_iter().map(|entry| StackEntry::new(entry.panel, entry.open, entry.height)).collect() }
}
Node::Split { axis, size, first, second } => DockNode::Split {
vertical: axis == SplitAxis::Vertical,
size: match size {
SharedSplitSize::Ratio(value) => SplitSize::Ratio(value),
SharedSplitSize::FixedFirst(value) => SplitSize::FixedA(value),
SharedSplitSize::FixedSecond(value) => SplitSize::FixedB(value),
},
a: Box::new(decode_node(*first)),
b: Box::new(decode_node(*second)),
},
}
}
pub(crate) fn encode(root: &DockNode, floating: &[Floating]) -> Result<SharedLayout<PanelKind>, String> {
if floating.len() > MAX_PANELS || floating.iter().any(|group| group.panels.len() > MAX_PANELS) {
return Err("too many floating workspace panels".into());
}
let root = if matches!(root, DockNode::Tabs { panels, active: 0 } if panels.is_empty()) { None } else { Some(encode_node(root, 0, &mut 0)?) };
Ok(SharedLayout {
root,
floating: floating.iter().map(|group| SharedFloating { panels: group.panels.clone(), active: group.active, rect: group.rect }).collect(),
})
}
pub(crate) fn decode(layout: SharedLayout<PanelKind>) -> Layout {
Layout {
root: layout.root.map(decode_node).unwrap_or_else(|| DockNode::Tabs { panels: vec![], active: 0 }),
floating: layout.floating.into_iter().map(|group| Floating { panels: group.panels, active: group.active, rect: group.rect }).collect(),
}
}
/// Restore app-owned accordion tab frames after shared validation/transactions.
/// The shared model represents individual stack panels; grouping stays in the persisted app schema.
fn restore_frames(node: &mut DockNode, original: &DockNode) {
fn collect(node: &DockNode, out: &mut Vec<StackEntry>) {
match node {
DockNode::Split { a, b, .. } => {
collect(a, out);
collect(b, out);
}
DockNode::Stack { entries } => out.extend(entries.iter().cloned()),
DockNode::Tabs { .. } => {}
}
}
fn restore(node: &mut DockNode, frames: &[StackEntry]) {
match node {
DockNode::Split { a, b, .. } => {
restore(a, frames);
restore(b, frames);
}
DockNode::Stack { entries } => {
let mut result: Vec<StackEntry> = Vec::new();
for entry in std::mem::take(entries) {
let Some(panel) = entry.shown() else { continue };
let old = frames.iter().find(|frame| frame.contains(panel));
if let Some(last) = result.last_mut()
&& old.is_some_and(|frame| last.panels.iter().all(|p| frame.contains(*p)))
{
last.panels.push(panel);
if old.and_then(StackEntry::shown) == Some(panel) {
last.active = last.panels.len().saturating_sub(1);
}
} else {
result.push(entry);
}
}
for entry in &mut result {
if let Some(old) = frames.iter().find(|frame| entry.panels.iter().all(|panel| frame.contains(*panel))) {
entry.active = old
.shown()
.and_then(|shown| entry.panels.iter().position(|panel| *panel == shown))
.unwrap_or(old.active.min(entry.panels.len().saturating_sub(1)));
}
}
*entries = result;
}
DockNode::Tabs { .. } => {}
}
}
let mut frames = Vec::new();
collect(original, &mut frames);
restore(node, &frames);
}
pub(crate) fn apply(layout: &mut Layout, action: Action<PanelKind>) -> Result<(), String> {
let mut shared = encode(&layout.root, &layout.floating)?;
// Validate the same shared command first. Native stack insertion then retains tabs inside
// their accordion frame, including the existing before/after insertion policy.
shared.apply(action.clone()).map_err(|error| error.to_string())?;
let mut proposed = decode(shared);
restore_frames(&mut proposed.root, &layout.root);
match action {
Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } if layout.root.contains(anchor) => {
fn stacked(node: &DockNode, p: PanelKind) -> bool {
match node {
DockNode::Split { a, b, .. } => stacked(a, p) || stacked(b, p),
DockNode::Stack { entries } => entries.iter().any(|e| e.contains(p)),
_ => false,
}
}
if stacked(&layout.root, anchor) {
let mut native = layout.clone();
native.unfloat(panel);
let applied = match placement {
Placement::Tab { before } => native.root.insert_tab(panel, anchor, before),
Placement::Split(zone) => native.root.dock_panel(
panel,
anchor,
match zone {
craft_ui::docking::Zone::Left => crate::dock::Zone::Left,
craft_ui::docking::Zone::Right => crate::dock::Zone::Right,
craft_ui::docking::Zone::Top => crate::dock::Zone::Top,
craft_ui::docking::Zone::Bottom => crate::dock::Zone::Bottom,
craft_ui::docking::Zone::Center => crate::dock::Zone::Center,
},
),
};
if !applied {
return Err("stacked panel move failed".into());
}
proposed = native;
}
}
Action::Activate { panel } => {
proposed.root.activate(panel);
}
Action::SetStackOpen { panel, open } => {
fn set(node: &mut DockNode, p: PanelKind, open: bool) {
match node {
DockNode::Split { a, b, .. } => {
set(a, p, open);
set(b, p, open);
}
DockNode::Stack { entries } => {
if let Some(e) = entries.iter_mut().find(|e| e.contains(p)) {
e.open = open;
}
}
_ => {}
}
}
set(&mut proposed.root, panel, open);
}
Action::ResizeStack { panel, height } => {
fn set(node: &mut DockNode, p: PanelKind, h: Option<f32>) {
match node {
DockNode::Split { a, b, .. } => {
set(a, p, h);
set(b, p, h);
}
DockNode::Stack { entries } => {
if let Some(e) = entries.iter_mut().find(|e| e.contains(p)) {
e.height = h;
}
}
_ => {}
}
}
set(&mut proposed.root, panel, height);
}
_ => {}
}
encode(&proposed.root, &proposed.floating)?.validate().map_err(|error| error.to_string())?;
*layout = proposed;
Ok(())
}
pub(crate) fn apply_tree(root: &mut DockNode, action: Action<PanelKind>) -> bool {
if !valid_tree(root) {
return false;
}
let mut layout = Layout { root: root.clone(), floating: vec![] };
if apply(&mut layout, action).is_err() {
return false;
}
*root = layout.root;
true
}
pub(crate) fn valid_tree(root: &DockNode) -> bool {
encode(root, &[]).is_ok_and(|shared| shared.validate().is_ok())
}
pub(crate) fn valid_for_close(root: &DockNode) -> bool {
fn normalize(node: &mut Node<PanelKind>) {
match node {
Node::Tabs { panels, active } => *active = (*active).min(panels.len().saturating_sub(1)),
Node::Split { first, second, .. } => {
normalize(first);
normalize(second);
}
Node::Stack { .. } => {}
}
}
let Ok(mut shared) = encode(root, &[]) else { return false };
if let Some(root) = &mut shared.root {
normalize(root);
}
shared.validate().is_ok()
}
pub(crate) fn unfloat(layout: &mut Layout, panel: PanelKind) -> bool {
let Ok(mut shared) = encode(&layout.root, &layout.floating) else { return false };
let mut found = false;
for group in &mut shared.floating {
if group.panels.contains(&panel) {
group.active = group.active.min(group.panels.len().saturating_sub(1));
found = true;
}
}
if !found || shared.apply(Action::Close { panel }).is_err() {
return false;
}
let mut proposed = decode(shared);
restore_frames(&mut proposed.root, &layout.root);
*layout = proposed;
true
}
#[cfg(test)]
mod stacked_frame_tests {
use super::*;
use crate::dock::Zone;
#[test]
fn stacked_tabs_survive_shared_resize_activate_float_and_close() {
let mut root = crate::dock::workspace("Default");
assert!(root.dock_panel(PanelKind::Info, PanelKind::Properties, Zone::Center));
fn group(node: &DockNode) -> Option<&StackEntry> {
match node {
DockNode::Split { a, b, .. } => group(a).or_else(|| group(b)),
DockNode::Stack { entries } => entries.iter().find(|e| e.contains(PanelKind::Properties)),
_ => None,
}
}
let before = group(&root).unwrap().clone();
assert!(before.contains(PanelKind::Info));
let mut layout = Layout { root, floating: vec![] };
layout.apply(Action::ResizeSplit { path: vec![], size: SharedSplitSize::Ratio(0.6) }).unwrap();
assert_eq!(group(&layout.root).unwrap(), &before);
layout.apply(Action::Activate { panel: PanelKind::Properties }).unwrap();
assert_eq!(group(&layout.root).unwrap().shown(), Some(PanelKind::Properties));
let original_frame = group(&layout.root).unwrap().clone();
layout.apply(Action::OpenAt { panel: PanelKind::History, anchor: PanelKind::Properties, placement: Placement::Tab { before: None } }).unwrap();
let opened = group(&layout.root).unwrap();
assert!(opened.contains(PanelKind::History));
assert!(opened.contains(PanelKind::Info));
assert_eq!(opened.shown(), Some(PanelKind::History));
assert_eq!(opened.height, original_frame.height);
layout.apply(Action::Close { panel: PanelKind::History }).unwrap();
layout.apply(Action::Float { panel: PanelKind::Info, rect: [20.0, 20.0, 300.0, 300.0] }).unwrap();
assert_eq!(group(&layout.root).unwrap().panels, vec![PanelKind::Properties]);
layout.apply(Action::Close { panel: PanelKind::Info }).unwrap();
assert!(layout.floating.is_empty());
assert_eq!(group(&layout.root).unwrap().panels, vec![PanelKind::Properties]);
}
}
File diff suppressed because it is too large Load Diff
+133 -44
View File
@@ -14,7 +14,6 @@ pub mod color_picker;
pub mod control;
pub mod credits;
pub mod dock;
mod dock_shared;
pub mod dock_ui;
mod expression_diagnostics;
pub mod frames;
@@ -30,6 +29,7 @@ mod pointer_wrap;
pub mod prefs_live;
pub mod state;
pub mod theme;
pub mod updates_ui;
pub mod widgets;
mod workspace_state;
@@ -37,7 +37,7 @@ use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender};
pub use control::ControlRequest;
use dock::PanelKind;
use dock::{PanelKind, WorkspaceExt as _};
use effectcraft_engine::Session;
use effectcraft_engine::project::ItemId;
use effectcraft_engine::render::RenderOpts;
@@ -441,6 +441,8 @@ pub struct EffectcraftApp {
pub(crate) control_rx: Option<Receiver<ControlRequest>>,
pub(crate) deferred: Vec<(ControlRequest, f64)>,
pub(crate) synthetic: Vec<egui::Event>,
/// Input from floating panels' own OS windows for the main window's next frame.
pub(crate) forwarded: ForwardedInput,
pub(crate) input_waiters: Vec<Sender<serde_json::Value>>,
/// Control requests waiting for an offloaded job (`wait: true` in the browser).
pub(crate) job_waiters: Vec<(control::JobWaiter, Sender<serde_json::Value>)>,
@@ -479,6 +481,8 @@ pub struct EffectcraftApp {
device_lost_close: Option<bool>,
/// Answers of file dialogs shown without blocking ([`Hooks::file_dialog_async`]).
pub(crate) dialog_answers: (Sender<DialogAnswer>, Receiver<DialogAnswer>),
/// Those dialogs that are up, for blocking floating panels' OS windows meanwhile.
pub(crate) file_dialogs: FileDialogsUp,
/// Commands from the native menu bar.
pub command_inbox: Option<Receiver<String>>,
/// Pointer position in comp pixels (Info panel).
@@ -489,6 +493,7 @@ pub struct EffectcraftApp {
pub audio: Option<audio::AudioPlayback>,
/// Audio scrubbing's open output (Ctrl/Cmd-drag the current time, `playback.scrubAudio`).
pub scrub: Option<audio::AudioScrub>,
pub(crate) updates_rt: updates_ui::Runtime,
/// Audio panel VU meters.
pub meter: audio::Meter,
/// Waveform peak summaries per footage item (see `panels::waveform`).
@@ -504,6 +509,13 @@ pub struct EffectcraftApp {
/// Docked groups laid out last frame: (active panel, group rect) — `~` maximizes the one
/// under the pointer.
pub(crate) dock_rects: Vec<(PanelKind, egui::Rect)>,
/// The floating panels' OS windows that drew a panel last frame; one that is gone releases
/// its IME state.
pub(crate) native_viewports: Vec<egui::ViewportId>,
/// The native windows that drew a panel last frame without publishing their IME descriptor.
pub(crate) unpublished_viewports: Vec<egui::ViewportId>,
/// A floating panel shown by a command: its window comes to the front next frame.
pub(crate) pending_panel_raise: Option<PanelKind>,
/// Home screen: recent-project thumbnail textures by path (None = no thumbnail).
pub(crate) home_thumbs: std::collections::HashMap<String, Option<egui::TextureHandle>>,
/// The (project path, saved revision) whose thumbnail was stored last.
@@ -519,14 +531,6 @@ pub struct EffectcraftApp {
/// The saved workspaces in the settings store ([`effectcraft_engine::config::ConfigStore`]).
pub const WORKSPACES_FILE: &str = "workspaces.json";
#[derive(serde::Serialize, serde::Deserialize)]
struct SavedWorkspaces {
#[serde(default)]
workspaces: std::collections::BTreeMap<String, dock::DockNode>,
#[serde(default)]
floating: std::collections::BTreeMap<String, Vec<dock::Floating>>,
}
/// The comp frames a playback pass covers (`first..=last`).
#[derive(Clone, Copy, Debug)]
struct FrameWindow {
@@ -594,6 +598,7 @@ impl EffectcraftApp {
control_rx: None,
deferred: vec![],
synthetic: vec![],
forwarded: ForwardedInput::default(),
input_waiters: vec![],
job_waiters: vec![],
pending_screenshots: vec![],
@@ -614,6 +619,7 @@ impl EffectcraftApp {
gpu_failure_applied: None,
device_lost_close: None,
dialog_answers: std::sync::mpsc::channel(),
file_dialogs: FileDialogsUp::default(),
command_inbox: None,
pointer_comp: None,
dialog_state: Default::default(),
@@ -627,11 +633,15 @@ impl EffectcraftApp {
applied_prefs: None,
recovery: None,
dock_rects: vec![],
native_viewports: vec![],
unpublished_viewports: vec![],
pending_panel_raise: None,
home_thumbs: Default::default(),
home_thumb_saved: None,
window_title: String::new(),
template_thumbs: Default::default(),
template_list: None,
updates_rt: updates_ui::Runtime::default(),
}
.with_ui_commands()
}
@@ -748,8 +758,7 @@ impl EffectcraftApp {
self.ui.home_learn = false;
}
self.ui.workspace = name.to_string();
self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::workspace(name));
self.ui.floating = self.ui.saved_floating.get(name).cloned().unwrap_or_else(|| dock::workspace_floating(name));
self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::Workspace::new(dock::workspace(name)));
self.ui.maximized = None;
}
@@ -855,46 +864,65 @@ impl EffectcraftApp {
/// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and
/// give it the focus: Window ▸ `<panel>`.
pub fn show_panel(&mut self, p: PanelKind) {
if !self.ui.floating.iter().any(|f| f.panels.contains(&p)) && self.ui.maximized.is_some_and(|m| m != p) {
if !self.ui.dock.is_floating(p) && self.ui.maximized.is_some_and(|m| m != p) {
self.ui.maximized = None;
}
self.raise_panel(p);
self.ui.focused = p;
}
/// Bring panel `p` to the front of its group, opening it in its usual place if it is closed,
/// without moving the focus (a comp opening brings up its viewer, an effect applied brings up
/// Effect Controls).
/// Bring panel `p` to the front of its group, opening it if it is closed, without moving the
/// focus (a comp opening brings up its viewer, an effect applied brings up Effect Controls).
/// A closed panel goes back where it was; one never laid out goes to its usual place: the
/// Composition panel in the centre with the Layer panel and other viewers (with none of
/// those left, above the Timeline), Layer and the viewers beside the Composition panel, and
/// so on.
pub fn raise_panel(&mut self, p: PanelKind) {
if self.ui.floating.iter().any(|group| group.panels.contains(&p)) {
self.edit_layout(|layout| layout.apply(craft_ui::docking::Action::Activate { panel: p }).is_ok());
return;
}
if !self.ui.dock.contains(p) {
// The Composition panel lives in the centre, with the Layer panel and other viewers;
// with none of those left, above the Timeline.
let centre = |d: &dock::DockNode| {
let mut v = Vec::new();
d.panels(&mut v);
v.into_iter().find(|q| matches!(q, PanelKind::Layer | PanelKind::Viewer(_) | PanelKind::Flowchart | PanelKind::Footage))
};
if !self.ui.dock.is_open(p) {
let docked = |q: &PanelKind| self.ui.dock.is_docked(*q);
let near = match p {
PanelKind::Composition => match centre(&self.ui.dock) {
Some(q) => q,
None if self.ui.dock.dock_panel(p, PanelKind::Timeline, dock::Zone::Top) => {
self.ui.dock.activate(p);
return;
}
None => PanelKind::EffectsPresets,
},
PanelKind::Layer | PanelKind::Flowchart | PanelKind::Viewer(_) => PanelKind::Composition,
PanelKind::RenderQueue => PanelKind::Timeline,
PanelKind::EffectControls | PanelKind::History => PanelKind::Project,
_ => PanelKind::EffectsPresets,
PanelKind::Composition => self
.ui
.dock
.panels()
.into_iter()
.find(|q| matches!(q, PanelKind::Layer | PanelKind::Viewer(_) | PanelKind::Flowchart | PanelKind::Footage) && docked(q)),
PanelKind::Layer | PanelKind::Flowchart | PanelKind::Viewer(_) => Some(PanelKind::Composition),
PanelKind::RenderQueue => Some(PanelKind::Timeline),
PanelKind::EffectControls | PanelKind::History => Some(PanelKind::Project),
_ => Some(PanelKind::EffectsPresets),
}
.filter(|q| self.ui.dock.is_open(*q));
let mut workspace = self.ui.dock.clone();
let known = workspace.hidden.iter().any(|(q, _)| *q == p);
let opened = if p == PanelKind::Composition && near.is_none() && !known && self.ui.dock.is_docked(PanelKind::Timeline) {
workspace
.apply(
craft_ui::docking::Action::OpenAt {
panel: p,
anchor: PanelKind::Timeline,
placement: craft_ui::docking::Placement::Split(craft_ui::docking::Zone::Top),
},
dock::permissions,
)
.map(|_| ())
} else if known {
workspace.set_visible(p, true, near, dock::permissions)
} else {
workspace.apply(craft_ui::docking::Action::Open { panel: p, anchor: near }, dock::permissions).map(|_| ())
};
self.ui.dock.open_near(p, near);
match opened {
Ok(()) => self.ui.dock = workspace,
Err(e) => {
self.ui.status = format!("can't open {}: {e}", p.title());
return;
}
}
}
let _ = self.ui.dock.apply(craft_ui::docking::Action::Activate { panel: p }, dock::permissions);
if self.ui.dock.is_floating(p) {
self.pending_panel_raise = Some(p);
}
self.ui.dock.activate(p);
}
pub fn render_source(&self) -> RenderSource {
@@ -1013,6 +1041,7 @@ impl EffectcraftApp {
/// Run the commands whose non-blocking file dialogs have been answered.
pub(crate) fn run_dialog_answers(&mut self) {
while let Ok(answer) = self.dialog_answers.1.try_recv() {
self.file_dialogs.answered();
menus::dialog_answered(self, answer);
}
}
@@ -1257,7 +1286,7 @@ impl EffectcraftApp {
}
let restore_max = preset.full_screen.then_some(self.ui.maximized);
let viewer = panels::viewers::active_panel(self);
if preset.full_screen && self.ui.dock.contains(viewer) {
if preset.full_screen && self.ui.dock.is_docked(viewer) {
self.ui.maximized = Some(viewer);
}
self.playback = Playback {
@@ -2218,6 +2247,7 @@ impl EffectcraftApp {
}
// Frames and worker completions wake the app even while it is hidden.
self.frames.set_context(ctx);
updates_ui::tick(self, ctx);
if self.session.render_job.is_some() {
self.session.poll_render();
ctx.request_repaint_after(std::time::Duration::from_millis(100));
@@ -2300,7 +2330,7 @@ impl EffectcraftApp {
// A hidden window paints when work needs a UI pass: queued input and waits, a settled
// screenshot, or playback into a child viewport.
let focused = self.focused;
let child_viewports = !self.ui.floating.is_empty() || prefs_live::video_preview_active(self, focused);
let child_viewports = !self.ui.dock.layout.floating.is_empty() || prefs_live::video_preview_active(self, focused);
let paint = self.want_paint
|| !self.synthetic.is_empty()
|| !self.input_waiters.is_empty()
@@ -2473,6 +2503,7 @@ impl EffectcraftApp {
panels::precomp::mini_flowchart(self, &ctx);
panels::home::capture_thumbnail(self);
panels::dialogs::show(self, &ctx);
updates_ui::show(self, &ctx);
panels::scriptui_view::sync_panels(self);
panels::scriptui_view::show_windows(self, &ctx);
panels::learn::coach(self, &ctx);
@@ -2626,6 +2657,62 @@ impl EffectcraftApp {
}
}
/// Non-blocking file dialogs ([`Hooks::file_dialog_async`]) that are up. While one is, the dock
/// area blocks floating panels' OS windows, as it does for a modal dialog; otherwise craft-ui
/// takes the app for inactive while the dialog has the keyboard, and hides floating palettes.
///
/// A dialog can end without answering: on macOS a sheet whose window goes away ends without its
/// completion handler. A dialog is taken for gone, so floating windows never stay blocked, once
/// one of the app's windows has the keyboard again after none had it, or when one has kept it
/// for [`FileDialogsUp::UNSEEN`] since the dialog was asked for, so the dialog never showed.
#[derive(Default)]
pub(crate) struct FileDialogsUp {
count: usize,
/// When the oldest dialog up was first checked ([`FileDialogsUp::up`]).
since: Option<f64>,
/// None of the app's windows had the keyboard since then.
left: bool,
}
impl FileDialogsUp {
/// Seconds a dialog has to take the keyboard from the app's windows. The desktop portal can
/// take a moment to show the first dialog on Linux.
const UNSEEN: f64 = 2.0;
pub(crate) fn opened(&mut self) {
if self.count == 0 {
*self = Self::default();
}
self.count = self.count.saturating_add(1);
}
pub(crate) fn answered(&mut self) {
self.count = self.count.saturating_sub(1);
}
/// Whether a dialog is up at `now`, `focused`: whether one of the app's windows has the
/// keyboard.
pub(crate) fn up(&mut self, focused: bool, now: f64) -> bool {
if self.count == 0 {
return false;
}
let since = *self.since.get_or_insert(now);
if !focused {
self.left = true;
} else if self.left || now - since >= Self::UNSEEN {
*self = Self::default();
}
self.count > 0
}
}
/// Keys and dropped files from floating panels' OS windows (craft-ui `DockOutput`).
#[derive(Default)]
pub(crate) struct ForwardedInput {
pub(crate) events: Vec<egui::Event>,
pub(crate) files: Vec<egui::DroppedFileHandle>,
}
impl eframe::App for EffectcraftApp {
fn on_exit(&mut self) {
self.save_workspace_state();
@@ -2640,6 +2727,8 @@ impl eframe::App for EffectcraftApp {
/// a batch), then prepare pointer wrapping; ending a numeric scrub clears the history merge key so the next edit
/// starts a new undo step.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
raw_input.events.append(&mut self.forwarded.events);
raw_input.dropped_files.append(&mut self.forwarded.files);
let injected = self.synthetic_in_flight || !self.synthetic.is_empty();
// Only a pass that can draw may receive synthetic input: `Context::run_ui` applies
// everything the hook feeds to its input state, so a pass that cannot draw (fonts still
+90 -68
View File
@@ -8,7 +8,7 @@
use serde_json::{Value, json};
use crate::EffectcraftApp;
use crate::dock::PanelKind;
use crate::dock::{PanelKind, WorkspaceExt as _};
use crate::state::{Resolution, Tool};
use effectcraft_engine::menus::{MenuEntry, MenuNode};
@@ -415,6 +415,11 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
if crate::panels::layer_styles_dialog::route(app, id, &params)? {
return Ok(Value::Null);
}
// Shared docking: `window.dockAction`, craft-ui's `window.panel.*` commands, `ui.dock`,
// `ui.dock.reset` and `window.panelWindow`.
if let Some(result) = crate::dock_ui::route(app, ctx, id, &params) {
return result;
}
// Legacy per-panel / per-workspace ids (`window.panel.Project`, `window.workspace.default`).
if let Some(rest) = id.strip_prefix("window.panel.") {
return frontend(app, ctx, "window.panel", json!({"panel": rest}));
@@ -502,46 +507,6 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
_ => {}
}
}
if id == "window.dockAction" {
let value = params.get("action").cloned().ok_or("missing docking action")?;
let action: craft_ui::docking::Action<PanelKind> = serde_json::from_value(value).map_err(|error| format!("invalid docking action: {error}"))?;
if matches!(&action, craft_ui::docking::Action::Open { .. } | craft_ui::docking::Action::OpenAt { .. }) {
return Err("use the panel open commands to preserve viewer and ScriptUI lifecycle callbacks".into());
}
if let craft_ui::docking::Action::Close { panel } = &action {
let mut layout = crate::dock_shared::encode(&app.ui.dock, &app.ui.floating)?;
layout.apply(action.clone()).map_err(|error| error.to_string())?;
app.close_panel(*panel);
return Ok(Value::Null);
}
let mut error = None;
if !app.edit_layout(|layout| match layout.apply(action) {
Ok(()) => true,
Err(reason) => {
error = Some(reason);
false
}
}) {
return Err(error.unwrap_or_else(|| "invalid docking layout".into()));
}
return Ok(Value::Null);
}
// Multiple monitors: {panel, window?: bool} moves a panel into its own native window (or
// back into the main window; no `window` toggles).
if id == "window.panelWindow" {
// A panel name ("Info", "Effect Controls") or its serialized form ({"Viewer": 2}).
let value = params.get("panel").ok_or("panelWindow: need `panel`")?;
let panel = match value.as_str() {
Some(name) => PanelKind::from_name(name).ok_or(format!("unknown panel `{name}`"))?,
None => serde_json::from_value::<PanelKind>(value.clone()).map_err(|_| "panelWindow: unknown `panel`".to_string())?,
};
let window = match params.get("window") {
None | Some(Value::Null) => None,
Some(value) => Some(value.as_bool().ok_or("panelWindow: `window` must be true or false")?),
};
let window = app.set_panel_window(ctx, panel, window)?;
return Ok(json!({"window": window}));
}
// A UI-only command (not in the engine's menus), so `frontend` gets it from here.
if id == "window.maximizeApp" {
return frontend(app, ctx, id, params);
@@ -560,7 +525,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
}
"closePanel" => {
let p = panel_of("panel").ok_or("closePanel: need `panel`")??;
app.close_panel(p);
if app.ui.dock.is_open(p) {
app.apply_dock(craft_ui::docking::Action::Close { panel: p })?;
}
return Ok(Value::Null);
}
"floatPanel" => {
@@ -569,22 +536,31 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
Some(value) => serde_json::from_value::<[f32; 4]>(value.clone()).map_err(|_| "rect must contain four finite numbers")?,
None => crate::dock_ui::default_float_rect(ctx.content_rect()),
};
if !app.edit_layout(|l| l.float(p, r)) {
return Err(format!("can't undock {}", p.title()));
}
app.apply_dock(craft_ui::docking::Action::Float { panel: p, rect: r }).map_err(|e| format!("can't undock {}: {e}", p.title()))?;
return Ok(Value::Null);
}
_ => {
// craft-ui's `window.panel.dock`: zones center/tab, left/right/top/bottom (a split
// beside the anchor), above/below; without an anchor a side zone docks along that
// edge of the whole workspace.
let p = panel_of("panel").ok_or("dockPanel: need `panel`")??;
let anchor = panel_of("anchor").ok_or("dockPanel: need `anchor` (a docked panel)")??;
let zone = params
.get("zone")
.and_then(Value::as_str)
.map_or(Some(crate::dock::Zone::Center), crate::dock::Zone::from_name)
.ok_or("zone: center|left|right|top|bottom")?;
if !app.edit_layout(|l| l.dock(p, anchor, zone)) {
return Err(format!("can't dock {} at {} ({zone:?})", p.title(), anchor.title()));
let mut command = json!({"command": "window.panel.dock", "panel": p});
if let Some(anchor) = panel_of("anchor") {
command["anchor"] = json!(anchor?);
command["zone"] = json!("center");
}
if let Some(zone) = params.get("zone") {
let zone = zone.as_str().ok_or("zone: center|tab|left|right|top|bottom|above|below")?.to_ascii_lowercase();
if command.get("anchor").is_some() || zone != "center" {
command["zone"] = json!(zone);
}
}
if let Some(before) = panel_of("before") {
command["before"] = json!(before?);
}
let command: craft_ui::docking::DockCommand<PanelKind> =
serde_json::from_value(command).map_err(|e| format!("dockPanel: zone: center|tab|left|right|top|bottom|above|below ({e})"))?;
app.run_dock_command(ctx, command).map_err(|e| format!("can't dock {}: {e}", p.title()))?;
app.show_panel(p);
return Ok(Value::Null);
}
@@ -666,10 +642,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
// Alt+Shift+, / Alt+Shift+.: the previous / next tab of the focused panel's group.
"window.nextPanel" | "window.previousPanel" => {
let focused = app.ui.focused;
let group: Vec<PanelKind> = match app.ui.floating.iter().find(|f| f.panels.contains(&focused)) {
Some(f) => f.panels.clone(),
None => app.ui.dock.group_of(focused).map(<[PanelKind]>::to_vec).unwrap_or_default(),
};
let group = app.ui.dock.group_of(focused);
let Some(i) = group.iter().position(|p| *p == focused) else { return Ok(Value::Null) };
let step = if id == "window.nextPanel" { 1 } else { group.len().saturating_sub(1) };
if let Some(next) = group.get((i + step) % group.len().max(1)).copied() {
@@ -933,6 +906,10 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
ctx.copy_text(path.to_string());
json!({"path": path})
}
"app.checkForUpdates" | "help.checkForUpdates" => {
crate::updates_ui::check(app);
Value::Null
}
"app.about" => {
app.dialog = Some(crate::Dialog::About);
Value::Null
@@ -1174,7 +1151,6 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
"window.saveWorkspace" => {
let name = app.ui.workspace.clone();
app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone());
app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone());
app.store_saved_workspaces()?;
json!({"workspace": name})
}
@@ -1190,7 +1166,6 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
return Ok(Value::Null);
};
app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone());
app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone());
app.ui.workspace = name.clone();
app.store_saved_workspaces()?;
json!({"workspace": name})
@@ -1213,7 +1188,6 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
};
let builtin = crate::dock::WORKSPACES.contains(&name.as_str());
if p.get("delete").and_then(Value::as_bool) == Some(true) {
app.ui.saved_floating.remove(&name);
if app.ui.saved_workspaces.remove(&name).is_none() && !builtin {
return Err(format!("no workspace `{name}`"));
}
@@ -1230,9 +1204,6 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
}
let dock = app.ui.saved_workspaces.remove(&name).ok_or_else(|| format!("no workspace `{name}`"))?;
app.ui.saved_workspaces.insert(new.to_string(), dock);
if let Some(f) = app.ui.saved_floating.remove(&name) {
app.ui.saved_floating.insert(new.to_string(), f);
}
if app.ui.workspace == name {
app.ui.workspace = new.to_string();
}
@@ -1448,6 +1419,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
};
let tx = app.dialog_answers.0.clone();
let answer = crate::DialogAnswer { command: id.to_string(), key, params: params.clone(), paths: Vec::new() };
app.file_dialogs.opened();
spawn(
request,
Box::new(move |paths| {
@@ -1571,10 +1543,10 @@ pub(crate) fn entry_shortcut(app: &EffectcraftApp, e: &MenuEntry) -> Option<Stri
/// Whether panel `p` is on screen: the front tab of a floating window, or of a docked group
/// (an open frame in a stack) that a maximized panel does not cover.
pub(crate) fn panel_shown(app: &EffectcraftApp, p: PanelKind) -> bool {
if app.ui.floating.iter().any(|f| f.panels.get(f.active) == Some(&p)) {
return true;
if app.ui.dock.is_floating(p) {
return app.ui.dock.is_visible(p);
}
match app.ui.maximized.filter(|m| app.ui.dock.contains(*m)) {
match app.ui.maximized.filter(|m| app.ui.dock.is_docked(*m)) {
Some(m) => m == p,
None => app.ui.dock.is_visible(p),
}
@@ -2569,7 +2541,7 @@ mod tests {
run(&mut app, "window.toggleCompTimeline");
assert_eq!(app.ui.focused, PanelKind::Timeline);
// Alt+Shift+. / Alt+Shift+,: the tabs of the focused panel's group, wrapping.
let group = app.ui.dock.group_of(PanelKind::Timeline).unwrap().to_vec();
let group = app.ui.dock.group_of(PanelKind::Timeline);
assert!(group.len() > 1, "{group:?}");
run(&mut app, "window.nextPanel");
assert_eq!(app.ui.focused, group[(group.iter().position(|p| *p == PanelKind::Timeline).unwrap() + 1) % group.len()]);
@@ -2581,7 +2553,7 @@ mod tests {
// With another panel active it closes that panel.
app.show_panel(PanelKind::Project);
run(&mut app, "window.closeAll");
assert!(!app.ui.dock.contains(PanelKind::Project));
assert!(!app.ui.dock.is_open(PanelKind::Project));
}
#[test]
@@ -2703,6 +2675,56 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// While a command's OS file dialog is up, floating panels' OS windows are blocked, as for
/// a modal dialog; a dialog that ends, answered or not, unblocks them, and Save As opens a
/// dialog again. A macOS sheet can end without answering (its window went away): once one of
/// the app's windows has the keyboard again, or keeps it as if no dialog showed, floating
/// windows are not left blocked.
#[test]
fn file_dialogs_block_floating_windows_only_while_up() {
use std::sync::{Arc, Mutex};
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let ctx = egui::Context::default();
let pending: Arc<Mutex<Vec<crate::DialogDone>>> = Arc::default();
let queue = pending.clone();
app.hooks.file_dialog_async = Some(Box::new(move |_dialog, done| queue.lock().unwrap().push(done)));
let blocked = |app: &mut EffectcraftApp, focused: bool, time: f64| {
let input = egui::RawInput { focused, time: Some(time), ..Default::default() };
let mut out = false;
ctx.run_ui(input, |ui| out = app.floats_blocked(ui.ctx())).textures_delta.clear();
out
};
assert!(!blocked(&mut app, true, 0.0));
// Cancelled.
invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
assert!(blocked(&mut app, true, 0.1), "blocked while the dialog takes the keyboard");
assert!(blocked(&mut app, false, 0.2));
let done = pending.lock().unwrap().pop().expect("a dialog was shown");
done(Vec::new());
app.run_dialog_answers();
assert!(!blocked(&mut app, false, 0.3));
// Save As asks again; the sheet ends without answering.
invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
assert_eq!(pending.lock().unwrap().len(), 1, "Save As opens a dialog again");
assert!(blocked(&mut app, true, 1.0));
assert!(blocked(&mut app, false, 1.1));
assert!(blocked(&mut app, false, 9.0), "a dialog may stay up for long");
assert!(!blocked(&mut app, true, 9.1), "an app window has the keyboard again");
// A dialog that never takes the keyboard.
invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
assert!(blocked(&mut app, true, 10.0));
assert!(blocked(&mut app, true, 11.0));
assert!(!blocked(&mut app, true, 12.5));
// Answers of dialogs taken for gone arrive late: nothing stays blocked.
for done in pending.lock().unwrap().drain(..) {
done(Vec::new());
}
app.run_dialog_answers();
assert!(!blocked(&mut app, false, 13.0));
invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
assert!(blocked(&mut app, false, 13.1), "a later dialog blocks again");
}
#[test]
fn tga_is_available_in_import_replace_and_proxy_dialogs() {
use std::sync::{Arc, Mutex};
+86 -18
View File
@@ -126,6 +126,27 @@ impl NativeMenu {
}
}
/// The native menu spec as the menu bar shows it now: [`build`], with the Edit items a text field
/// takes ([`edits_text`]) enabled while one has the keyboard ([`text_field_window`]). Paste
/// otherwise follows the project clipboard and would be greyed out with nothing copied there,
/// while the field can paste.
pub fn build_now(app: &EffectcraftApp, ctx: &egui::Context) -> NativeMenu {
fn enable(nodes: &mut [NativeNode]) {
for node in nodes {
match node {
NativeNode::Item(item) if edits_text(&item.command) => item.enabled = true,
NativeNode::Submenu { children, .. } => enable(children),
_ => {}
}
}
}
let mut menu = build(app);
if text_field_window(ctx).is_some() {
enable(&mut menu.menus);
}
menu
}
/// Build the native menu spec for the app's current state.
pub fn build(app: &EffectcraftApp) -> NativeMenu {
fn rec(app: &EffectcraftApp, nodes: &[MenuNode], path: &str, out: &mut Vec<NativeNode>) {
@@ -332,9 +353,23 @@ pub fn state_key(app: &EffectcraftApp) -> u64 {
h.finish()
}
/// Commands that act on a focused text field instead (Edit menu while typing): the egui event to
/// send in their place.
fn text_field_event(command: &str, clipboard: Option<String>) -> Option<Vec<egui::Event>> {
/// The window whose text field has the keyboard: a floating panel's own OS window, or the main
/// window, where any widget that holds the keyboard counts (the Viewer's text editor is not an
/// egui text field).
pub fn text_field_window(ctx: &egui::Context) -> Option<egui::ViewportId> {
craft_ui::docking::typing_window(ctx).or_else(|| ctx.egui_wants_keyboard_input().then_some(egui::ViewportId::ROOT))
}
/// Whether a focused text field takes the menu command `command` ([`activate`]): the native menu
/// keeps such items enabled while one has the keyboard, whatever the project allows.
pub fn edits_text(command: &str) -> bool {
text_field_event(command).is_some()
}
/// Commands that act on a focused text field instead (Edit menu while typing): the egui events
/// to send in their place. Paste is a Cmd+V press, which the field's window turns into a request
/// for the clipboard text.
fn text_field_event(command: &str) -> Option<Vec<egui::Event>> {
let key = |k: egui::Key, shift: bool| egui::Event::Key {
key: k,
physical_key: None,
@@ -345,7 +380,7 @@ fn text_field_event(command: &str, clipboard: Option<String>) -> Option<Vec<egui
Some(match command {
"edit.copy" => vec![egui::Event::Copy],
"edit.cut" => vec![egui::Event::Cut],
"edit.paste" => vec![egui::Event::Paste(clipboard?)],
"edit.paste" => vec![key(egui::Key::V, false)],
"edit.selectAll" => vec![key(egui::Key::A, false)],
"edit.undo" => vec![key(egui::Key::Z, false)],
"edit.redo" => vec![key(egui::Key::Z, true)],
@@ -353,8 +388,43 @@ fn text_field_event(command: &str, clipboard: Option<String>) -> Option<Vec<egui
})
}
/// Hand a text field's Edit command to the window `window` whose field has the keyboard. The
/// main window gets the clipboard text itself and the events through the synthetic queue; a
/// floating panel's OS window gets each key pressed and released, and asks for the clipboard
/// text on Cmd+V (`craft_ui::docking::send_keys`).
fn send_to_text_field(app: &mut EffectcraftApp, ctx: &egui::Context, window: egui::ViewportId, events: Vec<egui::Event>) {
if window == egui::ViewportId::ROOT {
for event in events {
match event {
egui::Event::Key { key: egui::Key::V, .. } => {
if let Some(text) = app.hooks.clipboard_text.as_ref().and_then(|f| f()) {
app.synthetic.push(egui::Event::Paste(text));
}
}
event => app.synthetic.push(event),
}
}
ctx.request_repaint();
return;
}
let events = events
.into_iter()
.flat_map(|event| {
let release = match &event {
egui::Event::Key { key, physical_key, modifiers, .. } => {
Some(egui::Event::Key { key: *key, physical_key: *physical_key, pressed: false, repeat: false, modifiers: *modifiers })
}
_ => None,
};
std::iter::once(event).chain(release)
})
.collect();
craft_ui::docking::send_keys(ctx, window, events);
}
/// Run a native menu item (by id) the way the in-window menu bar does. While a text field has
/// the keyboard, Edit ▸ Cut/Copy/Paste/Select All/Undo/Redo edit the text instead.
/// the keyboard ([`text_field_window`]), Edit ▸ Cut/Copy/Paste/Select All/Undo/Redo edit the
/// text instead.
pub fn activate(app: &mut EffectcraftApp, ctx: &egui::Context, menu: &NativeMenu, id: &str) -> Result<Value, String> {
let item = menu.find(id).ok_or_else(|| format!("no native menu item `{id}`"))?;
let (command, params) = (item.command.clone(), item.params.clone());
@@ -369,13 +439,12 @@ pub fn activate(app: &mut EffectcraftApp, ctx: &egui::Context, menu: &NativeMenu
ctx.request_repaint();
return Ok(Value::Null);
}
if ctx.egui_wants_keyboard_input() {
let clip = if command == "edit.paste" { app.hooks.clipboard_text.as_ref().and_then(|f| f()) } else { None };
if let Some(events) = text_field_event(&command, clip) {
app.synthetic.extend(events);
ctx.request_repaint();
return Ok(Value::Null);
}
// The menu bar takes the Edit menu's key equivalents before any window sees them: while a
// text field has the keyboard, in the main window or a floating panel's own window, the
// field gets the key and the project command does not run.
if let (Some(window), Some(events)) = (text_field_window(ctx), text_field_event(&command)) {
send_to_text_field(app, ctx, window, events);
return Ok(Value::Null);
}
let params = if params.is_null() { json!({}) } else { params };
let (command, params) = crate::menus::menu_choice(app, command, params);
@@ -605,7 +674,7 @@ mod tests {
assert_ne!(state_key(&app), k0);
// A tab behind another one, a closed panel and panels under a maximized one are not shown.
assert_eq!((checked(&app, "renderQueue"), checked(&app, "timeline")), (Some(true), Some(false)));
app.ui.dock.close(PanelKind::RenderQueue);
app.close_panel(PanelKind::RenderQueue);
assert_eq!((checked(&app, "renderQueue"), checked(&app, "timeline")), (Some(false), Some(true)));
app.ui.maximized = Some(PanelKind::Timeline);
assert_eq!((checked(&app, "timeline"), checked(&app, "project")), (Some(true), Some(false)));
@@ -648,9 +717,9 @@ mod tests {
activate(app, &ctx, &spec, &id).unwrap();
};
choose(&mut app);
assert!(app.ui.dock.contains(PanelKind::Align));
assert!(crate::dock::WorkspaceExt::is_open(&app.ui.dock, PanelKind::Align));
choose(&mut app);
assert!(!app.ui.dock.contains(PanelKind::Align));
assert!(!crate::dock::WorkspaceExt::is_open(&app.ui.dock, PanelKind::Align));
}
#[test]
@@ -663,8 +732,7 @@ mod tests {
activate(&mut app, &ctx, &spec, &id).unwrap();
assert_eq!(app.session.active_comp().unwrap().layers.len(), n + 1);
assert!(activate(&mut app, &ctx, &spec, "m.999").is_err());
assert!(text_field_event("edit.copy", None).is_some());
assert!(text_field_event("edit.paste", None).is_none());
assert!(text_field_event("layer.newNull", None).is_none());
assert!(edits_text("edit.copy") && edits_text("edit.paste"));
assert!(!edits_text("layer.newNull"));
}
}
+37
View File
@@ -170,6 +170,18 @@ pub(crate) fn modal_space(ctx: &egui::Context) -> egui::Vec2 {
(ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0))
}
fn modal_pass_id() -> egui::Id {
egui::Id::unique("effectcraft-modal-pass")
}
/// Whether a modal dialog showed in this pass or the one before: egui's `Modal` blocks input
/// only in its own window, so floating panels in OS windows of their own are blocked by the
/// dock area while this holds. Dialogs draw after the dock, so the previous pass counts.
pub(crate) fn modal_open(ctx: &egui::Context) -> bool {
let shown = ctx.data(|d| d.get_temp::<u64>(modal_pass_id()));
shown.is_some_and(|pass| pass.saturating_add(1) >= ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT))
}
pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
modal_with_scroll(ctx, title, size, t, false, body);
}
@@ -190,6 +202,8 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok
/// only its content: `body` gets the height left under the title, with `size` kept within the
/// window.
pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) {
let pass = ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT);
ctx.data_mut(|d| d.insert_temp(modal_pass_id(), pass));
let size = size.min(modal_space(ctx));
let id = egui::Id::unique(("modal", title));
// Dragging the title row moves the dialog off what it edits. The offset is kept within the
@@ -693,6 +707,29 @@ fn palette(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
mod tests {
use super::*;
#[test]
fn modal_open_holds_from_a_shown_dialog_until_the_pass_after_it_closes() {
let (app, ctx) = app();
let t = app.tokens;
// Asked as the dock asks it: during the pass, before the dialogs draw.
let pass = |show: bool| {
let mut open = false;
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
open = modal_open(ui.ctx());
if show {
modal(ui.ctx(), "Test", vec2(200.0, 100.0), &t, |_| {});
}
});
output.textures_delta.clear();
open
};
assert!(!pass(false));
assert!(!pass(true), "the first pass of a dialog finds none shown yet");
assert!(pass(true));
assert!(pass(false), "the pass after the last one shown still counts");
assert!(!pass(false));
}
fn app() -> (EffectcraftApp, egui::Context) {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
+1 -1
View File
@@ -127,7 +127,7 @@ pub fn format_size(bytes: u64) -> String {
/// The Kind column: what a recent file is, by its extension, in the interface language.
fn kind_of(path: &str) -> &'static str {
tr(match std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
tr(match std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).as_deref() {
Some("ecproj") => "EffectCraft project",
Some("aep" | "aepx") => "After Effects project",
Some("json" | "lottie") => "Lottie animation",
+3 -6
View File
@@ -220,10 +220,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
let id = egui::Id::unique("panel-menu");
let Some((panel, pos)) = ui.ctx().data(|d| d.get_temp::<(PanelKind, egui::Pos2)>(id)) else { return };
// The menu opens in the window that shows the panel (a panel can have its own window).
if app.panel_viewport(ui.ctx(), panel) != ui.ctx().viewport_id() {
return;
}
let mut close = false;
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
@@ -232,13 +228,14 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
let _ = app.dock_command(ui.ctx(), craft_ui::docking::Action::Close { panel });
close = true;
}
let floating = app.ui.floating.iter().any(|f| f.panels.contains(&panel));
let floating = crate::dock::WorkspaceExt::is_floating(&app.ui.dock, panel);
if !floating && ui.button(tr("Undock Panel")).clicked() {
let r = crate::dock_ui::default_float_rect(ui.ctx().content_rect());
let _ = app.dock_command(ui.ctx(), craft_ui::docking::Action::Float { panel, rect: r });
close = true;
}
// Multiple monitors: a native window of its own, which can go on another screen.
// Multiple monitors: an OS window of its own, which can go on another screen. Where
// egui embeds viewports (the web), floating windows stay in the page.
if !ui.ctx().embed_viewports() {
let windowed = app.in_panel_window(panel);
if ui.button(if windowed { tr("Move Panel to Main Window") } else { tr("Move Panel to New Window") }).clicked() {
+2 -7
View File
@@ -15,7 +15,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, UiBuilder, pos2, ve
use serde_json::{Value, json};
use crate::EffectcraftApp;
use crate::dock::PanelKind;
use crate::dock::{PanelKind, WorkspaceExt as _};
use crate::theme::Tokens;
enum Act {
@@ -587,12 +587,7 @@ pub fn panel_title(app: &EffectcraftApp, id: u32) -> Option<String> {
/// Close the dock panels whose script window has gone (the script closed it, or a saved
/// workspace from an earlier run).
pub fn sync_panels(app: &mut EffectcraftApp) {
let mut all = vec![];
app.ui.dock.panels(&mut all);
for f in &app.ui.floating {
all.extend(f.panels.iter().copied());
}
for p in all {
for p in app.ui.dock.panels() {
if let PanelKind::ScriptPanel(id) = p
&& app.session.script_ui.window(id).is_none()
{
+3
View File
@@ -1776,6 +1776,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.rect_filled(handle, 2.0, t.accent);
let resp = ui.interact(hr, egui::Id::unique(("wa", set)), Sense::drag());
app.auto.add(&format!("timeline.workArea.{set}"), hr, set);
if resp.dragged() || resp.hovered() {
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
if resp.dragged()
&& let Some(pt) = resp.interact_pointer_pos()
{
+103 -6
View File
@@ -269,6 +269,13 @@ pub(crate) fn begin_ui(ctx: &egui::Context) {
ctx.data_mut(|data| data.remove::<ImeGeometry>(id));
}
/// Whether the text editor recorded its IME geometry in this pass of the current viewport.
pub(crate) fn ime_recorded(ctx: &egui::Context) -> bool {
let viewport = ctx.viewport_id();
let pass = ctx.cumulative_pass_nr_for(viewport);
ctx.data(|d| d.get_temp::<ImeGeometry>(ime_geometry_id(viewport))).is_some_and(|(p, _, _)| p == pass)
}
/// Close a logic-only tick: when no full UI pass ran, revoke admission and resync and suspend the reset guard so a
/// hidden tick never publishes IME state.
pub(crate) fn finish_logic(app: &EffectcraftApp, ctx: &egui::Context) {
@@ -298,7 +305,7 @@ fn ime_composition_id(viewport: egui::ViewportId) -> egui::Id {
egui::Id::unique(("viewer-text-ime-composition", viewport))
}
/// The `egui::Id` under which the current pass number, text region and caret rectangle are stored for the
/// pass-end finalizer.
/// viewport IME publisher.
fn ime_geometry_id(viewport: egui::ViewportId) -> egui::Id {
egui::Id::unique(("viewer-text-ime-geometry", viewport))
}
@@ -553,11 +560,12 @@ fn visible_preedit(ui: &egui::Ui, app: &EffectcraftApp) -> Option<String> {
(p.owner == owner && !p.text.is_empty()).then_some(p.text)
}
/// Publish the settled descriptor for one completed viewport pass.
/// Publish the descriptor from this viewport's current text state and recorded geometry.
///
/// This runs at the very end of a root pass (after dock actions, ScriptUI, learn and dialogs)
/// and at the end of each panel-window pass after that window's actions. The overlay only
/// records geometry; it never publishes early with a snapshot that a later action can replace.
/// The root viewport publishes after dock actions, ScriptUI, learn and dialogs. Native panel
/// windows publish after the IME-owning body records geometry and again after their last body;
/// publication can therefore precede later bodies in that window. The overlay only records
/// geometry; this function resolves the current owner and availability when it publishes.
pub(crate) fn finish_ime(app: &mut EffectcraftApp, ui: &egui::Ui) {
let ctx = ui.ctx();
let viewport = ctx.viewport_id();
@@ -1407,7 +1415,7 @@ fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, ma
let rect = Rect::from_points(&quad);
app.auto.add("viewer.textEdit", rect, &format!("Editing {}", e.layer.name));
let cursor_rect = Rect::from_two_pos(sa, sb).expand2(vec2(1.0, 0.0));
// The outgoing IME descriptor is published by the pass-end finalizer, with this actual
// The viewport IME publisher uses this actual
// caret geometry and the edited layer's committed `TextDoc` (never the bidi or pre-edit
// display text). An available-empty document stays distinct from unavailable.
let viewport = ui.ctx().viewport_id();
@@ -1564,6 +1572,95 @@ mod tests {
}
}
/// One native floating window holding the Timeline's group and then the Viewer's.
fn two_group_window() -> craft_ui::docking::Floating<PanelKind> {
let groups = vec![craft_ui::docking::Group::new(vec![PanelKind::Timeline], 0), craft_ui::docking::Group::new(vec![PanelKind::Viewer(1)], 0)];
craft_ui::docking::Floating::stack(groups, [0.0, 0.0, 320.0, 360.0]).expect("two groups make a window")
}
impl ImePass {
/// One pass of a native window with a body that edits no text and the Viewer's editor,
/// in the order given, publishing as `NativeIme` decides or, with `every`, after each
/// body. Returns the descriptor and the preedit the pass ended with.
fn window(
&mut self,
app: &mut EffectcraftApp,
window: &craft_ui::docking::Floating<PanelKind>,
events: Vec<egui::Event>,
viewer_first: bool,
every: bool,
) -> (Option<egui::output::IMEOutput>, Option<String>) {
let ctx = self.ctx.clone();
let (rect, viewport) = (self.rect, self.viewport);
let mut viewports = egui::ViewportIdMap::default();
viewports.insert(viewport, egui::ViewportInfo::default());
let raw = egui::RawInput { viewport_id: viewport, viewports, screen_rect: Some(rect), events, ..Default::default() };
let mut preedit = None;
let mut out = ctx.run_ui(raw, |ui| {
let mut ime = crate::dock_ui::NativeIme::new(std::slice::from_ref(window), &[]);
let order = if viewer_first { [true, false] } else { [false, true] };
for viewer in order {
if ime.begin_body(viewport) {
begin_ui(ui.ctx());
}
if viewer {
ui.interact(rect, focus_id(), egui::Sense::focusable_noninteractive());
ui.memory_mut(|m| m.request_focus(focus_id()));
sync_ime_target(ui, app);
keyboard(app, ui, rect);
let pass = ui.ctx().cumulative_pass_nr_for(viewport);
ui.data_mut(|d| d.insert_temp(ime_geometry_id(viewport), (pass, rect, rect)));
}
if ime.publishes(viewport, ime_recorded(ui.ctx())) || every {
finish_ime(app, ui);
}
}
assert!(ime.unpublished().is_empty(), "the window published");
preedit = read_preedit(ui).map(|p| p.text);
});
out.textures_delta.clear();
(out.platform_output.ime, preedit)
}
}
#[test]
fn native_window_with_a_group_before_the_viewer_keeps_the_composition() {
let window = two_group_window();
let viewport = craft_ui::docking::DockArea::<PanelKind>::window_viewport(crate::dock_ui::area_id(), window.id);
let preview = || egui::Event::Ime(egui::ImeEvent::Preedit { text: "é".into(), active_range_chars: None });
for viewer_first in [false, true] {
let (mut app, _) = editor("abcd", 4, 4);
let mut pass = ImePass::new(viewport);
let (first, preedit) = pass.window(&mut app, &window, vec![preview()], viewer_first, false);
let first = first.expect("the editor publishes");
assert_eq!(preedit.as_deref(), Some("é"));
for _ in 0..2 {
let (ime, preedit) = pass.window(&mut app, &window, vec![], viewer_first, false);
let ime = ime.expect("the editor keeps publishing");
assert!(!ime.should_interrupt_composition, "viewer first: {viewer_first}");
assert_eq!((ime.owner, ime.context_generation), (first.owner, first.context_generation), "the same IME context");
assert_eq!(preedit.as_deref(), Some("é"), "the preedit survives the other body");
}
}
// Publishing after the body drawn before the Viewer clears the preedit and interrupts.
let (mut app, _) = editor("abcd", 4, 4);
let mut pass = ImePass::new(viewport);
pass.window(&mut app, &window, vec![preview()], false, true);
let (ime, preedit) = pass.window(&mut app, &window, vec![], false, true);
assert!(ime.expect("the editor publishes").should_interrupt_composition);
assert_eq!(preedit, None);
// A window that drew fewer bodies than its layout counts publishes after every body in
// its next pass.
let mut ime = crate::dock_ui::NativeIme::new(std::slice::from_ref(&window), &[]);
ime.begin_body(viewport);
assert!(!ime.publishes(viewport, false));
let unpublished = ime.unpublished();
assert_eq!(unpublished, vec![viewport]);
let mut ime = crate::dock_ui::NativeIme::new(std::slice::from_ref(&window), &unpublished);
ime.begin_body(viewport);
assert!(ime.publishes(viewport, false));
}
fn ime_text(ime: &egui::output::IMEOutput) -> (&str, usize, usize) {
let p = ime.surrounding_text.available().expect("committed surrounding text");
(p.text.as_str(), p.cursor, p.anchor)
+5 -5
View File
@@ -14,7 +14,7 @@ use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use serde_json::json;
use crate::EffectcraftApp;
use crate::dock::PanelKind;
use crate::dock::{PanelKind, WorkspaceExt as _};
use crate::frames::{FrameImage, FrameKey};
use crate::theme::Tokens;
@@ -59,7 +59,7 @@ pub fn locked(app: &EffectcraftApp, id: u32) -> bool {
/// Viewer `id`'s panel is docked or floating.
fn open(app: &EffectcraftApp, id: u32) -> bool {
let p = panel(id);
app.ui.dock.contains(p) || app.ui.floating.iter().any(|f| f.panels.contains(&p))
app.ui.dock.is_open(p)
}
/// The comp viewer `id` shows: the active comp in the active viewer, else its own.
@@ -241,7 +241,7 @@ pub fn on_open_comp(app: &mut EffectcraftApp, comp: ItemId) {
// The comp's viewer and its Timeline tab come to the front (unless locked), reopened if they
// were closed (as in After Effects).
for p in [active_panel(app), PanelKind::Timeline] {
let closed = !app.ui.dock.contains(p) && !app.ui.floating.iter().any(|f| f.panels.contains(&p));
let closed = !app.ui.dock.is_open(p);
if closed || !app.ui.locked_tabs.contains(&p.id()) {
app.raise_panel(p);
}
@@ -267,8 +267,8 @@ fn add_viewer(app: &mut EffectcraftApp, near: u32) -> u32 {
let id = app.ui.viewers.keys().copied().max().unwrap_or(0).saturating_add(1).max(1);
app.ui.viewers.insert(id, None);
let p = panel(id);
let anchor = if app.ui.dock.contains(panel(near)) { panel(near) } else { PanelKind::Composition };
app.ui.dock.open_near(p, anchor);
let anchor = [panel(near), PanelKind::Composition].into_iter().find(|q| app.ui.dock.is_open(*q));
let _ = app.ui.dock.apply(craft_ui::docking::Action::Open { panel: p, anchor }, crate::dock::permissions);
id
}
+12 -17
View File
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::dock::{DockNode, PanelKind};
use crate::dock::PanelKind;
use crate::icons::Icon;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -568,23 +568,20 @@ pub struct UiState {
/// Tool shown in each toolbar slot.
pub slot_tools: Vec<Tool>,
pub workspace: String,
pub dock: DockNode,
/// The current layout: docked frames, floating windows (one can be recorded as its own
/// native window, Move Panel to New Window) and the places of closed panels.
pub dock: crate::dock::Workspace,
/// Saved workspace layouts (Save Changes to this Workspace / Save as New Workspace).
#[serde(default)]
pub saved_workspaces: std::collections::BTreeMap<String, DockNode>,
/// Undocked (floating) panel groups.
#[serde(default)]
pub floating: Vec<crate::dock::Floating>,
/// Floating panels of the saved workspaces.
#[serde(default)]
pub saved_floating: std::collections::BTreeMap<String, Vec<crate::dock::Floating>>,
/// Floating groups shown in their own native window instead of over the dock (Move Panel
/// to New Window), so they can be placed on another monitor.
#[serde(default)]
pub panel_windows: Vec<crate::dock::PanelWindow>,
pub saved_workspaces: std::collections::BTreeMap<String, crate::dock::Workspace>,
/// The panel maximized to fill the dock area (`~`), if any.
#[serde(default)]
pub maximized: Option<PanelKind>,
/// Floating windows of [`Self::dock`] whose native rect holds the OS window's outer
/// position, from a workspace file of an earlier version (see
/// `crate::dock_ui::native_outer_to_content`). Workspaces are not saved while any remain.
#[serde(skip)]
pub native_outer: Vec<u64>,
/// Locked Composition / Timeline tabs (panel ids): opening another composition does not
/// bring a locked panel forward (the tab's lock icon, as After Effects' viewer lock).
#[serde(default)]
@@ -700,11 +697,9 @@ impl Default for UiState {
script_console: Default::default(),
slot_tools: Tool::SLOTS.iter().map(|s| s[0]).collect(),
workspace: "Default".into(),
dock: crate::dock::workspace("Default"),
dock: crate::dock::Workspace::new(crate::dock::workspace("Default")),
saved_workspaces: Default::default(),
floating: vec![],
saved_floating: Default::default(),
panel_windows: vec![],
native_outer: Vec::new(),
maximized: None,
locked_tabs: BTreeSet::new(),
viewers: Default::default(),
+125
View File
@@ -0,0 +1,125 @@
//! Software updates, background check at startup, and startup prompt modal.
use crate::EffectcraftApp;
#[cfg(not(target_arch = "wasm32"))]
use craft_update::updater::{Action, Status, Updater};
pub(crate) struct Runtime {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater,
/// The desktop app turns the check at start on; tests and the web build leave it off.
#[cfg(not(target_arch = "wasm32"))]
startup_check: bool,
#[cfg(not(target_arch = "wasm32"))]
check_requested: bool,
pub(crate) manual_window_open: bool,
pub(crate) status_message: Option<String>,
}
impl Default for Runtime {
fn default() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater::new(&craft_update::suite::EFFECTCRAFT, env!("CARGO_PKG_VERSION")),
#[cfg(not(target_arch = "wasm32"))]
startup_check: false,
#[cfg(not(target_arch = "wasm32"))]
check_requested: false,
manual_window_open: false,
status_message: None,
}
}
}
impl EffectcraftApp {
/// Whether the app asks for a newer release when it starts (off until the desktop app turns it on).
#[cfg(not(target_arch = "wasm32"))]
pub fn set_startup_update_check(&mut self, on: bool) {
self.updates_rt.startup_check = on;
}
}
pub(crate) fn check(app: &mut EffectcraftApp) {
#[cfg(not(target_arch = "wasm32"))]
{
app.updates_rt.check_requested = true;
}
#[cfg(target_arch = "wasm32")]
{
app.updates_rt.manual_window_open = true;
app.updates_rt.status_message = Some(crate::i18n::tr("Update check is not supported in the web browser.").to_string());
}
}
pub(crate) fn tick(app: &mut EffectcraftApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
let rt = &mut app.updates_rt;
if std::mem::take(&mut rt.check_requested) {
rt.updater.check_now(ctx);
} else if rt.startup_check {
rt.updater.check_at_start(ctx);
}
rt.updater.tick(ctx);
}
#[cfg(target_arch = "wasm32")]
let _ = (app, ctx);
}
pub(crate) fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
let clean = !app.session.is_dirty();
match app.updates_rt.updater.show_if(ctx, clean) {
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
Action::None => {}
}
// The window answers Help ▸ Check for Updates, so it follows the updater's status.
let rt = &mut app.updates_rt;
(rt.manual_window_open, rt.status_message) = match rt.updater.status() {
Status::Idle => (false, None),
Status::Checking => (true, None),
Status::UpToDate => (
true,
Some(crate::i18n::tr("EffectCraft {version} is currently the newest version available.").replace("{version}", env!("CARGO_PKG_VERSION"))),
),
Status::Failed(e) => (true, Some(crate::i18n::tr("Could not check for updates:\n{error}").replace("{error}", e))),
};
}
if app.updates_rt.manual_window_open {
let mut open = true;
let mut close = false;
egui::Window::new(crate::i18n::tr("Check for Updates"))
.open(&mut open)
.resizable(false)
.collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
.show(ctx, |ui| {
if let Some(msg) = &app.updates_rt.status_message {
ui.label(msg);
} else {
ui.label(crate::i18n::tr("Checking for updates…"));
}
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(crate::i18n::tr("Close")).clicked() {
close = true;
}
});
});
if !open || close {
app.updates_rt.manual_window_open = false;
app.updates_rt.status_message = None;
}
}
// Closing the window dismisses the outcome it showed.
#[cfg(not(target_arch = "wasm32"))]
if !app.updates_rt.manual_window_open {
app.updates_rt.updater.dismiss_status();
}
}
+258 -167
View File
@@ -5,93 +5,72 @@ use std::collections::BTreeMap;
use effectcraft_engine::config::ConfigStore;
use serde::{Deserialize, Serialize};
use crate::dock::{DockNode, Floating, PanelKind, PanelWindow, SplitSize};
use crate::dock::{PanelKind, Workspace};
use crate::state::UiState;
mod legacy;
const FILE: &str = "workspaces.json";
/// An earlier version's file, kept when it is first rewritten in the current format.
const BACKUP: &str = "workspaces.old.json";
const MAX_BYTES: usize = 1024 * 1024;
const MAX_WORKSPACES: usize = 64;
const MAX_NODES: usize = 4096;
const MAX_DEPTH: usize = 32;
const MAX_PANELS: usize = 128;
/// Leave room for new viewers without approaching the u32 allocator limit.
const MAX_VIEWER_ID: u32 = 65_535;
const MAX_COORD: f32 = 1_000_000.0;
/// The snapshot version: 2 holds craft-ui docking workspaces.
const VERSION: u32 = 2;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct Snapshot {
version: u32,
workspace: String,
dock: DockNode,
floating: Vec<Floating>,
dock: Workspace,
focused: PanelKind,
maximized: Option<PanelKind>,
saved_workspaces: BTreeMap<String, DockNode>,
saved_floating: BTreeMap<String, Vec<Floating>>,
/// Floating groups in their own native window (absent from files of earlier versions).
#[serde(default)]
panel_windows: Vec<PanelWindow>,
saved_workspaces: BTreeMap<String, Workspace>,
}
impl Snapshot {
fn capture(ui: &UiState) -> Self {
Self {
version: 1,
version: VERSION,
workspace: ui.workspace.clone(),
dock: ui.dock.clone(),
floating: ui.floating.clone(),
focused: ui.focused,
maximized: ui.maximized,
saved_workspaces: ui.saved_workspaces.clone(),
saved_floating: ui.saved_floating.clone(),
panel_windows: ui.panel_windows.clone(),
}
}
fn apply(self, ui: &mut UiState) {
ui.workspace = self.workspace;
ui.dock = self.dock;
ui.floating = self.floating;
ui.focused = self.focused;
ui.maximized = self.maximized;
ui.saved_workspaces = self.saved_workspaces;
ui.saved_floating = self.saved_floating;
// A window is only kept for a panel that still floats, and only where a window can be.
let floating = |panel: PanelKind| ui.floating.iter().any(|group| group.panels.contains(&panel));
ui.panel_windows = self
.panel_windows
.into_iter()
.filter(|window| floating(window.panel))
.map(|window| PanelWindow { panel: window.panel, rect: PanelWindow::sanitized(window.rect) })
.collect();
// Reserve restored viewer slots without restoring any project-specific composition
// links. New Viewer allocates from this map, including after switching named layouts.
let mut panels = Vec::new();
for root in std::iter::once(&ui.dock).chain(ui.saved_workspaces.values()) {
root.panels(&mut panels);
}
for groups in std::iter::once(&ui.floating).chain(ui.saved_floating.values()) {
for group in groups {
panels.extend(group.panels.iter().copied());
// A native window's place is made safe to hand to the windowing system.
for workspace in std::iter::once(&mut ui.dock).chain(ui.saved_workspaces.values_mut()) {
for window in &mut workspace.layout.floating {
window.native = window.native.map(crate::dock_ui::sanitized_window_rect);
}
}
for panel in panels {
if let PanelKind::Viewer(id) = panel {
ui.viewers.entry(id).or_insert(None);
// Reserve restored viewer slots without restoring any project-specific composition
// links. New Viewer allocates from this map, including after switching named layouts.
for workspace in std::iter::once(&ui.dock).chain(ui.saved_workspaces.values()) {
for panel in crate::dock::referenced_panels(workspace) {
if let PanelKind::Viewer(id) = panel {
ui.viewers.entry(id).or_insert(None);
}
}
}
}
fn validate(&self) -> Result<(), String> {
if self.version != 1 {
if self.version != VERSION {
return Err(format!("unsupported workspace version {}", self.version));
}
let name_ok = |name: &str| !name.trim().is_empty() && name.len() <= 256;
if !name_ok(&self.workspace)
|| self.saved_workspaces.len() > MAX_WORKSPACES
|| self.saved_floating.len() > MAX_WORKSPACES
|| self.saved_workspaces.keys().chain(self.saved_floating.keys()).any(|name| !name_ok(name))
{
if !name_ok(&self.workspace) || self.saved_workspaces.len() > MAX_WORKSPACES || self.saved_workspaces.keys().any(|name| !name_ok(name)) {
return Err("workspace names or count exceed supported limits".into());
}
if !crate::dock::WORKSPACES.contains(&self.workspace.as_str()) && !self.saved_workspaces.contains_key(&self.workspace) {
@@ -101,79 +80,16 @@ impl Snapshot {
PanelKind::Viewer(id) => (1..=MAX_VIEWER_ID).contains(&id),
_ => true,
};
if !panel_ok(self.focused) || self.maximized.is_some_and(|panel| !panel_ok(panel)) || self.panel_windows.iter().any(|w| !panel_ok(w.panel)) {
if !panel_ok(self.focused) || self.maximized.is_some_and(|panel| !panel_ok(panel)) {
return Err("workspace viewer ID is outside the supported range 1–65535".into());
}
let mut nodes = 0usize;
for root in std::iter::once(&self.dock).chain(self.saved_workspaces.values()) {
let mut pending = vec![(root, 0usize)];
while let Some((node, depth)) = pending.pop() {
nodes = nodes.saturating_add(1);
if depth > MAX_DEPTH || nodes > MAX_NODES {
return Err("workspace dock tree exceeds supported limits".into());
}
match node {
DockNode::Split { size, a, b, .. } => {
let valid = match *size {
SplitSize::Ratio(v) => v.is_finite() && (0.0..=1.0).contains(&v),
SplitSize::FixedA(v) | SplitSize::FixedB(v) => v.is_finite() && (0.0..=MAX_COORD).contains(&v),
};
if !valid {
return Err("invalid workspace split size".into());
}
pending.push((a, depth.saturating_add(1)));
pending.push((b, depth.saturating_add(1)));
}
DockNode::Tabs { panels, active } => {
// Closing the last docked panel leaves an empty group with active=0.
if panels.len() > MAX_PANELS || *active >= panels.len().max(1) || panels.iter().any(|panel| !panel_ok(*panel)) {
return Err("invalid workspace tab group".into());
}
}
DockNode::Stack { entries } => {
if entries.len() > MAX_PANELS
|| entries.iter().any(|e| {
e.panels.is_empty()
|| e.panels.len() > MAX_PANELS
|| e.active >= e.panels.len()
|| e.panels.iter().any(|p| !panel_ok(*p))
|| e.height.is_some_and(|v| !v.is_finite() || !(0.0..=MAX_COORD).contains(&v))
})
{
return Err("invalid workspace panel stack".into());
}
}
}
for workspace in std::iter::once(&self.dock).chain(self.saved_workspaces.values()) {
// craft-ui checks the trees, selections, sizes, rects and limits.
workspace.validate().map_err(|error| error.to_string())?;
if crate::dock::referenced_panels(workspace).into_iter().any(|panel| !panel_ok(panel)) {
return Err("workspace viewer ID is outside the supported range 1–65535".into());
}
}
if self.panel_windows.len() > MAX_PANELS || self.panel_windows.iter().any(|w| !w.rect.iter().all(|v| v.is_finite())) {
return Err("invalid workspace panel windows".into());
}
for groups in std::iter::once(&self.floating).chain(self.saved_floating.values()) {
if groups.len() > MAX_PANELS {
return Err("too many floating workspace groups".into());
}
for group in groups {
let [x, y, width, height] = group.rect;
if group.panels.is_empty()
|| group.panels.len() > MAX_PANELS
|| group.panels.iter().any(|panel| !panel_ok(*panel))
|| group.active >= group.panels.len()
|| !group.rect.iter().all(|v| v.is_finite())
|| x.abs() > MAX_COORD
|| y.abs() > MAX_COORD
|| !(1.0..=MAX_COORD).contains(&width)
|| !(1.0..=MAX_COORD).contains(&height)
{
return Err("invalid floating workspace group".into());
}
}
}
crate::dock_shared::encode(&self.dock, &self.floating)?.validate().map_err(|error| error.to_string())?;
for (name, root) in &self.saved_workspaces {
let floating = self.saved_floating.get(name).map(Vec::as_slice).unwrap_or(&[]);
crate::dock_shared::encode(root, floating)?.validate().map_err(|error| error.to_string())?;
}
Ok(())
}
}
@@ -182,24 +98,26 @@ pub(crate) struct WorkspaceStore {
saved: Snapshot,
last_attempt: f64,
writable: bool,
/// The text of an earlier version's file that loaded: the first save copies it to
/// [`BACKUP`] and writes the file in the current format.
migrated_from: Option<String>,
pub(crate) error: Option<String>,
}
impl WorkspaceStore {
pub(crate) fn load(config: Option<&dyn ConfigStore>, ui: &mut UiState) -> Self {
let mut original = None;
let mut outer = Vec::new();
let loaded = config.map_or(Ok(None), |c| c.read_bounded(FILE, MAX_BYTES)).map_err(|e| e.to_string()).and_then(|text| {
text.map(|text| {
let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
// Only an explicitly legacy-shaped record can migrate. A versioned future
// snapshot must never deserialize as an empty defaulted legacy record.
let snapshot = if value.get("version").is_none()
&& value.as_object().is_some_and(|fields| fields.contains_key("workspaces") || fields.contains_key("floating"))
{
let legacy: crate::SavedWorkspaces = serde_json::from_value(value).map_err(|e| e.to_string())?;
let mut snapshot = Snapshot::capture(ui);
snapshot.saved_workspaces = legacy.workspaces;
snapshot.saved_floating = legacy.floating;
let snapshot = if legacy::is_legacy(&value) {
let (snapshot, windows) = legacy::migrate(value, ui)?;
original = Some(text);
outer = windows;
snapshot
} else if value.get("version").is_none() {
return Err("not a workspace file".to_string());
} else {
serde_json::from_value::<Snapshot>(value).map_err(|e| e.to_string())?
};
@@ -211,6 +129,7 @@ impl WorkspaceStore {
let error = match loaded {
Ok(Some(snapshot)) => {
snapshot.apply(ui);
ui.native_outer = outer;
None
}
Ok(None) => None,
@@ -219,10 +138,11 @@ impl WorkspaceStore {
if let Some(error) = &error {
log::warn!("{error}");
ui.status = error.clone();
original = None;
}
// An unreadable or newer file is never replaced by this session, even after a later
// layout edit. Ordinary failed writes remain dirty and retryable.
Self { saved: Snapshot::capture(ui), last_attempt: f64::NEG_INFINITY, writable: error.is_none(), error }
Self { saved: Snapshot::capture(ui), last_attempt: f64::NEG_INFINITY, writable: error.is_none(), migrated_from: original, error }
}
pub(crate) fn due(&mut self, now: f64) -> bool {
@@ -237,19 +157,18 @@ impl WorkspaceStore {
if !self.writable {
return false;
}
self.saved.workspace != ui.workspace
self.migrated_from.is_some()
|| self.saved.workspace != ui.workspace
|| self.saved.dock != ui.dock
|| self.saved.floating != ui.floating
|| self.saved.focused != ui.focused
|| self.saved.maximized != ui.maximized
|| self.saved.saved_workspaces != ui.saved_workspaces
|| self.saved.saved_floating != ui.saved_floating
|| self.saved.panel_windows != ui.panel_windows
}
pub(crate) fn save(&mut self, config: Option<&dyn ConfigStore>, ui: &UiState) -> Result<bool, String> {
let Some(config) = config else { return Ok(false) };
if !self.has_changes(ui) {
// A window still at its outer position would be saved as if that were its content's.
if !self.has_changes(ui) || !ui.native_outer.is_empty() {
return Ok(false);
}
let snapshot = Snapshot::capture(ui);
@@ -258,7 +177,11 @@ impl WorkspaceStore {
if text.len() > MAX_BYTES {
return Err("workspace file exceeds 1 MiB".into());
}
if let Some(original) = &self.migrated_from {
config.write(BACKUP, original).map_err(|e| format!("keep the earlier workspace file: {e}"))?;
}
config.write(FILE, &text).map_err(|e| format!("save workspaces: {e}"))?;
self.migrated_from = None;
self.saved = snapshot;
self.error = None;
Ok(true)
@@ -281,26 +204,173 @@ impl WorkspaceStore {
#[cfg(test)]
mod tests {
use super::*;
use crate::dock::{Floating, Layout, Node, StackEntry, WorkspaceExt as _};
use craft_ui::docking::Action;
use craft_ui::layout::{SplitAxis, SplitSize};
use effectcraft_engine::config::MemoryConfig;
fn minimal() -> Workspace {
Workspace::new(crate::dock::workspace("Minimal"))
}
fn tabs(panels: &[PanelKind]) -> Node<PanelKind> {
Node::Tabs { panels: panels.to_vec(), active: 0 }
}
#[test]
fn corrupt_or_future_workspace_keeps_defaults_and_original_file() {
fn corrupt_future_or_unknown_workspace_keeps_defaults_and_original_file() {
let valid = Snapshot::capture(&UiState::default());
for text in ["{".into(), serde_json::to_string(&Snapshot { version: 2, ..valid }).unwrap(), " ".repeat(MAX_BYTES + 1)] {
// Damaged files of the earlier version: a panel in two places, a bad split, an unknown
// panel, a tree deeper than craft-ui allows, a missing field.
let earlier = |dock: &str| {
format!(
r#"{{"version":1,"workspace":"Default","dock":{dock},"floating":[],"focused":"Composition","maximized":null,"saved_workspaces":{{}},"saved_floating":{{}}}}"#
)
};
let mut deep = r#"{"Tabs":{"panels":["Composition"],"active":0}}"#.to_string();
for _ in 0..=craft_ui::docking::MAX_DEPTH {
deep = format!(r#"{{"Split":{{"vertical":true,"size":{{"Ratio":0.5}},"a":{deep},"b":{{"Tabs":{{"panels":[],"active":0}}}}}}}}"#);
}
let twice =
r#"{"Split":{"vertical":true,"size":{"Ratio":0.5},"a":{"Tabs":{"panels":["Project"],"active":0}},"b":{"Tabs":{"panels":["Project"],"active":0}}}}"#;
let bad_split = r#"{"Split":{"vertical":true,"size":{"Ratio":7.0},"a":{"Tabs":{"panels":["Project"],"active":0}},"b":{"Tabs":{"panels":["Timeline"],"active":0}}}}"#;
for text in [
"{".into(),
"{}".into(),
"[1, 2]".into(),
serde_json::to_string(&Snapshot { version: VERSION + 1, ..valid }).unwrap(),
" ".repeat(MAX_BYTES + 1),
earlier(twice),
earlier(bad_split),
earlier(r#"{"Tabs":{"panels":["NoSuchPanel"],"active":0}}"#),
earlier(&deep),
r#"{"version":1,"workspace":"Default"}"#.into(),
r#"{"workspaces":{"Mine":{"Tabs":{"panels":["Composition"],"active":3}}}}"#.into(),
] {
let config = MemoryConfig::default();
config.write(FILE, &text).unwrap();
let mut ui = UiState::default();
let mut store = WorkspaceStore::load(Some(&config), &mut ui);
assert!(store.error.is_some());
assert_eq!(ui.workspace, "Default");
assert_eq!(ui.dock, UiState::default().dock);
ui.focused = PanelKind::Project;
ui.dock = crate::dock::workspace("Minimal");
ui.dock = minimal();
assert!(!store.save(Some(&config), &ui).unwrap());
assert!(store.save_now(Some(&config), &ui).unwrap_err().contains("couldn't be loaded"));
assert_eq!(config.read(FILE).as_deref(), Some(text.as_str()));
assert_eq!(config.read(BACKUP), None);
}
}
/// A file written by EffectCraft 0.10 (craft-libs 5c874d51): the Default workspace with
/// Preview floating, Render Queue floating in its own native window, Align closed,
/// Effects & Presets moved into the Project group and Audio split beside the Timeline,
/// saved as "My Layout"; then Layer closed.
const V1: &str = include_str!("../tests/fixtures/workspaces-v1.json");
#[test]
fn version_one_file_loads_with_every_panel_in_place() {
let config = MemoryConfig::default();
config.write(FILE, V1).unwrap();
let mut ui = UiState::default();
let store = WorkspaceStore::load(Some(&config), &mut ui);
assert_eq!(store.error, None);
assert_eq!(ui.workspace, "My Layout");
assert_eq!(ui.focused, PanelKind::Composition);
use PanelKind::*;
let root = crate::dock::hsplit(
SplitSize::FixedSecond(300.0),
crate::dock::vsplit(
SplitSize::Ratio(0.56),
crate::dock::hsplit(SplitSize::FixedFirst(300.0), crate::dock::tabs(&[Project, EffectControls, EffectsPresets], 2), tabs(&[Composition])),
crate::dock::hsplit(SplitSize::Ratio(0.5), tabs(&[Timeline]), tabs(&[Audio])),
),
tabs(&[Properties]),
);
assert_eq!(ui.dock.layout.root, Some(root));
let floating: Vec<_> = ui.dock.layout.floating.iter().map(|w| (w.panels().copied().collect::<Vec<_>>(), w.rect, w.native)).collect();
assert_eq!(
floating,
[
(vec![Preview], [900.0, 120.0, 300.0, 260.0], None),
(vec![RenderQueue], [200.0, 500.0, 520.0, 240.0], Some(crate::dock_ui::sanitized_window_rect([200.0, 500.0, 520.0, 240.0]))),
]
);
// Closed panels stay closed.
for panel in [Align, Layer] {
assert!(!ui.dock.is_open(panel), "{panel:?}");
}
// The native window's place is its outer position until the main window reports its
// frame (here a 28-point title bar); the file is not rewritten before.
let id = ui.dock.layout.floating[1].id;
assert_eq!(ui.native_outer, [id]);
let mut store = store;
assert!(!store.save(Some(&config), &ui).unwrap());
let outer = egui::Rect::from_min_size(egui::pos2(10.0, 20.0), egui::vec2(1600.0, 1000.0));
crate::dock_ui::native_outer_to_content(&mut ui, Some(outer), Some(outer.translate(egui::vec2(0.0, 28.0))), 0.1);
assert_eq!(ui.dock.layout.floating[1].native, Some([200.0, 528.0, 520.0, 240.0]));
assert!(ui.native_outer.is_empty());
assert!(store.save(Some(&config), &ui).unwrap());
// The named workspace still has Layer, closed only after it was saved.
let saved = ui.saved_workspaces.get("My Layout").unwrap();
assert!(saved.is_docked(Layer) && saved.is_floating(Preview) && !saved.is_open(Align));
assert_eq!(saved.layout.floating.iter().filter_map(|w| w.native).count(), 0);
}
#[test]
fn version_one_file_is_rewritten_once_in_the_current_format_and_kept_as_a_backup() {
let config = MemoryConfig::default();
config.write(FILE, V1).unwrap();
let mut ui = UiState::default();
let mut store = WorkspaceStore::load(Some(&config), &mut ui);
// A backend that reports no window positions: the native place stays as it was.
let native = ui.dock.layout.floating[1].native;
crate::dock_ui::native_outer_to_content(&mut ui, None, None, 1.0);
assert!(!ui.native_outer.is_empty(), "waits for a report");
crate::dock_ui::native_outer_to_content(&mut ui, None, None, 5.0);
assert!(ui.native_outer.is_empty());
assert_eq!(ui.dock.layout.floating[1].native, native);
let migrated = Snapshot::capture(&ui);
assert!(store.has_changes(&ui), "the migrated layout is written without waiting for an edit");
assert!(store.save(Some(&config), &ui).unwrap());
assert_eq!(config.read(BACKUP).as_deref(), Some(V1));
let text = config.read(FILE).unwrap();
assert_eq!(serde_json::from_str::<serde_json::Value>(&text).unwrap()["version"], VERSION);
assert!(!store.has_changes(&ui));
let mut restored = UiState::default();
let reloaded = WorkspaceStore::load(Some(&config), &mut restored);
assert_eq!(reloaded.error, None);
assert!(!reloaded.has_changes(&restored));
assert_eq!(Snapshot::capture(&restored), migrated);
}
#[test]
fn stacked_frames_with_tabs_and_an_empty_root_migrate() {
let text = r#"{"version":1,"workspace":"Mine","dock":{"Tabs":{"panels":[],"active":0}},
"floating":[{"panels":["Project"],"active":0,"rect":[10.0,10.0,300.0,200.0]}],
"focused":"Project","maximized":null,
"saved_workspaces":{"Mine":{"Stack":{"entries":[{"panel":"Info","open":true},{"panels":["Audio","Preview"],"active":1,"open":true,"height":80.0}]}},
"Column":{"Stack":{"entries":[{"panel":"Align","open":false},{"panel":"Preview","open":true,"height":46.0},{"panels":["Properties","Paint"],"active":1,"open":false,"height":300.0},{"panel":"Info","open":true}]}}},
"saved_floating":{"Gone":[{"panels":["Align"],"active":0,"rect":[0.0,0.0,100.0,100.0]}]}}"#;
let config = MemoryConfig::default();
config.write(FILE, text).unwrap();
let mut ui = UiState::default();
let store = WorkspaceStore::load(Some(&config), &mut ui);
assert_eq!(store.error, None);
assert_eq!(ui.dock.layout.root, None);
assert!(ui.dock.is_floating(PanelKind::Project));
use PanelKind::*;
// Each open frame is a tab group with all its tabs; a frame's height is a fixed size
// (with its tab strip), and the frame without one takes the rest.
let column = crate::dock::vsplit(SplitSize::FixedSecond(80.0 + crate::dock::TAB_STRIP), tabs(&[Info]), crate::dock::tabs(&[Audio, Preview], 1));
assert_eq!(ui.saved_workspaces.get("Mine").and_then(|w| w.layout.root.clone()), Some(column));
// Closed frames' tabs join the open frame above them, or below at the top.
let column = crate::dock::vsplit(SplitSize::FixedFirst(46.0 + crate::dock::TAB_STRIP), tabs(&[Preview, Properties, Paint, Align]), tabs(&[Info]));
assert_eq!(ui.saved_workspaces.get("Column").and_then(|w| w.layout.root.clone()), Some(column));
assert_eq!(ui.saved_workspaces.len(), 2, "floating groups of no named workspace are dropped");
}
#[test]
fn explicit_save_without_a_settings_folder_is_an_error() {
let mut ui = UiState::default();
@@ -324,24 +394,25 @@ mod tests {
#[test]
fn invalid_tabs_split_and_deep_tree_are_rejected() {
let mut snapshot = Snapshot::capture(&UiState::default());
snapshot.dock = DockNode::Tabs { panels: vec![], active: usize::MAX };
snapshot.dock = Workspace::new(Layout { root: Some(Node::Tabs { panels: vec![PanelKind::Project], active: usize::MAX }), floating: vec![] });
assert!(snapshot.validate().is_err());
snapshot.dock = DockNode::Split {
vertical: false,
size: SplitSize::FixedA(f32::NAN),
a: Box::new(crate::dock::workspace("Minimal")),
b: Box::new(crate::dock::workspace("Minimal")),
};
let split = |size, first, second| Node::Split { axis: SplitAxis::Horizontal, size, first: Box::new(first), second: Box::new(second) };
snapshot.dock = Workspace::new(Layout {
root: Some(split(SplitSize::FixedFirst(f32::NAN), tabs(&[PanelKind::Project]), tabs(&[PanelKind::Timeline]))),
floating: vec![],
});
assert!(snapshot.validate().is_err());
snapshot.dock = crate::dock::workspace("Minimal");
for _ in 0..=MAX_DEPTH {
snapshot.dock = DockNode::Split {
vertical: false,
size: SplitSize::Ratio(0.5),
a: Box::new(snapshot.dock),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }),
};
let mut root = tabs(&[PanelKind::Composition]);
for _ in 0..=craft_ui::docking::MAX_DEPTH {
root = split(SplitSize::Ratio(0.5), root, tabs(&[]));
}
snapshot.dock = Workspace::new(Layout { root: Some(root), floating: vec![] });
assert!(snapshot.validate().is_err());
// A panel in two places.
snapshot.dock = Workspace::new(Layout {
root: Some(tabs(&[PanelKind::Project])),
floating: vec![Floating::single(vec![PanelKind::Project], 0, [0.0, 0.0, 200.0, 100.0])],
});
assert!(snapshot.validate().is_err());
}
@@ -350,11 +421,10 @@ mod tests {
let config = MemoryConfig::default();
let mut ui = UiState::default();
let mut store = WorkspaceStore::load(Some(&config), &mut ui);
let mut panels = Vec::new();
ui.dock.panels(&mut panels);
for panel in panels {
ui.dock.close(panel);
for panel in ui.dock.panels() {
ui.dock.apply(Action::Close { panel }, crate::dock::permissions).unwrap();
}
assert!(ui.dock.layout.root.is_none());
assert!(store.save(Some(&config), &ui).unwrap());
let mut restored = UiState::default();
let loaded = WorkspaceStore::load(Some(&config), &mut restored);
@@ -368,16 +438,17 @@ mod tests {
let mut ui = UiState::default();
let mut store = WorkspaceStore::load(Some(&config), &mut ui);
ui.workspace = "Floating".into();
ui.dock.close(PanelKind::Preview);
ui.floating = vec![Floating { panels: vec![PanelKind::Preview], active: 0, rect: [50.0, 80.0, 200.0, 160.0] }];
ui.dock.apply(Action::Float { panel: PanelKind::Preview, rect: [50.0, 80.0, 200.0, 160.0] }, crate::dock::permissions).unwrap();
ui.dock.apply(Action::Close { panel: PanelKind::Align }, crate::dock::permissions).unwrap();
ui.saved_workspaces.insert(ui.workspace.clone(), ui.dock.clone());
ui.saved_floating.insert(ui.workspace.clone(), ui.floating.clone());
ui.maximized = Some(PanelKind::Composition);
ui.project_search = "do not persist".into();
assert!(store.save(Some(&config), &ui).unwrap());
let mut restored = UiState::default();
WorkspaceStore::load(Some(&config), &mut restored);
assert_eq!(Snapshot::capture(&restored), Snapshot::capture(&ui));
// The closed panel's place comes back with it.
assert!(restored.dock.hidden.iter().any(|(p, _)| *p == PanelKind::Align));
assert!(restored.project_search.is_empty());
}
@@ -386,8 +457,9 @@ mod tests {
let config = MemoryConfig::default();
let mut ui = UiState::default();
let mut store = WorkspaceStore::load(Some(&config), &mut ui);
ui.dock = DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Viewer(2)], active: 0 };
ui.saved_workspaces.insert("Two views".into(), DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Viewer(5)], active: 0 });
let two = |viewer| Workspace::new(Layout { root: Some(tabs(&[PanelKind::Composition, PanelKind::Viewer(viewer)])), floating: vec![] });
ui.dock = two(2);
ui.saved_workspaces.insert("Two views".into(), two(5));
ui.viewers.insert(2, Some(42));
assert!(store.save(Some(&config), &ui).unwrap());
let mut app = crate::EffectcraftApp::new(effectcraft_engine::Session::default());
@@ -395,7 +467,7 @@ mod tests {
assert_eq!(app.ui.viewers.get(&2), Some(&None));
assert_eq!(app.ui.viewers.get(&5), Some(&None));
assert_eq!(crate::panels::viewers::new_viewer(&mut app), 6);
assert!(app.ui.dock.contains(PanelKind::Viewer(2)));
assert!(app.ui.dock.is_docked(PanelKind::Viewer(2)));
}
#[test]
@@ -404,23 +476,27 @@ mod tests {
for location in 0..8 {
let mut snapshot = Snapshot::capture(&UiState::default());
let panel = PanelKind::Viewer(id);
let dock = DockNode::Tabs { panels: vec![panel], active: 0 };
let stack = DockNode::Stack { entries: vec![crate::dock::StackEntry::new(panel, true, None)] };
let floating = vec![Floating { panels: vec![panel], active: 0, rect: [0.0, 0.0, 200.0, 100.0] }];
let docked = Workspace::new(Layout { root: Some(tabs(&[panel])), floating: vec![] });
let stacked =
Workspace::new(Layout { root: Some(Node::Stack { entries: vec![StackEntry { panel, open: true, height: None }] }), floating: vec![] });
let floating = Workspace::new(Layout {
root: Some(tabs(&[PanelKind::Composition])),
floating: vec![Floating::single(vec![panel], 0, [0.0, 0.0, 200.0, 100.0])],
});
let mut hidden = Workspace::new(Layout { root: Some(tabs(&[PanelKind::Composition, panel])), floating: vec![] });
hidden.apply(Action::Close { panel }, crate::dock::permissions).unwrap();
match location {
0 => snapshot.focused = panel,
1 => snapshot.maximized = Some(panel),
2 => snapshot.dock = dock,
3 => snapshot.dock = stack,
4 => snapshot.floating = floating,
5 => {
snapshot.saved_workspaces.insert("Saved".into(), dock);
}
2 => snapshot.dock = docked,
3 => snapshot.dock = stacked,
4 => snapshot.dock = floating,
5 => snapshot.dock = hidden,
6 => {
snapshot.saved_workspaces.insert("Saved".into(), stack);
snapshot.saved_workspaces.insert("Saved".into(), stacked);
}
_ => {
snapshot.saved_floating.insert("Saved".into(), floating);
snapshot.saved_workspaces.insert("Saved".into(), floating);
}
}
assert!(snapshot.validate().is_err(), "viewer {id} at location {location} must be rejected");
@@ -428,6 +504,21 @@ mod tests {
}
}
#[test]
fn hostile_native_window_places_are_made_safe_on_load() {
let config = MemoryConfig::default();
let mut ui = UiState::default();
let mut store = WorkspaceStore::load(Some(&config), &mut ui);
ui.dock.apply(Action::Float { panel: PanelKind::Project, rect: [10.0, 10.0, 300.0, 200.0] }, crate::dock::permissions).unwrap();
ui.dock.apply(Action::SetNative { panel: PanelKind::Project, rect: Some([90_000.0, -90_000.0, 1.0e5, 0.5]) }, crate::dock::permissions).unwrap();
assert!(store.save(Some(&config), &ui).unwrap());
let mut restored = UiState::default();
WorkspaceStore::load(Some(&config), &mut restored);
let native = restored.dock.layout.floating.iter().find(|w| w.contains(&PanelKind::Project)).and_then(|w| w.native).unwrap();
assert_eq!(native, crate::dock_ui::sanitized_window_rect(native));
assert!(native[2] <= crate::dock_ui::PANEL_WINDOW_MAX && native[3] >= crate::dock_ui::PANEL_WINDOW_MIN[1]);
}
#[test]
fn failed_write_keeps_previous_snapshot_and_retries() {
struct Fails<'a>(&'a MemoryConfig);
@@ -0,0 +1,245 @@
//! Workspace files of earlier versions: EffectCraft's own dock tree (craft-libs 5c874d51 and
//! before), read into private copies of its types and turned into craft-ui workspaces.
//!
//! - Version 1 (`{version: 1, workspace, dock, floating, …, panel_windows}`) keeps the current
//! arrangement, the named workspaces, focus and the maximized panel.
//! - The unversioned record (`{workspaces, floating}`) holds only named workspaces; the
//! current arrangement stays the default.
//! - A column of stacked frames becomes tab groups split top to bottom. Each open frame is a
//! group with all its tabs; a frame with a height keeps it as a fixed size (its tab strip
//! added), and the others share the rest. A closed frame's tabs join the open frame above it
//! (below it, at the top of the column), as a split has no collapsed frames.
//! - A floating group in its own native window (`panel_windows`) keeps that place as the
//! window's native rect. The earlier version stored the OS window's outer position, so the
//! window's id is returned for [`crate::dock_ui::native_outer_to_content`] to move it to its
//! content position once the main window reports its frame.
//! - Closed panels had no recorded place: they stay closed and reopen at their default place.
use std::collections::BTreeMap;
use craft_ui::docking::{Floating, Node};
use craft_ui::layout::{SplitAxis, SplitSize};
use serde::Deserialize;
use super::{Snapshot, VERSION};
use crate::dock::{Layout, PanelKind, Workspace};
use crate::state::UiState;
#[derive(Deserialize)]
enum OldSplitSize {
Ratio(f32),
FixedA(f32),
FixedB(f32),
}
/// A stacked frame; files from before stacked frames held tabs name one `panel`.
#[derive(Deserialize)]
struct OldStackEntry {
#[serde(default)]
panel: Option<PanelKind>,
#[serde(default)]
panels: Vec<PanelKind>,
#[serde(default)]
active: usize,
open: bool,
#[serde(default)]
height: Option<f32>,
}
#[derive(Deserialize)]
enum OldNode {
Split { vertical: bool, size: OldSplitSize, a: Box<OldNode>, b: Box<OldNode> },
Tabs { panels: Vec<PanelKind>, active: usize },
Stack { entries: Vec<OldStackEntry> },
}
#[derive(Deserialize)]
struct OldFloating {
panels: Vec<PanelKind>,
active: usize,
rect: [f32; 4],
}
/// The floating group holding `panel` shows in its own native window at `rect`.
#[derive(Deserialize)]
struct OldPanelWindow {
panel: PanelKind,
rect: [f32; 4],
}
#[derive(Deserialize)]
struct OldSnapshot {
workspace: String,
dock: OldNode,
floating: Vec<OldFloating>,
focused: PanelKind,
maximized: Option<PanelKind>,
saved_workspaces: BTreeMap<String, OldNode>,
saved_floating: BTreeMap<String, Vec<OldFloating>>,
#[serde(default)]
panel_windows: Vec<OldPanelWindow>,
}
#[derive(Deserialize)]
struct OldRecord {
#[serde(default)]
workspaces: BTreeMap<String, OldNode>,
#[serde(default)]
floating: BTreeMap<String, Vec<OldFloating>>,
}
/// Whether `value` is a file of an earlier version that [`migrate`] reads.
pub(super) fn is_legacy(value: &serde_json::Value) -> bool {
match value.get("version") {
Some(version) => version.as_u64() == Some(1),
None => value.as_object().is_some_and(|fields| fields.contains_key("workspaces") || fields.contains_key("floating")),
}
}
/// The snapshot an earlier version's file describes, and the ids of its floating windows whose
/// native rect holds the OS window's outer position. `ui` supplies what an unversioned record
/// leaves out (the current arrangement). The caller validates the result.
pub(super) fn migrate(value: serde_json::Value, ui: &UiState) -> Result<(Snapshot, Vec<u64>), String> {
if value.get("version").is_none() {
let record: OldRecord = serde_json::from_value(value).map_err(|e| e.to_string())?;
let mut snapshot = Snapshot::capture(ui);
snapshot.saved_workspaces = named(record.workspaces, record.floating)?;
return Ok((snapshot, Vec::new()));
}
let old: OldSnapshot = serde_json::from_value(value).map_err(|e| e.to_string())?;
let dock = workspace(old.dock, old.floating, &old.panel_windows)?;
let outer = dock.layout.floating.iter().filter(|window| window.native.is_some()).map(|window| window.id).collect();
let snapshot = Snapshot {
version: VERSION,
workspace: old.workspace,
dock,
focused: old.focused,
maximized: old.maximized,
saved_workspaces: named(old.saved_workspaces, old.saved_floating)?,
};
Ok((snapshot, outer))
}
/// Named workspaces with their floating groups; floating groups of no named workspace are
/// dropped, as the earlier version never showed them.
fn named(roots: BTreeMap<String, OldNode>, mut floating: BTreeMap<String, Vec<OldFloating>>) -> Result<BTreeMap<String, Workspace>, String> {
roots
.into_iter()
.map(|(name, root)| {
let groups = floating.remove(&name).unwrap_or_default();
workspace(root, groups, &[]).map(|workspace| (name, workspace))
})
.collect()
}
fn workspace(root: OldNode, floating: Vec<OldFloating>, windows: &[OldPanelWindow]) -> Result<Workspace, String> {
// Closing the last docked panel left an empty tab group as the root.
let root = match root {
OldNode::Tabs { panels, .. } if panels.is_empty() => None,
root => Some(node(root, 0)?),
};
let floating = floating
.into_iter()
.map(|group| {
let native = windows.iter().find(|window| group.panels.contains(&window.panel)).map(|window| window.rect);
let mut window = Floating::single(group.panels, group.active, group.rect);
window.native = native.map(crate::dock_ui::sanitized_window_rect);
window
})
.collect();
Ok(Workspace::new(Layout { root, floating }))
}
fn node(old: OldNode, depth: usize) -> Result<Node<PanelKind>, String> {
if depth > craft_ui::docking::MAX_DEPTH {
return Err("workspace dock tree exceeds supported limits".into());
}
Ok(match old {
OldNode::Tabs { panels, active } => Node::Tabs { panels, active },
OldNode::Split { vertical, size, a, b } => Node::Split {
axis: if vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal },
size: match size {
OldSplitSize::Ratio(value) => SplitSize::Ratio(value),
OldSplitSize::FixedA(value) => SplitSize::FixedFirst(value),
OldSplitSize::FixedB(value) => SplitSize::FixedSecond(value),
},
first: Box::new(node(*a, depth.saturating_add(1))?),
second: Box::new(node(*b, depth.saturating_add(1))?),
},
OldNode::Stack { entries } => column(entries)?,
})
}
/// The space between two frames of a column, in points ([`crate::theme::Tokens::gap`]).
const FRAME_GAP: f32 = 4.0;
/// The largest frame height a migrated column keeps, in points.
const MAX_FRAME: f32 = 10_000.0;
/// A tab group of a column: its panels, shown tab, and body height when it has one.
struct Frame {
panels: Vec<PanelKind>,
active: usize,
height: Option<f32>,
}
/// A column of stacked frames as tab groups split top to bottom (see the module docs).
fn column(entries: Vec<OldStackEntry>) -> Result<Node<PanelKind>, String> {
let mut frames: Vec<Frame> = Vec::new();
// Tabs of closed frames above the first open one.
let mut waiting: Vec<PanelKind> = Vec::new();
for entry in entries {
let mut panels = entry.panels;
if let Some(panel) = entry.panel
&& !panels.contains(&panel)
{
panels.insert(0, panel);
}
if panels.is_empty() {
continue;
}
let active = entry.active.min(panels.len().saturating_sub(1));
if entry.open {
let height = entry.height.filter(|h| h.is_finite() && *h > 0.0).map(|h| h.min(MAX_FRAME));
frames.push(Frame { panels, active, height });
} else if let Some(above) = frames.last_mut() {
above.panels.extend(panels);
} else {
waiting.extend(panels);
}
}
match frames.first_mut() {
Some(top) => top.panels.extend(waiting),
None if !waiting.is_empty() => frames.push(Frame { panels: waiting, active: 0, height: None }),
None => return Err("a stacked frame holds no panels".into()),
}
let frame_size = |frame: &Frame| frame.height.map(|h| h + crate::dock::TAB_STRIP);
let mut node: Option<Node<PanelKind>> = None;
// Below the frame being added: the frames' fixed sizes and gaps, or None when one shares.
let mut below_fixed = Some(0.0_f32);
let mut below_flexible = 0_usize;
for frame in frames.into_iter().rev() {
let own = frame_size(&frame);
let gap = if node.is_some() { FRAME_GAP } else { 0.0 };
let tabs = Node::Tabs { panels: frame.panels, active: frame.active };
node = Some(match node {
None => tabs,
Some(rest) => {
let size = match (own, below_fixed) {
(Some(own), _) => SplitSize::FixedFirst(own),
(None, Some(below)) => SplitSize::FixedSecond(below),
// Shares the rest with the flexible frames below.
(None, None) => SplitSize::Ratio(1.0 / (below_flexible as f32 + 1.0)),
};
Node::Split { axis: SplitAxis::Vertical, size, first: Box::new(tabs), second: Box::new(rest) }
}
});
below_fixed = match (own, below_fixed) {
(Some(own), Some(below)) => Some(below + gap + own),
_ => None,
};
if own.is_none() {
below_flexible = below_flexible.saturating_add(1);
}
}
node.ok_or_else(|| "a stacked frame holds no panels".into())
}
+234
View File
@@ -0,0 +1,234 @@
{
"version": 1,
"workspace": "My Layout",
"dock": {
"Split": {
"vertical": false,
"size": {
"FixedB": 300.0
},
"a": {
"Split": {
"vertical": true,
"size": {
"Ratio": 0.56
},
"a": {
"Split": {
"vertical": false,
"size": {
"FixedA": 300.0
},
"a": {
"Tabs": {
"panels": [
"Project",
"EffectControls",
"EffectsPresets"
],
"active": 2
}
},
"b": {
"Tabs": {
"panels": [
"Composition"
],
"active": 0
}
}
}
},
"b": {
"Split": {
"vertical": false,
"size": {
"Ratio": 0.5
},
"a": {
"Tabs": {
"panels": [
"Timeline"
],
"active": 0
}
},
"b": {
"Tabs": {
"panels": [
"Audio"
],
"active": 0
}
}
}
}
}
},
"b": {
"Stack": {
"entries": [
{
"panels": [
"Properties"
],
"active": 0,
"open": true,
"height": null
}
]
}
}
}
},
"floating": [
{
"panels": [
"Preview"
],
"active": 0,
"rect": [
900.0,
120.0,
300.0,
260.0
]
},
{
"panels": [
"RenderQueue"
],
"active": 0,
"rect": [
200.0,
500.0,
520.0,
240.0
]
}
],
"focused": "Composition",
"maximized": null,
"saved_workspaces": {
"My Layout": {
"Split": {
"vertical": false,
"size": {
"FixedB": 300.0
},
"a": {
"Split": {
"vertical": true,
"size": {
"Ratio": 0.56
},
"a": {
"Split": {
"vertical": false,
"size": {
"FixedA": 300.0
},
"a": {
"Tabs": {
"panels": [
"Project",
"EffectControls",
"EffectsPresets"
],
"active": 2
}
},
"b": {
"Tabs": {
"panels": [
"Composition",
"Layer"
],
"active": 0
}
}
}
},
"b": {
"Split": {
"vertical": false,
"size": {
"Ratio": 0.5
},
"a": {
"Tabs": {
"panels": [
"Timeline"
],
"active": 0
}
},
"b": {
"Tabs": {
"panels": [
"Audio"
],
"active": 0
}
}
}
}
}
},
"b": {
"Stack": {
"entries": [
{
"panels": [
"Properties"
],
"active": 0,
"open": true,
"height": null
}
]
}
}
}
}
},
"saved_floating": {
"My Layout": [
{
"panels": [
"Preview"
],
"active": 0,
"rect": [
900.0,
120.0,
300.0,
260.0
]
},
{
"panels": [
"RenderQueue"
],
"active": 0,
"rect": [
200.0,
500.0,
520.0,
240.0
]
}
]
},
"panel_windows": [
{
"panel": "RenderQueue",
"rect": [
200.0,
500.0,
520.0,
240.0
]
}
]
}
+1
View File
@@ -1,5 +1,6 @@
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
mod native_menu_text_keys;
mod render_queue_snapshot;
mod shared_docking_ui;
mod ui_3d;
@@ -0,0 +1,191 @@
//! The macOS menu bar takes the Edit menu's key equivalents before any window sees them: while a
//! text field has the keyboard, in a docked panel or a floating panel's own OS window, they edit
//! the field and leave the project alone, and the items stay enabled.
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::{menus, native_menu};
use egui::{Event, Modifiers, PointerButton, Pos2, ViewportCommand, ViewportId, ViewportInfo, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
fn app() -> EffectcraftApp {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Solid", "color": "#406080"})).unwrap();
let mut app = EffectcraftApp::new(s);
app.ui.start_screen = false;
app.hooks.clipboard_text = Some(Box::new(|| Some("pasted".into())));
app
}
/// What the Edit commands would change in the project.
fn project(app: &EffectcraftApp) -> (u64, usize, usize, usize) {
let s = &app.session;
(s.revision, s.state.clipboard.len(), s.history.undo.len(), s.history.redo.len())
}
/// Choose the native menu item that runs `command`, then deliver what it queued for the main
/// window (kittest does not call `raw_input_hook`).
fn menu(h: &mut Harness<'_, EffectcraftApp>, command: &str) {
let spec = native_menu::build(h.state());
let id = spec.items().into_iter().find(|i| i.command == command).unwrap().id.clone();
let ctx = h.ctx.clone();
native_menu::activate(h.state_mut(), &ctx, &spec, &id).unwrap();
for _ in 0..3 {
let mut raw = egui::RawInput::default();
eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut raw);
h.input_mut().events.extend(raw.events);
h.step();
h.state_mut().mark_synthetic_applied();
}
h.run_steps(2);
}
/// Whether the menu bar shows `command`'s item enabled now, and whether the project alone would.
fn enabled(h: &Harness<'_, EffectcraftApp>, command: &str) -> (bool, bool) {
let find = |menu: native_menu::NativeMenu| menu.items().into_iter().find(|i| i.command == command).unwrap().enabled;
(find(native_menu::build_now(h.state(), &h.ctx)), find(native_menu::build(h.state())))
}
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
}
fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, window: Option<&Host>) {
let send = |h: &mut Harness<'_, EffectcraftApp>, event: Event| match window {
Some(host) => to_window(host, event),
None => h.input_mut().events.push(event),
};
send(h, Event::PointerMoved(at));
h.step();
for pressed in [true, false] {
send(h, Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
}
/// Stand-in OS windows for floating panels: each runs its own pass with its own input, and
/// asking one for the clipboard text gives it "pasted".
#[derive(Default)]
struct Windows {
window: Option<ViewportId>,
events: HashMap<ViewportId, Vec<Event>>,
}
type Host = Rc<RefCell<Windows>>;
fn to_window(host: &Host, event: Event) {
let mut host = host.borrow_mut();
let Some(window) = host.window else { panic!("no floating panel window") };
host.events.entry(window).or_default().push(event);
}
fn native_windows(h: &mut Harness<'_, EffectcraftApp>) -> Host {
let host = Host::default();
let shared = host.clone();
egui::Context::set_immediate_viewport_renderer(move |ctx, mut viewport| {
let id = viewport.ids.this;
let size = viewport.builder.inner_size.unwrap_or(vec2(400.0, 300.0));
let time = ctx.input(|i| i.time);
let events = {
let mut host = shared.borrow_mut();
host.window = Some(id);
host.events.remove(&id).unwrap_or_default()
};
let viewports = [(ViewportId::ROOT, false), (id, true)]
.into_iter()
.map(|(id, focused)| (id, ViewportInfo { focused: Some(focused), native_pixels_per_point: Some(1.0), ..Default::default() }))
.collect();
let input = egui::RawInput {
viewport_id: id,
viewports,
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, size)),
events,
focused: true,
time: Some(time),
..Default::default()
};
let mut full = ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui));
full.textures_delta.clear();
if full.viewport_output.get(&id).is_some_and(|out| out.commands.contains(&ViewportCommand::RequestPaste)) {
shared.borrow_mut().events.entry(id).or_default().push(Event::Paste("pasted".into()));
}
});
h.ctx.set_embed_viewports(false);
// A main window that reports its place: not Wayland, where floating panels stay in it.
let screen = h.ctx.content_rect();
h.input_mut().viewports.entry(ViewportId::ROOT).or_default().outer_rect = Some(screen);
h.run_steps(3);
let window = host.borrow().window.expect("the floating panel opens in an OS window of its own");
// That window has the keyboard.
let input = h.input_mut();
input.focused = false;
for (id, focused) in [(ViewportId::ROOT, false), (window, true)] {
input.viewports.entry(id).or_default().focused = Some(focused);
}
h.run_steps(2);
host
}
#[test]
fn edit_menu_keys_edit_a_text_field_in_a_panel_window() {
let mut app = app();
menus::invoke(&mut app, &egui::Context::default(), "window.panelWindow", json!({"panel": "Project", "window": true})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app);
h.ctx.set_os(egui::os::OperatingSystem::Mac);
let host = native_windows(&mut h);
let at = center(&h, "project.search");
click(&mut h, at, Some(&host));
to_window(&host, Event::Text("abc".into()));
h.run_steps(3);
let window = host.borrow().window.unwrap();
assert_eq!(h.state().ui.project_search, "abc");
assert!(!h.ctx.egui_wants_keyboard_input(), "the main window has no focused widget");
assert_eq!(native_menu::text_field_window(&h.ctx), Some(window));
assert_eq!(enabled(&h, "edit.paste"), (true, false), "Paste stays enabled with nothing copied in the project");
let before = project(h.state());
menu(&mut h, "edit.selectAll");
menu(&mut h, "edit.cut");
assert_eq!(h.state().ui.project_search, "", "Select All and Cut act on the field");
menu(&mut h, "edit.paste");
assert_eq!(h.state().ui.project_search, "pasted");
menu(&mut h, "edit.undo");
assert_eq!(h.state().ui.project_search, "", "Undo undoes the paste");
menu(&mut h, "edit.copy");
assert_eq!(project(h.state()), before, "the project is untouched");
assert_eq!(h.state().ui.project_search, "");
}
#[test]
fn edit_menu_keys_edit_a_docked_text_field() {
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app());
h.ctx.set_os(egui::os::OperatingSystem::Mac);
h.run_steps(3);
let at = center(&h, "project.search");
h.input_mut().events.push(Event::PointerMoved(at));
h.step();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.input_mut().events.push(Event::Text("abc".into()));
h.run_steps(3);
assert_eq!(h.state().ui.project_search, "abc");
assert_eq!(native_menu::text_field_window(&h.ctx), Some(ViewportId::ROOT));
assert_eq!(enabled(&h, "edit.paste"), (true, false), "Paste stays enabled with nothing copied in the project");
let before = project(h.state());
menu(&mut h, "edit.selectAll");
menu(&mut h, "edit.cut");
assert_eq!(h.state().ui.project_search, "", "Select All and Cut act on the field");
menu(&mut h, "edit.paste");
assert_eq!(h.state().ui.project_search, "pasted");
menu(&mut h, "edit.undo");
assert_eq!(h.state().ui.project_search, "", "Undo undoes the paste");
menu(&mut h, "edit.copy");
assert_eq!(project(h.state()), before, "the project is untouched");
}
+226 -299
View File
@@ -1,92 +1,86 @@
//! Existing EffectCraft workspaces use shared transactions without changing their saved schema.
use craft_ui::docking::{Action, Placement, Zone};
//! EffectCraft's workspaces in craft-ui's shared docking: transactions, the command path, pointer
//! gestures and persistence.
use craft_ui::docking::{Action, Node, Placement, StackEntry, Zone};
use effectcraft_engine::Session;
use effectcraft_ui_egui::{
EffectcraftApp,
dock::{self, DockNode, Layout, PanelKind, SplitSize, StackEntry},
dock::{self, Layout, PanelKind, SplitSize, Workspace, WorkspaceExt as _},
};
use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
#[test]
fn shared_move_preserves_accordion_metadata_and_unrelated_active_tabs() {
let mut layout = Layout {
root: DockNode::Split {
vertical: false,
size: SplitSize::FixedB(300.0),
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Composition, PanelKind::Layer], active: 1 }),
b: Box::new(DockNode::Stack {
entries: vec![
StackEntry::new(PanelKind::Preview, false, Some(73.0)),
StackEntry::new(PanelKind::Properties, true, Some(211.0)),
StackEntry::new(PanelKind::Align, false, None),
],
}),
},
floating: vec![],
};
layout.apply(Action::Close { panel: PanelKind::Project }).unwrap();
assert!(layout.root.is_visible(PanelKind::Composition));
layout.apply(Action::Move { panel: PanelKind::Preview, anchor: PanelKind::Align, placement: Placement::Tab { before: None } }).unwrap();
let DockNode::Split { b, size, .. } = &layout.root else { panic!("split was lost") };
assert_eq!(*size, SplitSize::FixedB(300.0));
let DockNode::Stack { entries } = b.as_ref() else { panic!("stack was lost") };
// Upstream now makes a tab drop join the anchor's accordion frame. Its height belongs
// to the destination frame; the unrelated Properties frame keeps its exact metadata.
let joined = entries.iter().find(|entry| entry.contains(PanelKind::Preview)).unwrap();
assert_eq!(joined.panels, vec![PanelKind::Align, PanelKind::Preview]);
assert_eq!(joined.shown(), Some(PanelKind::Preview));
assert!(joined.open);
assert_eq!(joined.height, None);
assert_eq!(entries.iter().find(|entry| entry.contains(PanelKind::Properties)).unwrap(), &StackEntry::new(PanelKind::Properties, true, Some(211.0)));
let before = layout.clone();
assert!(layout.apply(Action::Move { panel: PanelKind::Preview, anchor: PanelKind::Audio, placement: Placement::Split(Zone::Left) }).is_err());
assert_eq!(layout, before);
assert!(layout.apply(Action::Float { panel: PanelKind::Preview, rect: [0.0, 0.0, f32::INFINITY, 200.0] }).is_err());
assert_eq!(layout, before);
fn invoke(app: &mut EffectcraftApp, id: &str, params: serde_json::Value) -> Result<serde_json::Value, String> {
effectcraft_ui_egui::menus::invoke(app, &egui::Context::default(), id, params)
}
fn floating(app: &EffectcraftApp) -> &[dock::Floating<PanelKind>] {
&app.ui.dock.layout.floating
}
#[test]
fn every_builtin_workspace_and_dynamic_identity_roundtrips() {
fn shared_move_preserves_accordion_metadata_and_unrelated_active_tabs() {
let mut workspace = dock::docked(dock::hsplit(
SplitSize::FixedSecond(300.0),
dock::tabs(&[PanelKind::Project, PanelKind::Composition, PanelKind::Layer], 1),
dock::stack(&[(PanelKind::Preview, false, Some(73.0)), (PanelKind::Properties, true, Some(211.0)), (PanelKind::Align, false, None)]),
));
workspace.apply(Action::Close { panel: PanelKind::Project }, dock::permissions).unwrap();
assert!(workspace.is_visible(PanelKind::Composition));
workspace
.apply(Action::Move { panel: PanelKind::Preview, anchor: PanelKind::Layer, placement: Placement::Tab { before: None } }, dock::permissions)
.unwrap();
let Some(Node::Split { second, size, .. }) = &workspace.layout.root else { panic!("split was lost") };
assert_eq!(*size, SplitSize::FixedSecond(300.0));
let Node::Stack { entries } = second.as_ref() else { panic!("stack was lost") };
// The unrelated stacked panels keep their exact state.
assert_eq!(
entries,
&vec![StackEntry { panel: PanelKind::Properties, open: true, height: Some(211.0) }, StackEntry { panel: PanelKind::Align, open: false, height: None }]
);
assert_eq!(workspace.group_of(PanelKind::Preview), vec![PanelKind::Composition, PanelKind::Layer, PanelKind::Preview]);
// Rejected moves and geometry change nothing.
let before = workspace.clone();
assert!(
workspace
.apply(Action::Move { panel: PanelKind::Preview, anchor: PanelKind::Audio, placement: Placement::Split(Zone::Left) }, dock::permissions)
.is_err()
);
assert!(workspace.apply(Action::Float { panel: PanelKind::Preview, rect: [0.0, 0.0, f32::INFINITY, 200.0] }, dock::permissions).is_err());
assert_eq!(workspace, before);
}
#[test]
fn every_builtin_workspace_validates_and_roundtrips_with_dynamic_identities() {
for name in dock::WORKSPACES {
let mut layout = Layout { root: dock::workspace(name), floating: dock::workspace_floating(name) };
let before = serde_json::to_value(&layout).unwrap();
// A structural no-op still crosses both adapters and shared validation. The complete
// persisted shape must remain byte-equivalent, including accordion state and heights.
let action = match &layout.root {
DockNode::Split { size, .. } => Action::ResizeSplit {
path: vec![],
size: match *size {
SplitSize::Ratio(v) => craft_ui::layout::SplitSize::Ratio(v),
SplitSize::FixedA(v) => craft_ui::layout::SplitSize::FixedFirst(v),
SplitSize::FixedB(v) => craft_ui::layout::SplitSize::FixedSecond(v),
},
},
DockNode::Tabs { panels, .. } => Action::Move { panel: panels[0], anchor: panels[1], placement: Placement::Tab { before: Some(panels[0]) } },
DockNode::Stack { entries } => Action::SetStackOpen { panel: entries[0].shown().unwrap(), open: entries[0].open },
};
layout.apply(action).unwrap();
assert_eq!(before, serde_json::to_value(&layout).unwrap());
let back: Layout = serde_json::from_value(before).unwrap();
assert_eq!(layout, back);
let workspace = Workspace::new(dock::workspace(name));
workspace.validate().unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(workspace.is_open(PanelKind::Composition), "{name} lacks the Composition panel");
let text = serde_json::to_string(&workspace).unwrap();
assert_eq!(serde_json::from_str::<Workspace>(&text).unwrap(), workspace, "{name}");
}
let mut layout = Layout {
root: DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Viewer(42), PanelKind::ScriptPanel(77)], active: 1 },
floating: vec![],
};
layout.apply(Action::Float { panel: PanelKind::ScriptPanel(77), rect: [20.0, 30.0, 400.0, 300.0] }).unwrap();
layout.apply(Action::Move { panel: PanelKind::ScriptPanel(77), anchor: PanelKind::Viewer(42), placement: Placement::Tab { before: None } }).unwrap();
assert!(layout.floating.is_empty());
assert!(layout.contains(PanelKind::Viewer(42)));
assert!(layout.root.is_visible(PanelKind::ScriptPanel(77)));
assert_eq!(dock::workspace("Undocked Panels").floating.len(), 4);
// Built again, a built-in layout has the same windows (ids included).
let ids = |layout: &Layout| layout.floating.iter().map(|w| w.id).collect::<Vec<_>>();
assert_eq!(ids(&dock::workspace("Undocked Panels")), ids(&dock::workspace("Undocked Panels")));
let mut workspace = dock::docked(dock::tabs(&[PanelKind::Composition, PanelKind::Viewer(42), PanelKind::ScriptPanel(77)], 1));
workspace.apply(Action::Float { panel: PanelKind::ScriptPanel(77), rect: [20.0, 30.0, 400.0, 300.0] }, dock::permissions).unwrap();
let text = serde_json::to_string(&workspace).unwrap();
assert!(text.contains("\"ScriptPanel\":77") && text.contains("\"Viewer\":42"), "{text}");
assert_eq!(serde_json::from_str::<Workspace>(&text).unwrap(), workspace);
workspace
.apply(Action::Move { panel: PanelKind::ScriptPanel(77), anchor: PanelKind::Viewer(42), placement: Placement::Tab { before: None } }, dock::permissions)
.unwrap();
assert!(workspace.layout.floating.is_empty());
assert!(workspace.is_visible(PanelKind::ScriptPanel(77)));
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let element = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}"));
Rect::from_min_size(pos2(element.rect[0], element.rect[1]), vec2(element.rect[2], element.rect[3]))
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: egui::Pos2, to: egui::Pos2, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.event(Event::PointerMoved(from));
@@ -108,126 +102,52 @@ fn pointer_drop_merges_floats_and_redocks_without_project_changes() {
let mut session = Session::default();
session.execute("comp.new", json!({"name": "Docking composition", "width": 320, "height": 180, "duration": 4})).unwrap();
session.execute("layer.newSolid", json!({"name": "Teal plate", "width": 320, "height": 180, "color": "#245b68"})).unwrap();
session.execute("layer.newSolid", json!({"name": "Coral tile", "width": 110, "height": 70, "color": "#ee7859"})).unwrap();
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
app.ui.dock = DockNode::Split {
vertical: false,
size: SplitSize::FixedA(400.0),
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info, PanelKind::Audio], active: 1 }),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Layer], active: 0 }),
};
app.ui.dock = DockNode::Split {
vertical: true,
size: SplitSize::Ratio(0.62),
a: Box::new(app.ui.dock),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }),
};
let snapshots = std::env::var_os("CRAFT_UI_DOCKING_SNAPSHOT_DIR").map(std::path::PathBuf::from);
let mut builder = Harness::builder().with_size(vec2(1200.0, 760.0));
if snapshots.is_some() {
builder = builder.wgpu();
}
let mut h = builder.build_eframe(move |_| app);
app.ui.dock = dock::docked(dock::vsplit(
SplitSize::Ratio(0.62),
dock::hsplit(
SplitSize::FixedFirst(400.0),
dock::tabs(&[PanelKind::Project, PanelKind::Info, PanelKind::Audio], 1),
dock::tabs(&[PanelKind::Composition, PanelKind::Layer], 0),
),
dock::tabs(&[PanelKind::Timeline], 0),
));
let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app);
h.run_steps(4);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while h.state().frames.urgent_pending() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(!h.state().frames.urgent_pending());
h.run_steps(3);
let project = h.state().session.project.clone();
let times = h.state().session.state.times.clone();
let mut action_log = vec![action_state(&h, "start")];
// A tab dropped on another group's tab strip joins it.
let from = rect(&h, "panel.tab.Info").center();
let to = rect(&h, "panel.Composition").center();
let to = rect(&h, "panel.tab.Layer").center() + vec2(20.0, 0.0);
drag(&mut h, from, to, Modifiers::NONE);
assert!(h.state().ui.dock.is_visible(PanelKind::Info));
assert!(same_group(&h.state().ui.dock, PanelKind::Info, PanelKind::Composition));
action_log.push(action_state(&h, "merged"));
let from = rect(&h, "panel.tab.Info").center();
let before = rect(&h, "panel.tab.Composition");
drag(&mut h, from, pos2(before.left() + 2.0, before.center().y), Modifiers::NONE);
assert_eq!(tabs_containing(&h.state().ui.dock, PanelKind::Info).first(), Some(&PanelKind::Info));
action_log.push(action_state(&h, "reordered"));
assert!(h.state().ui.dock.group_of(PanelKind::Info).contains(&PanelKind::Composition));
// Cmd/Ctrl at the release floats it.
let from = rect(&h, "panel.tab.Info").center();
drag(&mut h, from, pos2(700.0, 400.0), Modifiers::COMMAND);
assert_eq!(h.state().ui.floating.len(), 1);
// The grip is a resize handle: the migration's role mapping (`ResizeHandle → Splitter`) applies
// to floating panels too, and the widget reports it (`dock_ui.rs`).
assert_eq!(h.get_by_label("Resize floating panel").accesskit_node().role(), egui::accesskit::Role::Splitter);
action_log.push(action_state(&h, "floated"));
let original = h.state().ui.floating[0].rect;
assert_eq!(floating(h.state()).len(), 1);
assert!(h.state().ui.dock.is_floating(PanelKind::Info));
let grips: Vec<_> = h.query_all_by_label("Resize panel window").collect();
assert!(!grips.is_empty() && grips.iter().all(|grip| grip.accesskit_node().role() == egui::accesskit::Role::Button));
// Its corner resizes it and its title bar moves it.
let original = floating(h.state())[0].rect;
let grip = rect(&h, "panel.float.0.resize").center();
drag(&mut h, grip, grip + vec2(36.0, 24.0), Modifiers::NONE);
assert!(h.state().ui.floating[0].rect[2] > original[2] + 25.0);
assert!(h.state().ui.floating[0].rect[3] > original[3] + 15.0);
action_log.push(action_state(&h, "resized"));
let grip_id = egui::Id::unique(("floating-panel", PanelKind::Info.id())).with("resize");
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(grip_id));
let before_keys = h.state().ui.floating[0].rect;
for key in [egui::Key::ArrowRight, egui::Key::ArrowDown] {
for pressed in [true, false] {
h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
h.step();
}
}
assert_eq!(h.state().ui.floating[0].rect[2], before_keys[2] + 8.0);
assert_eq!(h.state().ui.floating[0].rect[3], before_keys[3] + 8.0);
h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(3);
assert_ne!(h.ctx.memory(|memory| memory.focused()), Some(grip_id));
action_log.push(action_state(&h, "keyboard-resized"));
let resized = h.state().ui.floating[0].rect;
let header = rect(&h, "panel.float.0.move");
let from = pos2(header.right() - 24.0, header.center().y);
drag(&mut h, from, from - vec2(24.0, 18.0), Modifiers::NONE);
assert!(h.state().ui.floating[0].rect[0] < resized[0] - 15.0);
assert!(h.state().ui.floating[0].rect[1] < resized[1] - 10.0);
action_log.push(action_state(&h, "moved"));
if let Some(directory) = &snapshots {
std::fs::create_dir_all(directory).unwrap();
h.render().unwrap().save(directory.join("effectcraft-floating.png")).unwrap();
}
drag(&mut h, grip, grip - vec2(36.0, 24.0), Modifiers::NONE);
let resized = floating(h.state())[0].rect;
assert!(resized[2] < original[2] - 25.0 && resized[3] < original[3] - 15.0, "{original:?} -> {resized:?}");
let bar = rect(&h, "panel.float.0.move");
drag(&mut h, bar.center(), bar.center() - vec2(24.0, 18.0), Modifiers::NONE);
let moved = floating(h.state())[0].rect;
assert!(moved[0] < resized[0] - 15.0 && moved[1] < resized[1] - 10.0, "{resized:?} -> {moved:?}");
// Its tab dropped on a docked tab strip docks it again.
let from = rect(&h, "panel.tab.Info").center();
let to = rect(&h, "panel.Audio").center();
let to = rect(&h, "panel.tab.Project").center() + vec2(20.0, 0.0);
drag(&mut h, from, to, Modifiers::NONE);
assert!(h.state().ui.floating.is_empty());
action_log.push(action_state(&h, "redocked"));
assert!(floating(h.state()).is_empty());
assert!(h.state().ui.dock.group_of(PanelKind::Info).contains(&PanelKind::Project));
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
assert_eq!(times, h.state().session.state.times);
assert!(h.state().auto.elements.iter().any(|element| element.id.starts_with("timeline.layer.")));
h.state_mut().session.execute("view.exposure", json!({"stops": 2.0})).unwrap();
h.run_steps(3);
let pos = rect(&h, "viewer.resetExposure").center();
h.event(Event::PointerMoved(pos));
h.step();
for pressed in [true, false] {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
assert_eq!(h.state().session.state.viewer.exposure, 0.0);
action_log.push(action_state(&h, "viewer-reset-exposure"));
if let Some(directory) = &snapshots {
std::fs::write(directory.join("effectcraft-actions.json"), serde_json::to_vec_pretty(&action_log).unwrap()).unwrap();
}
if let Some(directory) = &snapshots {
h.render().unwrap().save(directory.join("effectcraft-redocked.png")).unwrap();
}
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
}
fn same_group(root: &DockNode, first: PanelKind, second: PanelKind) -> bool {
match root {
DockNode::Tabs { panels, .. } => panels.contains(&first) && panels.contains(&second),
DockNode::Split { a, b, .. } => same_group(a, first, second) || same_group(b, first, second),
DockNode::Stack { .. } => false,
}
}
#[test]
@@ -236,186 +156,175 @@ fn current_and_named_floating_layouts_survive_restart() {
use std::sync::Arc;
let store = Arc::new(MemoryConfig::default());
let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() });
assert!(app.edit_layout(|layout| layout.apply(Action::Float { panel: PanelKind::Project, rect: [45.0, 65.0, 430.0, 310.0] }).is_ok()));
let ctx = egui::Context::default();
effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name": "Shared float"})).unwrap();
let saved = app.ui.floating.clone();
assert!(app.edit_layout(|layout| layout.apply(Action::MoveFloating { panel: PanelKind::Project, rect: [75.0, 95.0, 480.0, 360.0] }).is_ok()));
let current = app.ui.floating.clone();
app.apply_dock(Action::Float { panel: PanelKind::Project, rect: [45.0, 65.0, 430.0, 310.0] }).unwrap();
invoke(&mut app, "window.saveWorkspaceAs", json!({"name": "Shared float"})).unwrap();
let saved = app.ui.dock.clone();
app.apply_dock(Action::MoveFloating { panel: PanelKind::Project, rect: [75.0, 95.0, 480.0, 360.0] }).unwrap();
let current = app.ui.dock.clone();
eframe::App::on_exit(&mut app);
let mut restarted = EffectcraftApp::new(Session { config: Some(store), ..Default::default() });
assert_eq!(restarted.ui.floating, current);
assert_eq!(restarted.ui.dock, current);
restarted.set_workspace("Default");
restarted.set_workspace("Shared float");
assert_eq!(restarted.ui.floating, saved);
}
fn tabs_containing(root: &DockNode, panel: PanelKind) -> Vec<PanelKind> {
match root {
DockNode::Tabs { panels, .. } if panels.contains(&panel) => panels.clone(),
DockNode::Split { a, b, .. } => {
let first = tabs_containing(a, panel);
if first.is_empty() { tabs_containing(b, panel) } else { first }
}
_ => vec![],
}
assert_eq!(restarted.ui.dock, saved);
}
#[test]
fn float_command_rejects_malformed_rectangles_atomically() {
fn float_command_rejects_malformed_rectangles_and_keeps_one_docked_panel() {
let mut app = EffectcraftApp::new(Session::default());
let before = app.ui.dock.clone();
let ctx = egui::Context::default();
for rect in [json!([1, 2, 300]), json!([1, 2, 300, 200, 7]), json!([1, 2, 1e100, 200]), json!("rectangle")] {
assert!(effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Project", "rect": rect})).is_err());
assert!(invoke(&mut app, "window.floatPanel", json!({"panel": "Project", "rect": rect})).is_err());
assert_eq!(app.ui.dock, before);
assert!(app.ui.floating.is_empty());
}
app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 };
assert!(!app.edit_layout(|layout| layout.apply(Action::Float { panel: PanelKind::Project, rect: [1.0, 2.0, 300.0, 200.0] }).is_ok()));
// The last docked panel stays docked, whether it moves alone, with its group or by command.
app.ui.dock = dock::docked(dock::tabs(&[PanelKind::Project], 0));
let before = app.ui.dock.clone();
assert!(app.apply_dock(Action::Float { panel: PanelKind::Project, rect: [1.0, 2.0, 300.0, 200.0] }).is_err());
assert!(invoke(&mut app, "window.panel.float", json!({"panel": "Project", "group": true})).is_err());
assert_eq!(app.ui.dock, before);
// Closing it is allowed.
invoke(&mut app, "window.closePanel", json!({"panel": "Project"})).unwrap();
assert!(app.ui.dock.layout.root.is_none());
}
/// A floating panel dragged past the window's bottom edge is drawn where its rect says: it was
/// shifted back into the window while its rect kept moving, and its strip then clashed with the
/// floating area's own widget ("Double use of widget ID").
/// A floating panel dragged towards the window's bottom edge moves there, and its title bar
/// still moves it from there.
#[test]
fn floating_panel_dragged_past_the_window_edge_is_drawn_at_its_rect() {
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default()));
fn floating_panel_dragged_towards_the_window_edge_keeps_moving() {
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| {
let mut app = EffectcraftApp::new(Session::default());
app.ui.start_screen = false;
app
});
h.run_steps(3);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.floatPanel", json!({"panel": "Preview", "rect": [600.0, 400.0, 360.0, 300.0]})).unwrap();
h.run_steps(3);
let strip = rect(&h, "panel.float.0.move");
let from = pos2(strip.right() - 24.0, strip.center().y);
drag(&mut h, from, from + vec2(40.0, 420.0), Modifiers::NONE);
let moved = h.state().ui.floating[0].rect;
assert!(moved[1] + moved[3] > 1000.0, "the panel hangs past the bottom edge: {moved:?}");
let strip = rect(&h, "panel.float.0.move");
assert_eq!([strip.min.x, strip.min.y], [moved[0], moved[1]]);
// The strip still moves it from there.
let from = pos2(strip.right() - 24.0, strip.center().y);
drag(&mut h, from, from - vec2(30.0, 200.0), Modifiers::NONE);
assert_eq!(h.state().ui.floating[0].rect[1], moved[1] - 200.0);
}
fn action_state(h: &Harness<'_, EffectcraftApp>, action: &str) -> serde_json::Value {
json!({"action": action, "dock": h.state().ui.dock, "floating": h.state().ui.floating, "focused": h.state().ui.focused})
let bar = rect(&h, "panel.float.0.move");
drag(&mut h, bar.center(), bar.center() + vec2(40.0, 420.0), Modifiers::NONE);
let moved = floating(h.state())[0].rect;
assert!(moved[1] > 550.0, "the panel moved down: {moved:?}");
let bar = rect(&h, "panel.float.0.move");
drag(&mut h, bar.center(), bar.center() - vec2(30.0, 200.0), Modifiers::NONE);
let back = floating(h.state())[0].rect;
assert!(back[1] < moved[1] - 150.0, "{moved:?} -> {back:?}");
}
#[test]
fn catalog_docking_action_supports_resize_reorder_and_floating_geometry() {
let mut app = EffectcraftApp::new(Session::default());
app.ui.dock = DockNode::Split {
vertical: false,
size: SplitSize::FixedA(300.0),
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info], active: 1 }),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Composition], active: 0 }),
};
let ctx = egui::Context::default();
app.ui.dock = dock::docked(dock::hsplit(
SplitSize::FixedFirst(300.0),
dock::tabs(&[PanelKind::Project, PanelKind::Info], 1),
dock::tabs(&[PanelKind::Composition], 0),
));
for action in [
Action::ResizeSplit { path: vec![], size: craft_ui::layout::SplitSize::FixedFirst(360.0) },
Action::ResizeSplit { path: vec![], size: SplitSize::FixedFirst(360.0) },
Action::Move { panel: PanelKind::Info, anchor: PanelKind::Project, placement: Placement::Tab { before: Some(PanelKind::Project) } },
Action::Float { panel: PanelKind::Info, rect: [20.0, 40.0, 300.0, 200.0] },
Action::MoveFloating { panel: PanelKind::Info, rect: [45.0, 65.0, 330.0, 240.0] },
] {
effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.dockAction", json!({"action": action})).unwrap();
invoke(&mut app, "window.dockAction", json!({"action": action})).unwrap();
}
assert_eq!(app.ui.floating[0].rect, [45.0, 65.0, 330.0, 240.0]);
let DockNode::Split { size, .. } = &app.ui.dock else { panic!("split lost") };
assert_eq!(*size, SplitSize::FixedA(360.0));
let before = json!({"dock": app.ui.dock, "floating": app.ui.floating});
assert!(
effectcraft_ui_egui::menus::invoke(
&mut app,
&ctx,
"window.dockAction",
json!({"action": {
"ResizeSplit": {"path": [true, true], "size": {"Ratio": 0.5}}
}})
)
.is_err()
);
assert_eq!(json!({"dock": app.ui.dock, "floating": app.ui.floating}), before);
assert_eq!(floating(&app)[0].rect, [45.0, 65.0, 330.0, 240.0]);
let Some(Node::Split { size, .. }) = &app.ui.dock.layout.root else { panic!("split lost") };
assert_eq!(*size, SplitSize::FixedFirst(360.0));
let before = app.ui.dock.clone();
assert!(invoke(&mut app, "window.dockAction", json!({"action": {"ResizeSplit": {"path": [true, true], "size": {"Ratio": 0.5}}}})).is_err());
assert!(invoke(&mut app, "window.dockAction", json!({"action": {"Open": {"panel": "Align", "anchor": null}}})).is_err());
assert_eq!(app.ui.dock, before);
}
#[test]
fn catalog_close_keeps_dynamic_panel_identity() {
fn ui_dock_and_window_panel_commands_parse_craft_ui_commands() {
let mut app = EffectcraftApp::new(Session::default());
app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Viewer(42), PanelKind::ScriptPanel(77)], active: 1 };
let ctx = egui::Context::default();
for panel in [PanelKind::Viewer(42), PanelKind::ScriptPanel(77)] {
effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.dockAction", json!({"action": Action::Close { panel }})).unwrap();
assert!(!app.ui.dock.contains(panel));
app.ui.dock = dock::docked(dock::hsplit(
SplitSize::FixedFirst(300.0),
dock::tabs(&[PanelKind::Project, PanelKind::Info], 0),
dock::tabs(&[PanelKind::Composition, PanelKind::Layer], 0),
));
// `ui.dock` operations and actions return the workspace.
let reply = invoke(&mut app, "ui.dock", json!({"operation": "move", "panel": "Info", "target": "Composition", "zone": "center"})).unwrap();
assert!(reply["docking"]["layout"].is_object(), "{reply}");
assert!(app.ui.dock.group_of(PanelKind::Info).contains(&PanelKind::Composition));
invoke(&mut app, "ui.dock", json!({"action": {"Activate": {"panel": "Layer"}}})).unwrap();
assert!(app.ui.dock.is_visible(PanelKind::Layer));
invoke(&mut app, "ui.dock", json!({"operation": "float", "panel": "Info", "rect": [100, 100, 300, 200]})).unwrap();
assert_eq!(floating(&app)[0].rect, [100.0, 100.0, 300.0, 200.0]);
// `window.panel.*` takes EffectCraft's panel names too.
invoke(&mut app, "window.panel.dock", json!({"panel": "Info", "anchor": "Project", "zone": "right"})).unwrap();
assert!(floating(&app).is_empty() && app.ui.dock.is_docked(PanelKind::Info));
invoke(&mut app, "window.panel.close", json!({"panel": "Info"})).unwrap();
assert!(!app.ui.dock.is_open(PanelKind::Info));
// A closed panel reopens where it was.
invoke(&mut app, "window.panel.activate", json!({"panel": "Info"})).unwrap();
assert!(app.ui.dock.is_docked(PanelKind::Info));
// The per-panel ids still open a panel.
invoke(&mut app, "window.panel.Align", json!({})).unwrap();
assert!(app.ui.dock.is_open(PanelKind::Align));
// Bad commands change nothing; Reset returns to the workspace's layout.
let before = app.ui.dock.clone();
for (id, params) in [
("ui.dock", json!({"operation": "move", "panel": "Info", "target": "Nope"})),
("ui.dock", json!({"operation": "teleport", "panel": "Info"})),
("window.panel.move", json!({"panel": "Info"})),
("window.panel.dock", json!({"panel": "Nope", "anchor": "Project"})),
] {
assert!(invoke(&mut app, id, params.clone()).is_err(), "{id} {params}");
assert_eq!(app.ui.dock, before);
}
assert!(app.ui.dock.contains(PanelKind::Composition));
invoke(&mut app, "ui.dock.reset", json!({})).unwrap();
assert_eq!(app.ui.dock, Workspace::new(dock::workspace("Default")));
}
#[test]
fn accordion_resize_routes_pointer_and_keyboard_without_changing_closed_entries() {
fn catalog_close_keeps_dynamic_panel_identity_and_releases_viewers() {
let mut app = EffectcraftApp::new(Session::default());
app.ui.dock = dock::docked(dock::tabs(&[PanelKind::Composition, PanelKind::Viewer(42), PanelKind::ScriptPanel(77)], 1));
app.ui.viewers.insert(42, Some(7));
app.ui.maximized = Some(PanelKind::Viewer(42));
for panel in [PanelKind::Viewer(42), PanelKind::ScriptPanel(77)] {
invoke(&mut app, "window.dockAction", json!({"action": Action::Close { panel }})).unwrap();
assert!(!app.ui.dock.is_open(panel));
assert!(app.ui.dock.hidden.iter().any(|(p, _)| *p == panel), "{panel:?} keeps its place");
}
assert!(app.ui.dock.is_docked(PanelKind::Composition));
assert_eq!(app.ui.maximized, None);
assert!(!app.ui.viewers.get(&42).is_some_and(Option::is_some), "the closed viewer forgets its comp");
}
#[test]
fn stacked_panels_resize_by_pointer_without_changing_closed_entries() {
let mut session = Session::default();
session.execute("comp.new", json!({"width": 160, "height": 90, "duration": 1})).unwrap();
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
let mut h = Harness::builder().with_size(vec2(1000.0, 720.0)).build_eframe(move |_| app);
h.run_steps(4);
h.state_mut().ui.dock = DockNode::Stack {
entries: vec![
StackEntry::new(PanelKind::Preview, true, Some(100.0)),
StackEntry::new(PanelKind::Properties, true, None),
StackEntry::new(PanelKind::Align, false, Some(77.0)),
],
};
h.state_mut().ui.dock =
dock::docked(dock::stack(&[(PanelKind::Preview, true, Some(100.0)), (PanelKind::Properties, true, None), (PanelKind::Align, false, Some(77.0))]));
h.run_steps(3);
let project = h.state().session.project.clone();
let id = egui::Id::unique(("dock-gutter", "s0".to_string()));
h.ctx.memory_mut(|memory| memory.request_focus(id));
h.run_steps(2);
for pressed in [true, false] {
h.event(Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
h.step();
}
let DockNode::Stack { entries } = &h.state().ui.dock else { panic!("stack lost") };
assert_eq!(entries[0].height, Some(108.0));
let before = rect(&h, "panel.Preview").height();
let from = rect(&h, "dock.gutter.s0").center();
drag(&mut h, from, from + vec2(0.0, 16.0), Modifiers::NONE);
let DockNode::Stack { entries } = &h.state().ui.dock else { panic!("stack lost") };
assert_eq!(entries[0].height, Some(124.0));
assert_eq!(entries[1].height, None);
assert_eq!(entries[2], StackEntry::new(PanelKind::Align, false, Some(77.0)));
let after = rect(&h, "panel.Preview").height();
assert!((after - before - 16.0).abs() < 2.0, "{before} -> {after}");
let Some(Node::Stack { entries }) = &h.state().ui.dock.layout.root else { panic!("stack lost") };
assert!(entries[0].height.unwrap_or_default() > 100.0, "{entries:?}");
assert_eq!(entries[2], StackEntry { panel: PanelKind::Align, open: false, height: Some(77.0) });
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
}
#[test]
fn legacy_close_repairs_selection_but_transactions_remain_strict() {
use effectcraft_ui_egui::dock::Floating;
let mut root = DockNode::Tabs { panels: vec![PanelKind::Properties, PanelKind::Align, PanelKind::EffectsPresets], active: usize::MAX };
root.close(PanelKind::History);
assert!(root.is_visible(PanelKind::EffectsPresets));
let mut layout = Layout {
root: DockNode::Tabs { panels: vec![PanelKind::Composition], active: 0 },
floating: vec![Floating {
panels: vec![PanelKind::Properties, PanelKind::Align, PanelKind::EffectsPresets],
active: usize::MAX,
rect: [0.0, 0.0, 300.0, 200.0],
}],
};
let before = layout.clone();
assert!(layout.apply(Action::Close { panel: PanelKind::Properties }).is_err());
assert_eq!(layout, before);
assert!(layout.unfloat(PanelKind::Properties));
assert_eq!(layout.floating[0].panels.get(layout.floating[0].active), Some(&PanelKind::EffectsPresets));
layout.floating[0].rect = [0.0; 4];
let before = layout.clone();
assert!(!layout.unfloat(PanelKind::Align));
assert_eq!(layout, before);
}
#[test]
fn failed_close_preserves_viewer_lifecycle_and_maximize_state() {
let mut app = EffectcraftApp::new(Session::default());
app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Viewer(42)], active: usize::MAX };
// A locked workspace refuses the close.
app.ui.dock = dock::docked(dock::tabs(&[PanelKind::Composition, PanelKind::Viewer(42)], 1));
app.ui.dock.locked = true;
app.ui.maximized = Some(PanelKind::Viewer(42));
app.ui.viewers.insert(42, None);
app.ui.viewers.insert(42, Some(3));
app.ui.active_viewer = 42;
app.ui.locked_tabs.insert(PanelKind::Viewer(42).id());
let before = app.ui.clone();
@@ -426,3 +335,21 @@ fn failed_close_preserves_viewer_lifecycle_and_maximize_state() {
assert_eq!(app.ui.active_viewer, before.active_viewer);
assert_eq!(app.ui.locked_tabs, before.locked_tabs);
}
/// A panel never laid out opens at EffectCraft's default place for it (History beside the
/// Project panel, Render Queue with the Timeline), not where craft-ui's fallback puts it; a panel
/// the user closed comes back where it was.
#[test]
fn never_placed_panels_open_at_their_default_place() {
let mut app = EffectcraftApp::new(Session::default());
assert!(!app.ui.dock.is_open(PanelKind::History));
invoke(&mut app, "window.panel", json!({"panel": "History"})).unwrap();
assert!(app.ui.dock.group_of(PanelKind::History).contains(&PanelKind::Project), "{:?}", app.ui.dock.group_of(PanelKind::History));
invoke(&mut app, "window.panel.activate", json!({"panel": "Tracker"})).unwrap();
assert!(app.ui.dock.group_of(PanelKind::Tracker).contains(&PanelKind::EffectsPresets) || app.ui.dock.is_open(PanelKind::Tracker));
// Closed by the user, it reopens at its recorded place.
invoke(&mut app, "window.dockPanel", json!({"panel": "History", "anchor": "Timeline"})).unwrap();
invoke(&mut app, "window.closePanel", json!({"panel": "History"})).unwrap();
invoke(&mut app, "window.panel", json!({"panel": "History"})).unwrap();
assert!(app.ui.dock.group_of(PanelKind::History).contains(&PanelKind::Timeline));
}
@@ -1,6 +1,7 @@
//! Right-click actions in the blank Project and Timeline areas, and on Timeline property names.
use effectcraft_engine::Session;
use effectcraft_engine::project::ItemKind;
use effectcraft_ui_egui::dock::WorkspaceExt as _;
use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind};
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable as _};
@@ -160,7 +161,7 @@ fn timeline_blank_menu_opens_current_comp_in_essential_graphics() {
h.get_by_label("Open in Essential Graphics").click();
h.run_steps(3);
assert_eq!(h.state().session.state.essential_primary, Some(comp));
assert!(h.state().ui.dock.contains(PanelKind::EssentialGraphics));
assert!(h.state().ui.dock.is_docked(PanelKind::EssentialGraphics));
}
}
@@ -2,7 +2,7 @@
use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId};
use effectcraft_ui_egui::{
EffectcraftApp,
dock::{DockNode, PanelKind, SplitSize},
dock::{self, PanelKind, SplitSize},
panels::viewer::comp_to_screen,
};
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
@@ -71,12 +71,7 @@ fn character_stroke_eyedropper_samples_viewer() {
s.execute("layer.setText", json!({"layer": text, "fill": [0.0, 0.0, 0.0], "size": 32.0})).unwrap();
let mut app = EffectcraftApp::new(s);
app.ui.viewer.show_layer_controls = false;
app.ui.dock = DockNode::Split {
vertical: false,
size: SplitSize::FixedB(360.0),
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Composition], active: 0 }),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Character], active: 0 }),
};
app.ui.dock = dock::docked(dock::hsplit(SplitSize::FixedSecond(360.0), dock::tabs(&[PanelKind::Composition], 0), dock::tabs(&[PanelKind::Character], 0)));
let builder = Harness::builder().with_size(vec2(1200.0, 760.0));
let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() { builder.wgpu().build_eframe(|_| app) } else { builder.build_eframe(|_| app) };
h.state_mut().show_panel(PanelKind::Character);
+8 -11
View File
@@ -1,10 +1,11 @@
//! Shared pane resizing in the full application, including application shortcut ownership.
use craft_ui::docking::{Divider, DockArea, Hit};
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::{DockNode, PanelKind, SplitSize};
use effectcraft_ui_egui::dock::{self, Node, PanelKind, SplitSize};
use effectcraft_ui_egui::theme::ThemeKind;
use egui::{Event, Id, Key, Modifiers, PointerButton, Rect, pos2, vec2};
use egui::{Event, Key, Modifiers, PointerButton, Rect, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
@@ -23,8 +24,8 @@ fn snapshots(h: &mut Harness<'_, EffectcraftApp>, directory: Option<&std::path::
}
fn first_size(h: &Harness<'_, EffectcraftApp>) -> f32 {
match h.state().ui.dock {
DockNode::Split { size: SplitSize::FixedA(value), .. } => value,
match &h.state().ui.dock.layout.root {
Some(Node::Split { size: SplitSize::FixedFirst(value), .. }) => *value,
ref other => panic!("fixed-size mode changed: {other:?}"),
}
}
@@ -45,16 +46,12 @@ fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() {
h.run_steps(4);
snapshots(&mut h, directory.as_deref(), "workspace");
h.state_mut().ui.focused = PanelKind::Composition;
h.state_mut().ui.dock = DockNode::Split {
vertical: false,
size: SplitSize::FixedA(400.0),
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }),
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Composition], active: 0 }),
};
h.state_mut().ui.dock =
dock::docked(dock::hsplit(SplitSize::FixedFirst(400.0), dock::tabs(&[PanelKind::Project], 0), dock::tabs(&[PanelKind::Composition], 0)));
h.run_steps(3);
let before_project = h.state().session.project.clone();
let before_times = h.state().session.state.times.clone();
let id = Id::unique(("dock-gutter", String::new()));
let id = DockArea::<PanelKind>::hit_id(effectcraft_ui_egui::dock_ui::area_id(), &Hit::Divider(Divider::Split(Vec::new())));
h.ctx.memory_mut(|memory| memory.request_focus(id));
h.run_steps(2);
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(id));
+2 -1
View File
@@ -8,6 +8,7 @@ use effectcraft_engine::keyframe::Value as KV;
use effectcraft_engine::project::{LayerId, Node, PropGroup};
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use effectcraft_ui_egui::dock::WorkspaceExt as _;
use egui::{Event, Pos2, pos2};
use egui_kittest::Harness;
use serde_json::json;
@@ -298,5 +299,5 @@ fn composition_tabs_and_scrolling_preview() {
let shown = h.state().session.state.active_comp.unwrap();
click_id(&mut h, "panel.tab.Timeline.close");
assert!(!h.state().session.state.open_comps.contains(&shown));
assert!(h.state().ui.dock.contains(PanelKind::Timeline));
assert!(h.state().ui.dock.is_docked(PanelKind::Timeline));
}

Some files were not shown because too many files have changed in this diff Show More