mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 03:28:22 -06:00
M0.37: reconcile shared docking and qualify migration follow-up
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
+7
-6
@@ -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" }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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![];
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>)>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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![];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
//!
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,4 +10,4 @@ rust-version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
miniz_oxide = "0.8"
|
||||
miniz_oxide = "0.9"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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]);
|
||||
}
|
||||
}
|
||||
+722
-556
File diff suppressed because it is too large
Load Diff
+133
-44
@@ -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
@@ -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, ¶ms)? {
|
||||
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, ¶ms) {
|
||||
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};
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user