mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 19:18:14 -06:00
Merge pull request #2432 from aiongg/craft-ui-docking
Panels: move onto craft-ui's shared docking
This commit is contained in:
Generated
+17
-13
@@ -216,7 +216,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -1038,12 +1038,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f"
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1052,16 +1052,20 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -2077,7 +2081,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2866,7 +2870,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f"
|
||||
dependencies = [
|
||||
"hayro-ccitt",
|
||||
]
|
||||
@@ -2880,7 +2884,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f"
|
||||
dependencies = [
|
||||
"hayro-jbig2",
|
||||
"hayro-jpeg2000",
|
||||
@@ -3575,7 +3579,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4705,7 +4709,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4762,7 +4766,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5437,7 +5441,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6507,7 +6511,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+6
-6
@@ -11,9 +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-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
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 = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", version = "=0.1.0", default-features = false, features = ["read"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] }
|
||||
# internal
|
||||
effectcraft-time = { path = "crates/time" }
|
||||
effectcraft-geom = { path = "crates/geom" }
|
||||
@@ -108,7 +108,7 @@ 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 = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", 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"] }
|
||||
@@ -220,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 = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", version = "=0.7.2" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", 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" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
@@ -326,7 +336,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));
|
||||
@@ -344,6 +354,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,
|
||||
@@ -425,6 +436,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).
|
||||
@@ -443,10 +456,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;
|
||||
@@ -646,10 +665,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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`
|
||||
`1e6f127844ec61b3b8afdcc2bb76e7cfe470153f`, 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.
|
||||
|
||||
@@ -33,6 +33,8 @@ 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 }
|
||||
|
||||
|
||||
@@ -377,7 +377,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)
|
||||
}
|
||||
|
||||
+329
-1422
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]);
|
||||
}
|
||||
}
|
||||
+705
-552
File diff suppressed because it is too large
Load Diff
+128
-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;
|
||||
@@ -38,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 +440,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 +480,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).
|
||||
@@ -505,6 +508,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.
|
||||
@@ -520,14 +530,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 {
|
||||
@@ -591,6 +593,7 @@ impl EffectcraftApp {
|
||||
control_rx: None,
|
||||
deferred: vec![],
|
||||
synthetic: vec![],
|
||||
forwarded: ForwardedInput::default(),
|
||||
input_waiters: vec![],
|
||||
job_waiters: vec![],
|
||||
pending_screenshots: vec![],
|
||||
@@ -611,6 +614,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(),
|
||||
@@ -624,6 +628,9 @@ 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(),
|
||||
@@ -746,8 +753,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;
|
||||
}
|
||||
|
||||
@@ -853,46 +859,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 {
|
||||
@@ -1011,6 +1036,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);
|
||||
}
|
||||
}
|
||||
@@ -1255,7 +1281,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 {
|
||||
@@ -2291,7 +2317,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()
|
||||
@@ -2614,6 +2640,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();
|
||||
@@ -2625,6 +2707,8 @@ impl eframe::App for EffectcraftApp {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
+86
-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() {
|
||||
@@ -1178,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})
|
||||
}
|
||||
@@ -1194,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})
|
||||
@@ -1217,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}`"));
|
||||
}
|
||||
@@ -1234,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();
|
||||
}
|
||||
@@ -1452,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| {
|
||||
@@ -1575,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),
|
||||
}
|
||||
@@ -2571,7 +2539,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()]);
|
||||
@@ -2583,7 +2551,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]
|
||||
@@ -2705,6 +2673,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};
|
||||
|
||||
@@ -605,7 +605,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 +648,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]
|
||||
|
||||
@@ -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
|
||||
@@ -689,6 +703,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());
|
||||
|
||||
@@ -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 {
|
||||
@@ -579,12 +579,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()
|
||||
{
|
||||
|
||||
@@ -241,6 +241,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)
|
||||
}
|
||||
|
||||
pub(crate) fn finish_logic(app: &EffectcraftApp, ctx: &egui::Context) {
|
||||
let id = mode_id(ctx.viewport_id());
|
||||
if !ctx.data(|data| data.get_temp::<bool>(id).unwrap_or(false)) {
|
||||
@@ -1509,6 +1516,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(),
|
||||
|
||||
@@ -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,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));
|
||||
}
|
||||
|
||||
@@ -318,7 +318,8 @@ fn hold_browse_opens_a_submenu_and_the_release_inside_picks_an_entry() {
|
||||
h.state_mut().session.state.selected_layers = vec![first];
|
||||
let effect = rect(&h, "menu.Effect");
|
||||
press(&mut h, effect.center());
|
||||
assert!(shown(&h, "Effect Controls"), "the Effect menu opens on the press");
|
||||
// (Effect ▸ Remove All: "Effect Controls" also names the panel's tab.)
|
||||
assert!(shown(&h, "Remove All"), "the Effect menu opens on the press");
|
||||
// The categories come from the effect registry; the first alphabetically is "3D Channel".
|
||||
let cat = h.query_by_label_contains("3D Channel").expect("the category row is shown").rect();
|
||||
move_to(&mut h, cat.center());
|
||||
@@ -326,7 +327,7 @@ fn hold_browse_opens_a_submenu_and_the_release_inside_picks_an_entry() {
|
||||
let entry = h.query_by_label_contains("Cryptomatte").expect("the submenu is open").rect();
|
||||
move_to(&mut h, entry.center());
|
||||
release(&mut h, entry.center());
|
||||
assert!(!shown(&h, "Effect Controls"), "the release closed the menus");
|
||||
assert!(!shown(&h, "Remove All"), "the release closed the menus");
|
||||
assert!(h.state().session.state.last_effect.is_some(), "and the release chose the entry: it applied its effect");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::{DockNode, PanelKind};
|
||||
use effectcraft_ui_egui::dock::WorkspaceExt as _;
|
||||
use effectcraft_ui_egui::dock::{Node, PanelKind, Workspace};
|
||||
use egui::{Event, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
@@ -42,15 +43,6 @@ fn click_n(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, n: u32) {
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// The tabs of the docked group holding `p`, in order.
|
||||
fn group_of(n: &DockNode, p: PanelKind) -> Option<Vec<PanelKind>> {
|
||||
match n {
|
||||
DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)),
|
||||
DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()),
|
||||
DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.panels.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: serde_json::Value) {
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, params).unwrap();
|
||||
@@ -66,7 +58,7 @@ fn opening_a_comp_brings_up_a_closed_composition_panel() {
|
||||
let (mut h, other, main) = harness();
|
||||
assert_eq!(group_of(&h.state().ui.dock, PanelKind::Composition), Some(vec![PanelKind::Composition, PanelKind::Layer]));
|
||||
invoke(&mut h, "window.closePanel", json!({"panel": "Composition"}));
|
||||
assert!(!h.state().ui.dock.contains(PanelKind::Composition));
|
||||
assert!(!h.state().ui.dock.is_docked(PanelKind::Composition));
|
||||
assert!(h.state().ui.dock.is_visible(PanelKind::Layer), "the centre shows the Layer panel");
|
||||
|
||||
// Double-click "Other" in the Project panel.
|
||||
@@ -75,7 +67,7 @@ fn opening_a_comp_brings_up_a_closed_composition_panel() {
|
||||
assert_eq!(h.state().session.active_comp_id(), Some(ItemId(other)));
|
||||
let dock = &h.state().ui.dock;
|
||||
assert!(dock.is_visible(PanelKind::Composition), "the Composition panel is back and in front");
|
||||
assert_eq!(group_of(dock, PanelKind::Composition), Some(vec![PanelKind::Layer, PanelKind::Composition]), "back in the centre");
|
||||
assert_eq!(group_of(dock, PanelKind::Composition), Some(vec![PanelKind::Composition, PanelKind::Layer]), "back in the centre");
|
||||
assert!(h.state().auto.elements.iter().any(|e| e.id == "panel.Composition"), "the Composition panel is drawn");
|
||||
|
||||
// Open Composition (the Project panel's context menu) with the panel closed again.
|
||||
@@ -83,12 +75,12 @@ fn opening_a_comp_brings_up_a_closed_composition_panel() {
|
||||
invoke(&mut h, "comp.open", json!({"comp": main}));
|
||||
assert_eq!(h.state().session.active_comp_id(), Some(ItemId(main)));
|
||||
assert!(h.state().ui.dock.is_visible(PanelKind::Composition));
|
||||
assert_eq!(group_of(&h.state().ui.dock, PanelKind::Composition), Some(vec![PanelKind::Layer, PanelKind::Composition]));
|
||||
assert_eq!(group_of(&h.state().ui.dock, PanelKind::Composition), Some(vec![PanelKind::Composition, PanelKind::Layer]));
|
||||
|
||||
// Window ▸ Composition reopens it in the centre too (not beside Effects & Presets).
|
||||
invoke(&mut h, "window.closePanel", json!({"panel": "Composition"}));
|
||||
invoke(&mut h, "window.panel", json!({"panel": "Composition"}));
|
||||
assert_eq!(group_of(&h.state().ui.dock, PanelKind::Composition), Some(vec![PanelKind::Layer, PanelKind::Composition]));
|
||||
assert_eq!(group_of(&h.state().ui.dock, PanelKind::Composition), Some(vec![PanelKind::Composition, PanelKind::Layer]));
|
||||
assert!(h.state().ui.dock.is_visible(PanelKind::Composition));
|
||||
}
|
||||
|
||||
@@ -99,12 +91,12 @@ fn opening_a_comp_in_the_minimal_workspace_docks_the_viewer_above_the_timeline()
|
||||
let (mut h, other, _) = harness();
|
||||
invoke(&mut h, "window.workspace", json!({"name": "Minimal"}));
|
||||
invoke(&mut h, "window.closePanel", json!({"panel": "Composition"}));
|
||||
assert!(!h.state().ui.dock.contains(PanelKind::Composition));
|
||||
assert!(!h.state().ui.dock.is_docked(PanelKind::Composition));
|
||||
invoke(&mut h, "comp.open", json!({"comp": other}));
|
||||
match &h.state().ui.dock {
|
||||
DockNode::Split { vertical: true, a, b, .. } => {
|
||||
assert_eq!(group_of(a, PanelKind::Composition), Some(vec![PanelKind::Composition]), "the viewer is on top");
|
||||
assert!(b.contains(PanelKind::Timeline));
|
||||
match &h.state().ui.dock.layout.root {
|
||||
Some(Node::Split { axis: effectcraft_ui_egui::dock::SplitAxis::Vertical, first, second, .. }) => {
|
||||
assert_eq!(**first, Node::Tabs { panels: vec![PanelKind::Composition], active: 0 }, "the viewer is on top");
|
||||
assert!(matches!(&**second, Node::Tabs { panels, .. } if panels.contains(&PanelKind::Timeline)));
|
||||
}
|
||||
other => panic!("expected the viewer above the Timeline, got {other:?}"),
|
||||
}
|
||||
@@ -147,7 +139,7 @@ fn applying_an_effect_brings_up_effect_controls_on_that_layer() {
|
||||
|
||||
// Closed: Effect ▸ Last Effect reopens it.
|
||||
invoke(&mut h, "window.closePanel", json!({"panel": "EffectControls"}));
|
||||
assert!(!h.state().ui.dock.contains(PanelKind::EffectControls));
|
||||
assert!(!h.state().ui.dock.is_docked(PanelKind::EffectControls));
|
||||
try_invoke(&mut h, "effect.applyLast", json!({}));
|
||||
assert!(ec_up(&h), "Effect Controls reopened");
|
||||
|
||||
@@ -177,7 +169,7 @@ fn double_clicking_an_effect_in_the_timeline_opens_effect_controls() {
|
||||
invoke(&mut h, "window.closePanel", json!({"panel": "EffectControls"}));
|
||||
let blue = layer_id(&h, "Blue");
|
||||
invoke(&mut h, "layer.select", json!({"layers": [blue]}));
|
||||
assert!(!h.state().ui.dock.contains(PanelKind::EffectControls));
|
||||
assert!(!h.state().ui.dock.is_docked(PanelKind::EffectControls));
|
||||
|
||||
let name = rect(&h, &format!("timeline.group.{uid}.name"));
|
||||
click_n(&mut h, name.center(), 2);
|
||||
@@ -231,3 +223,8 @@ fn focused_panel_outline_is_painted_over_the_panel_bodies() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tabs of the group holding `p`, in order.
|
||||
fn group_of(w: &Workspace, p: PanelKind) -> Option<Vec<PanelKind>> {
|
||||
Some(w.group_of(p)).filter(|g| !g.is_empty())
|
||||
}
|
||||
|
||||
@@ -1,67 +1,52 @@
|
||||
//! Multiple monitors (#486): a panel moves out of the main window into a native window of its
|
||||
//! own (Move Panel to New Window), which can be placed on another screen, and back.
|
||||
//! Multiple monitors (#486): Move Panel to New Window puts a panel in a floating window of its
|
||||
//! own, which on desktop is an OS window that can go on another screen, and Move Panel to Main
|
||||
//! Window docks it again. The test harness embeds viewports, so these windows draw in the main
|
||||
//! window here, as on the web.
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::{PanelKind, PanelWindow};
|
||||
use egui::vec2;
|
||||
use egui_kittest::Harness;
|
||||
use effectcraft_ui_egui::dock::{PanelKind, WorkspaceExt as _};
|
||||
use effectcraft_ui_egui::dock_ui::sanitized_window_rect;
|
||||
use serde_json::json;
|
||||
|
||||
fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, params: serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
effectcraft_ui_egui::menus::invoke(app, ctx, "window.panelWindow", params)
|
||||
}
|
||||
|
||||
fn native(app: &EffectcraftApp, panel: PanelKind) -> Option<[f32; 4]> {
|
||||
app.ui.dock.layout.floating.iter().find(|w| w.contains(&panel)).and_then(|w| w.native)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_window_command_moves_a_docked_panel_into_its_own_window_and_back() {
|
||||
let mut app = EffectcraftApp::new(Session::default());
|
||||
let ctx = egui::Context::default();
|
||||
assert!(app.ui.dock.contains(PanelKind::Project));
|
||||
assert!(app.ui.dock.is_docked(PanelKind::Project));
|
||||
assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project", "window": true})).unwrap(), json!({"window": true}));
|
||||
// Undocked first, then given a window of its own.
|
||||
assert!(!app.ui.dock.contains(PanelKind::Project));
|
||||
assert!(app.ui.floating.iter().any(|group| group.panels == [PanelKind::Project]));
|
||||
assert!(!app.ui.dock.is_docked(PanelKind::Project));
|
||||
assert!(app.ui.dock.layout.floating.iter().any(|w| w.panels().copied().collect::<Vec<_>>() == [PanelKind::Project]));
|
||||
assert!(app.in_panel_window(PanelKind::Project));
|
||||
let window = app.ui.panel_windows.iter().find(|w| w.panel == PanelKind::Project).copied().unwrap();
|
||||
assert_eq!(window.rect, PanelWindow::sanitized(window.rect));
|
||||
let rect = native(&app, PanelKind::Project).unwrap();
|
||||
assert_eq!(rect, sanitized_window_rect(rect));
|
||||
// Asking again changes nothing; no `window` toggles.
|
||||
assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project", "window": true})).unwrap(), json!({"window": true}));
|
||||
assert_eq!(app.ui.panel_windows.len(), 1);
|
||||
assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project"})).unwrap(), json!({"window": false}));
|
||||
// Back in the main window, still floating where it was.
|
||||
// Back in the main window: docked in the first docked group.
|
||||
assert!(!app.in_panel_window(PanelKind::Project));
|
||||
assert!(app.ui.panel_windows.is_empty());
|
||||
assert!(app.ui.floating.iter().any(|group| group.panels == [PanelKind::Project]));
|
||||
assert!(!app.ui.dock.is_floating(PanelKind::Project));
|
||||
assert!(app.ui.dock.is_docked(PanelKind::Project));
|
||||
// The serialized panel form works too.
|
||||
assert_eq!(invoke(&mut app, &ctx, json!({"panel": PanelKind::Project})).unwrap(), json!({"window": true}));
|
||||
// Bad requests are errors and change nothing.
|
||||
let before = app.ui.panel_windows.clone();
|
||||
let before = app.ui.dock.clone();
|
||||
for params in [json!({}), json!({"panel": "Nope"}), json!({"panel": 7}), json!({"panel": "Project", "window": "yes"}), json!({"panel": {"Viewer": 9}})] {
|
||||
assert!(invoke(&mut app, &ctx, params.clone()).is_err(), "{params} must be rejected");
|
||||
}
|
||||
assert_eq!(app.ui.panel_windows, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_in_its_own_window_leaves_the_main_window() {
|
||||
let mut app = EffectcraftApp::new(Session::default());
|
||||
app.ui.start_screen = false;
|
||||
let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app);
|
||||
h.run_steps(3);
|
||||
let ctx = h.ctx.clone();
|
||||
invoke(h.state_mut(), &ctx, json!({"panel": "Project", "window": true})).unwrap();
|
||||
h.run_steps(3);
|
||||
// No in-window floating group is drawn for it (no move strip, no floating area)...
|
||||
assert!(!h.state().auto.elements.iter().any(|e| e.id.starts_with("panel.float.")));
|
||||
let area = egui::Id::unique(("floating-panel", PanelKind::Project.id()));
|
||||
assert!(!h.ctx.memory(|m| m.layer_ids().any(|layer| layer.id == area && m.areas().is_visible(&layer))));
|
||||
// ...the window draws it (here embedded: the test harness has no native windows).
|
||||
assert!(h.state().auto.elements.iter().any(|e| e.id == "panel.Project"));
|
||||
// Docking it back into the main window closes the window entry.
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.dockPanel", json!({"panel": "Project", "anchor": "Timeline"})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.panel_windows.is_empty());
|
||||
assert!(!h.state().in_panel_window(PanelKind::Project));
|
||||
assert_eq!(app.ui.dock, before);
|
||||
// Docking it back into the main window ends its window.
|
||||
effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Project", "anchor": "Timeline"})).unwrap();
|
||||
assert!(!app.in_panel_window(PanelKind::Project));
|
||||
assert!(app.ui.dock.is_docked(PanelKind::Project));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -72,16 +57,50 @@ fn panel_windows_survive_restart_with_hostile_places_made_safe() {
|
||||
let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() });
|
||||
let ctx = egui::Context::default();
|
||||
invoke(&mut app, &ctx, json!({"panel": "Project", "window": true})).unwrap();
|
||||
let hostile = [1.0e9, -3.0e12, 1.0e9, 0.5];
|
||||
if let Some(w) = app.ui.panel_windows.first_mut() {
|
||||
w.rect = hostile;
|
||||
}
|
||||
// A window for a panel that doesn't float is dropped on load.
|
||||
app.ui.panel_windows.push(PanelWindow { panel: PanelKind::Timeline, rect: [10.0, 10.0, 300.0, 200.0] });
|
||||
let hostile = [90_000.0, -99_000.0, 99_000.0, 0.5];
|
||||
app.apply_dock(craft_ui::docking::Action::SetNative { panel: PanelKind::Project, rect: Some(hostile) }).unwrap();
|
||||
eframe::App::on_exit(&mut app);
|
||||
let restarted = EffectcraftApp::new(Session { config: Some(store), ..Default::default() });
|
||||
assert_eq!(restarted.ui.panel_windows, vec![PanelWindow { panel: PanelKind::Project, rect: PanelWindow::sanitized(hostile) }]);
|
||||
let [x, y, w, h] = PanelWindow::sanitized(hostile);
|
||||
assert_eq!(native(&restarted, PanelKind::Project), Some(sanitized_window_rect(hostile)));
|
||||
let [x, y, w, h] = sanitized_window_rect(hostile);
|
||||
assert!(x <= 100_000.0 && y >= -100_000.0 && (160.0..=16_384.0).contains(&w) && (100.0..=16_384.0).contains(&h));
|
||||
assert!(PanelWindow::sanitized([f32::NAN, f32::INFINITY, f32::NAN, f32::NEG_INFINITY]).iter().all(|v| v.is_finite()));
|
||||
assert!(sanitized_window_rect([f32::NAN, f32::INFINITY, f32::NAN, f32::NEG_INFINITY]).iter().all(|v| v.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_a_floating_panel_does_nothing_while_a_dialog_is_open() {
|
||||
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
let mut app = EffectcraftApp::new(Session::default());
|
||||
app.ui.start_screen = false;
|
||||
app.apply_dock(craft_ui::docking::Action::Float { panel: PanelKind::Project, rect: [80.0, 120.0, 320.0, 240.0] }).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app);
|
||||
h.run_steps(3);
|
||||
let close = |h: &Harness<'_, EffectcraftApp>| {
|
||||
let e = h.state().auto.elements.iter().find(|e| e.id.starts_with("panel.float.") && e.id.ends_with(".close")).expect("the floating window's ×");
|
||||
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
|
||||
};
|
||||
let click = |h: &mut Harness<'_, EffectcraftApp>, pos| {
|
||||
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(2);
|
||||
};
|
||||
let at = close(&h);
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "general"})).unwrap();
|
||||
h.run_steps(3);
|
||||
let before = h.state().ui.dock.clone();
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().ui.dock, before, "the floating panel takes no click while Settings is open");
|
||||
assert!(h.state().ui.dock.is_floating(PanelKind::Project));
|
||||
// The same click closes the window once the dialog is gone.
|
||||
h.state_mut().dialog = None;
|
||||
h.run_steps(3);
|
||||
let at = close(&h);
|
||||
click(&mut h, at);
|
||||
assert!(!h.state().ui.dock.is_open(PanelKind::Project));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! registers its automation ids; the Footage panel's buttons edit into the comp; the Progress
|
||||
//! panel lists and cancels a job; Lumetri Scopes switch scope through their dropdown.
|
||||
|
||||
use effectcraft_ui_egui::dock::WorkspaceExt as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::{Session, config::MemoryConfig};
|
||||
@@ -60,7 +61,7 @@ fn window_menu_opens_the_new_panels() {
|
||||
("contentAwareFill", PanelKind::ContentAwareFill, "contentFill.method"),
|
||||
] {
|
||||
open(&mut h, name);
|
||||
assert!(h.state().ui.dock.contains(kind) || h.state().ui.floating.iter().any(|f| f.panels.contains(&kind)), "{name} shown");
|
||||
assert!(h.state().ui.dock.is_open(kind), "{name} shown");
|
||||
if !auto.is_empty() {
|
||||
assert!(h.state().auto.find(auto).is_some(), "{name}: {auto}");
|
||||
}
|
||||
@@ -72,9 +73,7 @@ fn window_menu_opens_the_new_panels() {
|
||||
#[test]
|
||||
fn window_menu_closes_a_checked_panel() {
|
||||
let mut h = harness();
|
||||
let shown = |h: &Harness<'_, EffectcraftApp>| {
|
||||
h.state().ui.dock.contains(PanelKind::Align) || h.state().ui.floating.iter().any(|f| f.panels.contains(&PanelKind::Align))
|
||||
};
|
||||
let shown = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.is_open(PanelKind::Align);
|
||||
open(&mut h, "align");
|
||||
assert!(shown(&h));
|
||||
click(&mut h, "menu.Window");
|
||||
@@ -106,7 +105,7 @@ fn footage_panel_buttons_edit_into_the_comp() {
|
||||
let item = h.state().session.project.items.values().find(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Solid(_))).unwrap().id.0;
|
||||
h.state_mut().session.execute("footage.open", json!({"item": item})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.dock.contains(PanelKind::Footage) || h.state().ui.floating.iter().any(|f| f.panels.contains(&PanelKind::Footage)));
|
||||
assert!(h.state().ui.dock.is_open(PanelKind::Footage));
|
||||
assert!(h.state().auto.find("footage.overlayEdit").is_some());
|
||||
let before = h.state().session.active_comp().unwrap().layers.len();
|
||||
click(&mut h, "footage.overlayEdit");
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use effectcraft_ui_egui::dock::WorkspaceExt as _;
|
||||
use egui::{Event, Rect, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
@@ -75,7 +76,7 @@ fn script_windows_are_drawn_and_clickable() {
|
||||
h.state_mut().session.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap();
|
||||
h.run_steps(3);
|
||||
let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Layer Tools.jsx").unwrap().id;
|
||||
assert!(h.state().ui.dock.contains(PanelKind::ScriptPanel(panel)), "the panel docks");
|
||||
assert!(h.state().ui.dock.is_docked(PanelKind::ScriptPanel(panel)), "the panel docks");
|
||||
assert!(h.state().auto.find(&format!("scriptui.{panel}")).is_some());
|
||||
// The menus list scripts and panels.
|
||||
let cx = effectcraft_engine::menus::DynCtx::default();
|
||||
@@ -104,7 +105,7 @@ fn panels_open_at_quit_dock_again_at_launch() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Hello Panel.jsx").expect("the panel's window").id;
|
||||
assert!(h.state().ui.dock.contains(PanelKind::ScriptPanel(panel)), "the panel docks");
|
||||
assert!(h.state().ui.dock.is_docked(PanelKind::ScriptPanel(panel)), "the panel docks");
|
||||
assert!(h.state().auto.find(&format!("scriptui.{panel}.hi")).is_some(), "its controls are drawn");
|
||||
// Closing its tab forgets it for the next launch.
|
||||
h.state_mut().close_panel(PanelKind::ScriptPanel(panel));
|
||||
@@ -131,7 +132,7 @@ fn ease_presets_panel_eases_the_selected_keys_from_its_list() {
|
||||
s.execute("window.scriptPanel", json!({"name": "Ease Presets.jsx"})).unwrap();
|
||||
h.run_steps(3);
|
||||
let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Ease Presets.jsx").unwrap().id;
|
||||
assert!(h.state().ui.dock.contains(PanelKind::ScriptPanel(panel)), "the panel docks");
|
||||
assert!(h.state().ui.dock.is_docked(PanelKind::ScriptPanel(panel)), "the panel docks");
|
||||
h.state_mut().ui.maximized = Some(PanelKind::ScriptPanel(panel));
|
||||
h.run_steps(3);
|
||||
let steps = h.state().session.history.undo.len();
|
||||
|
||||
@@ -314,7 +314,8 @@ fn panels_taller_than_their_space_have_a_scroll_bar_to_drag() {
|
||||
click_at(&mut h, pos2(bar.center().x, bar.min.y + 2.0));
|
||||
assert_eq!(scrolled(&h, id).0, 0.0, "{id}: at the top");
|
||||
drag(&mut h, pos2(bar.center().x, bar.min.y + 6.0), pos2(bar.center().x, bar.max.y + 40.0));
|
||||
assert_eq!(scrolled(&h, id).0, max, "{id}: dragged to the end");
|
||||
let end = scrolled(&h, id).0;
|
||||
assert!((end - max).abs() < 0.01, "{id}: dragged to the end: {end} of {max}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +333,7 @@ fn effect_values_stay_clear_of_the_keyframe_navigator() {
|
||||
app.session.execute("prop.toggleAnimation", json!({"layer": lid, "path": path})).unwrap();
|
||||
app.session.execute("prop.set", json!({"layer": lid, "path": path, "value": [123456.7, 123456.7]})).unwrap();
|
||||
let uid = app.session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(lid)).unwrap().props.prop(path).unwrap().uid;
|
||||
assert!(app.edit_layout(|l| l.float(PanelKind::EffectControls, [300.0, 150.0, 330.0, 500.0])));
|
||||
app.apply_dock(craft_ui::docking::Action::Float { panel: PanelKind::EffectControls, rect: [300.0, 150.0, 330.0, 500.0] }).unwrap();
|
||||
h.run_steps(3);
|
||||
let value = rect(&h, &format!("effectControls.prop.{uid}.value.1"));
|
||||
let nav = rect(&h, &format!("effectControls.prop.{uid}.prevKey"));
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::{DockNode, PanelKind};
|
||||
use effectcraft_ui_egui::dock::WorkspaceExt as _;
|
||||
use effectcraft_ui_egui::dock::{Node, PanelKind, SplitSize, Workspace};
|
||||
use egui::{Event, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
@@ -59,15 +60,6 @@ fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// The tabs of the docked group holding `p`, in order.
|
||||
fn group_of(n: &DockNode, p: PanelKind) -> Option<Vec<PanelKind>> {
|
||||
match n {
|
||||
DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)),
|
||||
DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()),
|
||||
DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.panels.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_comps(h: &Harness<'_, EffectcraftApp>) -> Vec<u64> {
|
||||
h.state().session.state.open_comps.iter().map(|c| c.0).collect()
|
||||
}
|
||||
@@ -81,10 +73,11 @@ fn project_double_click_opens_a_comp_as_its_own_timeline_tab() {
|
||||
assert_eq!(open_comps(&h), vec![main, pre]);
|
||||
assert_eq!(h.state().session.state.active_comp, Some(ItemId(pre)));
|
||||
assert_eq!(h.state().session.project.item(ItemId(pre)).map(|i| i.name.clone()), Some("Pre".into()));
|
||||
// One Timeline tab per comp, the newest last; the shown one answers to the panel's id.
|
||||
// One comp tab per comp in the Timeline's tab strip, the newest last.
|
||||
let (tm, tp) = (rect(&h, &format!("panel.tab.Timeline.{main}")), rect(&h, &format!("panel.tab.Timeline.{pre}")));
|
||||
assert!(tp.min.x > tm.max.x, "{tm:?} {tp:?}");
|
||||
assert_eq!(rect(&h, "panel.tab.Timeline"), tp);
|
||||
assert!(tp.min.x >= tm.max.x - 0.5, "{tm:?} {tp:?}");
|
||||
let strip = rect(&h, "panel.tab.Timeline");
|
||||
assert!(strip.y_range().contains(tp.center().y) && tp.min.x > strip.max.x, "{strip:?} {tp:?}");
|
||||
// Clicking Main's tab shows Main again; both stay open.
|
||||
click_n(&mut h, tm.center(), 1);
|
||||
assert_eq!(h.state().session.state.active_comp, Some(ItemId(main)));
|
||||
@@ -94,7 +87,7 @@ fn project_double_click_opens_a_comp_as_its_own_timeline_tab() {
|
||||
click_n(&mut h, close.center(), 1);
|
||||
assert_eq!(open_comps(&h), vec![pre]);
|
||||
assert_eq!(h.state().session.state.active_comp, Some(ItemId(pre)));
|
||||
assert!(h.state().ui.dock.contains(PanelKind::Timeline));
|
||||
assert!(h.state().ui.dock.is_docked(PanelKind::Timeline));
|
||||
// Double-clicking an open comp again adds no second tab.
|
||||
let name = rect(&h, &format!("project.item.{pre}.name"));
|
||||
click_n(&mut h, name.center(), 2);
|
||||
@@ -179,18 +172,17 @@ fn double_clicking_a_precomp_bar_opens_its_comp() {
|
||||
assert_eq!(h.state().session.active_comp_id(), Some(ItemId(pre)));
|
||||
}
|
||||
|
||||
/// Issue #46: a panel dragged by its panel-menu (hamburger) button onto the left edge of another
|
||||
/// group docks there as its own group; dragging the gap between panels resizes them.
|
||||
/// Issue #46: a panel dragged onto the left edge of another group docks there as its own group;
|
||||
/// dragging the gap between panels resizes them.
|
||||
#[test]
|
||||
fn panels_dock_beside_others_and_gutters_resize_them() {
|
||||
let (mut h, _, _) = harness();
|
||||
let dock = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.clone();
|
||||
// Show Effect Controls in its group so its menu button is on screen, then drag by it.
|
||||
let tab = rect(&h, "panel.tab.EffectControls");
|
||||
click_n(&mut h, tab.center(), 1);
|
||||
let grip = rect(&h, "panel.menu.EffectControls").center();
|
||||
let tl = rect(&h, "panel.Timeline");
|
||||
drag(&mut h, grip, pos2(tl.min.x + tl.width() * 0.08, tl.center().y));
|
||||
// Inside the Timeline body's left drop zone.
|
||||
let depth = craft_ui::docking::SIDE_EDGE_ZONE_DEPTH.min(tl.width().min(tl.height()) / 4.0);
|
||||
drag(&mut h, tab.center(), pos2(tl.min.x + depth / 2.0, tl.center().y));
|
||||
assert_eq!(group_of(&dock(&h), PanelKind::EffectControls), Some(vec![PanelKind::EffectControls]));
|
||||
assert!(!group_of(&dock(&h), PanelKind::Timeline).unwrap().contains(&PanelKind::EffectControls));
|
||||
let (fx, tl) = (rect(&h, "panel.EffectControls"), rect(&h, "panel.Timeline"));
|
||||
@@ -212,41 +204,83 @@ fn panels_dock_beside_others_and_gutters_resize_them() {
|
||||
assert!((wider.width() - project.width() - 60.0).abs() < 2.0, "{} → {}", project.width(), wider.width());
|
||||
}
|
||||
|
||||
/// Issue #171: the gaps between the right column's stacked panels drag to resize them (Preview
|
||||
/// was stuck at its default height), not below a minimum; the heights stay in the layout.
|
||||
/// The divider below the Preview frame: the horizontal gutter between it and the Properties
|
||||
/// frame.
|
||||
fn preview_divider(h: &Harness<'_, EffectcraftApp>) -> egui::Rect {
|
||||
let (preview, props) = (rect(h, "panel.Preview"), rect(h, "panel.Properties"));
|
||||
h.state()
|
||||
.auto
|
||||
.previous
|
||||
.iter()
|
||||
.filter(|e| e.id.starts_with("dock.gutter.") && e.rect[2] > e.rect[3])
|
||||
.map(|e| egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])))
|
||||
.find(|g| g.x_range().contains(preview.center().x) && g.center().y >= preview.max.y - 1.0 && g.center().y <= props.min.y + 1.0)
|
||||
.expect("a gutter between Preview and Properties")
|
||||
}
|
||||
|
||||
/// Default's right column: a short Preview frame (46 points of body) and the Properties frame
|
||||
/// below it taking the rest of the column, at any window height.
|
||||
#[test]
|
||||
fn stacked_panel_gaps_resize_the_panels() {
|
||||
fn the_default_preview_frame_is_short_and_properties_takes_the_rest() {
|
||||
let (mut h, _, _) = harness();
|
||||
fn height(n: &DockNode, p: PanelKind) -> Option<Option<f32>> {
|
||||
match n {
|
||||
DockNode::Split { a, b, .. } => height(a, p).or_else(|| height(b, p)),
|
||||
DockNode::Tabs { .. } => None,
|
||||
DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.height),
|
||||
let body = |h: &Harness<'_, EffectcraftApp>| rect(h, "panel.Preview").height();
|
||||
assert!((body(&h) - 46.0).abs() < 1.5, "Preview {}", body(&h));
|
||||
let props = rect(&h, "panel.Properties");
|
||||
assert!(props.height() > 300.0, "Properties {props:?}");
|
||||
// A taller window gives the room to Properties.
|
||||
let size = h.ctx.content_rect().size();
|
||||
h.set_size(size + egui::vec2(0.0, 200.0));
|
||||
h.run_steps(3);
|
||||
assert!((body(&h) - 46.0).abs() < 1.5, "Preview {}", body(&h));
|
||||
assert!((rect(&h, "panel.Properties").height() - props.height() - 200.0).abs() < 2.0);
|
||||
}
|
||||
|
||||
/// Issue #171: the divider below the Preview frame drags to resize it (Preview was stuck at its
|
||||
/// default height), not below a minimum; the new height stays in the layout.
|
||||
#[test]
|
||||
fn the_preview_frame_divider_resizes_it() {
|
||||
let (mut h, _, _) = harness();
|
||||
fn preview_size(w: &Workspace) -> Option<SplitSize> {
|
||||
fn rec(n: &Node<PanelKind>) -> Option<SplitSize> {
|
||||
match n {
|
||||
Node::Split { first, second, size, .. } => match first.as_ref() {
|
||||
Node::Tabs { panels, .. } if panels.contains(&PanelKind::Preview) => Some(*size),
|
||||
_ => rec(first).or_else(|| rec(second)),
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
rec(w.layout.root.as_ref()?)
|
||||
}
|
||||
let (preview, props) = (rect(&h, "panel.Preview"), rect(&h, "panel.Properties"));
|
||||
let gap = rect(&h, "dock.gutter.bs0");
|
||||
assert!(gap.center().y > preview.max.y && gap.center().y < props.min.y, "the gap below Preview: {gap:?}");
|
||||
let gap = preview_divider(&h);
|
||||
drag(&mut h, gap.center(), gap.center() + egui::vec2(0.0, 150.0));
|
||||
let (p2, q2) = (rect(&h, "panel.Preview"), rect(&h, "panel.Properties"));
|
||||
assert!((p2.height() - preview.height() - 150.0).abs() < 2.0, "Preview {} → {}", preview.height(), p2.height());
|
||||
assert!((props.height() - q2.height() - 150.0).abs() < 2.0, "Properties {} → {}", props.height(), q2.height());
|
||||
assert!((q2.max.y - props.max.y).abs() < 1.0, "the column stays filled");
|
||||
let saved = height(&h.state().ui.dock, PanelKind::Preview).flatten().expect("Preview's height is in the layout");
|
||||
assert!((saved - p2.height()).abs() < 2.0, "{saved} vs {}", p2.height());
|
||||
assert_eq!(height(&h.state().ui.dock, PanelKind::Properties), Some(None), "Properties still takes the rest");
|
||||
// Collapsing and expanding Preview keeps its height.
|
||||
let tab = rect(&h, "panel.tab.Preview");
|
||||
click_n(&mut h, tab.center(), 1);
|
||||
assert!(!h.state().ui.dock.is_visible(PanelKind::Preview));
|
||||
click_n(&mut h, tab.center(), 1);
|
||||
assert!((rect(&h, "panel.Preview").height() - p2.height()).abs() < 1.0);
|
||||
// Not below the minimum.
|
||||
let gap = rect(&h, "dock.gutter.bs0");
|
||||
drag(&mut h, gap.center(), gap.center() - egui::vec2(0.0, 400.0));
|
||||
// The frame keeps a fixed height, 150 points more.
|
||||
let Some(SplitSize::FixedFirst(size)) = preview_size(&h.state().ui.dock) else { panic!("{:?}", preview_size(&h.state().ui.dock)) };
|
||||
assert!((size - effectcraft_ui_egui::dock::PREVIEW_FRAME - 150.0).abs() < 2.0, "{size}");
|
||||
// Dragged up to Preview's top, it stops at its minimum.
|
||||
let gap = preview_divider(&h);
|
||||
let top = rect(&h, "panel.Preview").min.y;
|
||||
drag(&mut h, gap.center(), pos2(gap.center().x, top));
|
||||
assert!((rect(&h, "panel.Preview").height() - 40.0).abs() < 1.0, "{}", rect(&h, "panel.Preview").height());
|
||||
}
|
||||
|
||||
/// Maximize acts on one frame: the Preview frame fills the dock area, Properties hides.
|
||||
#[test]
|
||||
fn maximizing_preview_maximizes_its_frame_alone() {
|
||||
let (mut h, _, _) = harness();
|
||||
let preview = rect(&h, "panel.Preview");
|
||||
h.state_mut().ui.maximized = Some(PanelKind::Preview);
|
||||
h.run_steps(3);
|
||||
let big = rect(&h, "panel.Preview");
|
||||
assert!(big.height() > preview.height() + 300.0 && big.width() > preview.width() + 300.0, "{preview:?} → {big:?}");
|
||||
assert!(h.state().auto.find("panel.Properties").is_none(), "the Properties frame is not shown");
|
||||
}
|
||||
|
||||
/// Issue #45: the Project panel's details (name, size, duration) stay inside the panel with a
|
||||
/// margin, cut short with "…" when they don't fit; so does the hint shown with nothing selected.
|
||||
#[test]
|
||||
@@ -338,42 +372,55 @@ fn dragging_the_viewer_timeline_divider_far_up_does_not_crash() {
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Issue #301: clicking anywhere on a stacked panel's header bar expands/collapses it, not only
|
||||
/// its name; the panel menu button keeps its own click.
|
||||
/// Issue #301: a stacked panel's header bar opens it with a click anywhere on it, and its −/+
|
||||
/// button at the bar's right end collapses and expands it.
|
||||
#[test]
|
||||
fn a_stacked_panels_whole_header_bar_toggles_it() {
|
||||
let (mut h, _, _) = harness();
|
||||
use effectcraft_ui_egui::dock;
|
||||
let stack = dock::stack(&[(PanelKind::Preview, true, Some(46.0)), (PanelKind::Properties, true, None)]);
|
||||
h.state_mut().ui.dock = dock::docked(dock::hsplit(SplitSize::FixedSecond(300.0), dock::tabs(&[PanelKind::Composition], 0), stack));
|
||||
h.run_steps(3);
|
||||
let open = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.is_visible(PanelKind::Preview);
|
||||
assert!(open(&h));
|
||||
let bar = rect(&h, "panel.header.Preview");
|
||||
let tab = rect(&h, "panel.tab.Preview");
|
||||
assert!(bar.min.x >= tab.max.x - 1.0 && bar.width() > 40.0, "{bar:?} {tab:?}");
|
||||
click_n(&mut h, pos2(bar.max.x - 6.0, bar.center().y), 1);
|
||||
assert!(!open(&h), "a click at the bar's right end collapses Preview");
|
||||
let bar = rect(&h, "panel.header.Preview");
|
||||
click_n(&mut h, bar.center(), 1);
|
||||
let toggle = rect(&h, "panel.header.Preview");
|
||||
let bar = rect(&h, "panel.tab.Preview");
|
||||
assert!(toggle.max.x <= bar.max.x + 1.0 && toggle.min.x > bar.center().x, "{toggle:?} {bar:?}");
|
||||
click_n(&mut h, toggle.center(), 1);
|
||||
assert!(!open(&h), "the button collapses Preview");
|
||||
let toggle = rect(&h, "panel.header.Preview");
|
||||
click_n(&mut h, toggle.center(), 1);
|
||||
assert!(open(&h), "and expands it again");
|
||||
let menu = rect(&h, "panel.menu.Preview");
|
||||
click_n(&mut h, menu.center(), 1);
|
||||
assert!(open(&h), "the panel menu button doesn't toggle the panel");
|
||||
let toggle = rect(&h, "panel.header.Preview");
|
||||
click_n(&mut h, toggle.center(), 1);
|
||||
assert!(!open(&h));
|
||||
click_n(&mut h, pos2(bar.min.x + bar.width() * 0.25, bar.center().y), 1);
|
||||
assert!(open(&h), "a click on the bar away from the button opens it");
|
||||
}
|
||||
|
||||
/// #302: a panel dropped onto a stacked panel becomes a tab in that panel's frame (it was
|
||||
/// inserted as a stacked panel of its own below it); its tabs switch the frame's panel.
|
||||
/// #302: a panel dropped onto the Properties frame becomes a tab in that frame; its tabs switch
|
||||
/// the frame's panel, and the Preview frame above keeps showing.
|
||||
#[test]
|
||||
fn a_tab_dropped_on_a_stacked_panel_joins_its_frame() {
|
||||
fn a_tab_dropped_on_a_frame_joins_it() {
|
||||
let (mut h, _, _) = harness();
|
||||
let dock = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.clone();
|
||||
let fx = rect(&h, "panel.tab.EffectControls");
|
||||
let props = rect(&h, "panel.Properties");
|
||||
drag(&mut h, fx.center(), props.center());
|
||||
assert_eq!(group_of(&dock(&h), PanelKind::Properties), Some(vec![PanelKind::Properties, PanelKind::EffectControls]));
|
||||
let group = group_of(&dock(&h), PanelKind::Properties).unwrap();
|
||||
assert!(group.starts_with(&[PanelKind::Properties]) && group.contains(&PanelKind::EffectControls), "{group:?}");
|
||||
assert!(dock(&h).is_visible(PanelKind::EffectControls) && !dock(&h).is_visible(PanelKind::Properties));
|
||||
assert!(dock(&h).is_visible(PanelKind::Preview));
|
||||
// The frame's other tab shows Properties again, the frame stays open.
|
||||
let tab = rect(&h, "panel.tab.Properties");
|
||||
click_n(&mut h, tab.center(), 1);
|
||||
assert!(dock(&h).is_visible(PanelKind::Properties) && !dock(&h).is_visible(PanelKind::EffectControls));
|
||||
// The layout (saved workspaces) round-trips.
|
||||
let s = serde_json::to_string(&dock(&h)).unwrap();
|
||||
assert_eq!(serde_json::from_str::<DockNode>(&s).unwrap(), dock(&h));
|
||||
assert_eq!(serde_json::from_str::<Workspace>(&s).unwrap(), dock(&h));
|
||||
}
|
||||
|
||||
/// The tabs of the group holding `p`, in order.
|
||||
fn group_of(w: &Workspace, p: PanelKind) -> Option<Vec<PanelKind>> {
|
||||
Some(w.group_of(p)).filter(|g| !g.is_empty())
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::{PanelKind, Zone};
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use effectcraft_ui_egui::dock::WorkspaceExt as _;
|
||||
use effectcraft_ui_egui::panels::viewers;
|
||||
use egui::{Event, pos2};
|
||||
use egui_kittest::Harness;
|
||||
@@ -67,12 +68,18 @@ fn new_viewers_lock_route_comps_and_activate_on_click() {
|
||||
h.run_steps(3);
|
||||
// View ▸ New Viewer: viewer 1 shows A too, viewer 0 (the Composition panel) is locked.
|
||||
invoke(&mut h, "view.newViewer", json!({}));
|
||||
assert!(h.state().ui.dock.contains(PanelKind::Viewer(1)));
|
||||
assert!(h.state().ui.dock.is_docked(PanelKind::Viewer(1)));
|
||||
assert!(viewers::locked(h.state(), 0) && !viewers::locked(h.state(), 1));
|
||||
assert_eq!((h.state().ui.active_viewer, shows(&h, 0), shows(&h, 1)), (1, Some(a), Some(a)));
|
||||
// Side by side, so both draw.
|
||||
assert!(h.state_mut().edit_layout(|l| l.dock(PanelKind::Viewer(1), PanelKind::Composition, Zone::Right)));
|
||||
h.state_mut().ui.dock.activate(PanelKind::Composition);
|
||||
h.state_mut()
|
||||
.apply_dock(craft_ui::docking::Action::Move {
|
||||
panel: PanelKind::Viewer(1),
|
||||
anchor: PanelKind::Composition,
|
||||
placement: craft_ui::docking::Placement::Split(craft_ui::docking::Zone::Right),
|
||||
})
|
||||
.unwrap();
|
||||
h.state_mut().apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Composition }).unwrap();
|
||||
h.run_steps(3);
|
||||
// Opening B shows it in the active (unlocked) viewer; the locked one keeps A.
|
||||
invoke(&mut h, "comp.open", json!({"comp": b}));
|
||||
@@ -90,7 +97,7 @@ fn new_viewers_lock_route_comps_and_activate_on_click() {
|
||||
h.state_mut().ui.locked_tabs.insert(PanelKind::Viewer(1).id());
|
||||
invoke(&mut h, "comp.open", json!({"comp": b}));
|
||||
assert_eq!(h.state().ui.active_viewer, 2);
|
||||
assert!(h.state().ui.dock.contains(PanelKind::Viewer(2)));
|
||||
assert!(h.state().ui.dock.is_docked(PanelKind::Viewer(2)));
|
||||
assert_eq!((shows(&h, 0), shows(&h, 1), shows(&h, 2)), (Some(a), Some(c), Some(b)));
|
||||
// Closing the active viewer forgets it; another open viewer takes over.
|
||||
h.state_mut().close_panel(PanelKind::Viewer(2));
|
||||
@@ -145,8 +152,14 @@ fn viewers_snapshot() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
invoke(&mut h, "view.newViewer", json!({}));
|
||||
assert!(h.state_mut().edit_layout(|l| l.dock(PanelKind::Viewer(1), PanelKind::Composition, Zone::Right)));
|
||||
h.state_mut().ui.dock.activate(PanelKind::Composition);
|
||||
h.state_mut()
|
||||
.apply_dock(craft_ui::docking::Action::Move {
|
||||
panel: PanelKind::Viewer(1),
|
||||
anchor: PanelKind::Composition,
|
||||
placement: craft_ui::docking::Placement::Split(craft_ui::docking::Zone::Right),
|
||||
})
|
||||
.unwrap();
|
||||
h.state_mut().apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Composition }).unwrap();
|
||||
invoke(&mut h, "comp.open", json!({"comp": other.0}));
|
||||
for _ in 0..300 {
|
||||
h.step();
|
||||
|
||||
@@ -5,27 +5,31 @@ use std::sync::Arc;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::config::{ConfigStore, DirConfig, MemoryConfig};
|
||||
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::state::Tool;
|
||||
use egui::{Event, Id, Key, Modifiers, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// The egui id of panel `p`'s dock tab.
|
||||
fn tab_id(p: PanelKind) -> Id {
|
||||
craft_ui::docking::DockArea::<PanelKind>::hit_id(effectcraft_ui_egui::dock_ui::area_id(), &craft_ui::docking::Hit::Tab(p))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_and_current_workspaces_survive_frontend_restart() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() });
|
||||
app.ui.dock = DockNode::Split {
|
||||
vertical: false,
|
||||
size: SplitSize::FixedA(345.0),
|
||||
a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }),
|
||||
b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Composition, PanelKind::Layer], active: 1 }),
|
||||
};
|
||||
app.ui.dock = dock::docked(dock::hsplit(
|
||||
SplitSize::FixedFirst(345.0),
|
||||
dock::tabs(&[PanelKind::Project], 0),
|
||||
dock::tabs(&[PanelKind::Composition, PanelKind::Layer], 1),
|
||||
));
|
||||
let ctx = egui::Context::default();
|
||||
effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name": "Restart layout"})).unwrap();
|
||||
let saved = app.ui.dock.clone();
|
||||
if let DockNode::Split { size, .. } = &mut app.ui.dock {
|
||||
*size = SplitSize::FixedA(390.0);
|
||||
if let Some(Node::Split { size, .. }) = &mut app.ui.dock.layout.root {
|
||||
*size = SplitSize::FixedFirst(390.0);
|
||||
}
|
||||
app.ui.focused = PanelKind::Layer;
|
||||
let current = app.ui.dock.clone();
|
||||
@@ -100,15 +104,18 @@ fn unreadable_native_workspace_is_preserved_after_later_layout_changes() {
|
||||
#[test]
|
||||
fn legacy_named_workspaces_migrate_once_and_survive_actual_app_restart() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let saved = effectcraft_ui_egui::dock::workspace("Minimal");
|
||||
store.write("workspaces.json", &json!({"workspaces":{"Legacy":saved},"floating":{}}).to_string()).unwrap();
|
||||
// An unversioned record from an earlier EffectCraft: its Minimal layout saved as "Legacy".
|
||||
let legacy = r#"{"floating":{},"workspaces":{"Legacy":{"Split":{"a":{"Tabs":{"active":0,"panels":["Composition"]}},"b":{"Tabs":{"active":0,"panels":["Timeline"]}},"size":{"Ratio":0.6000000238418579},"vertical":true}}}}"#;
|
||||
store.write("workspaces.json", legacy).unwrap();
|
||||
let saved = effectcraft_ui_egui::dock::Workspace::new(effectcraft_ui_egui::dock::workspace("Minimal"));
|
||||
let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() });
|
||||
assert_eq!(app.ui.saved_workspaces.get("Legacy"), Some(&saved));
|
||||
app.set_workspace("Legacy");
|
||||
let ctx = egui::Context::default();
|
||||
effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name":"Migrated"})).unwrap();
|
||||
let text = store.read("workspaces.json").unwrap();
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&text).unwrap()["version"], 1);
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&text).unwrap()["version"], 2);
|
||||
assert_eq!(store.read("workspaces.old.json").as_deref(), Some(legacy));
|
||||
let restarted = EffectcraftApp::new(Session { config: Some(store), ..Default::default() });
|
||||
assert_eq!(restarted.ui.saved_workspaces.get("Legacy"), Some(&saved));
|
||||
assert_eq!(restarted.ui.saved_workspaces.get("Migrated"), Some(&saved));
|
||||
@@ -166,19 +173,19 @@ fn tab_traversal_activates_an_inactive_dock_tab_without_editing_the_project() {
|
||||
app.ui.start_screen = false;
|
||||
app.ui.show_menu_bar = false;
|
||||
// Leave the inactive, layer-named tab visible instead of behind the overflow control.
|
||||
if let DockNode::Split { a, .. } = &mut app.ui.dock
|
||||
&& let DockNode::Split { a, .. } = a.as_mut()
|
||||
&& let DockNode::Split { size, .. } = a.as_mut()
|
||||
if let Some(Node::Split { first, .. }) = &mut app.ui.dock.layout.root
|
||||
&& let Node::Split { first, .. } = first.as_mut()
|
||||
&& let Node::Split { size, .. } = first.as_mut()
|
||||
{
|
||||
*size = SplitSize::FixedA(420.0);
|
||||
*size = SplitSize::FixedFirst(420.0);
|
||||
}
|
||||
let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find("panel.tab.EffectControls").is_some(), "the inactive tab must be visible in this fixture");
|
||||
let before = h.state().ui.dock.clone();
|
||||
let target = Id::unique(("tab", "aaa".to_string(), 1usize));
|
||||
let target = tab_id(PanelKind::EffectControls);
|
||||
let mut tabs = 0;
|
||||
for _ in 0..80 {
|
||||
for _ in 0..200 {
|
||||
key(&mut h, Key::Tab, Modifiers::NONE);
|
||||
tabs += 1;
|
||||
if h.ctx.memory(|m| m.focused()) == Some(target) {
|
||||
@@ -188,24 +195,26 @@ fn tab_traversal_activates_an_inactive_dock_tab_without_editing_the_project() {
|
||||
assert_eq!(h.ctx.memory(|m| m.focused()), Some(target));
|
||||
println!("Tab presses from no focus to Effect Controls: {tabs}");
|
||||
assert_eq!(h.state().ui.dock, before, "traversal alone must not activate a tab");
|
||||
// craft-ui outlines the keyboard-focused tab, inset 2 pt, in the dock's accent (EffectCraft's
|
||||
// focus colour).
|
||||
let response = h.ctx.read_response(target).unwrap();
|
||||
assert!(
|
||||
h.output().shapes.iter().any(|shape| {
|
||||
matches!(&shape.shape, egui::Shape::Rect(r) if r.rect == response.rect.shrink(2.0) && r.stroke.color == h.state().tokens.focus && r.stroke.width > 0.0)
|
||||
}),
|
||||
"the dock tab must visibly show keyboard focus"
|
||||
);
|
||||
key(&mut h, Key::Space, Modifiers::NONE);
|
||||
let mut activated = before;
|
||||
assert!(activated.activate(PanelKind::EffectControls));
|
||||
activated.apply(craft_ui::docking::Action::Activate { panel: PanelKind::EffectControls }, dock::permissions).unwrap();
|
||||
assert_eq!(h.state().ui.dock, activated);
|
||||
assert_eq!(h.state().ui.focused, PanelKind::EffectControls);
|
||||
assert!(!h.state().playback.playing);
|
||||
assert!(Arc::ptr_eq(&h.state().session.project, &project));
|
||||
let response = h.ctx.read_response(target).unwrap();
|
||||
assert!(
|
||||
h.output().shapes.iter().any(|shape| {
|
||||
matches!(&shape.shape, egui::Shape::Rect(r) if r.rect == response.rect.shrink(1.0) && r.stroke.color == h.state().tokens.text && r.stroke.width > 0.0)
|
||||
}),
|
||||
"the active dock tab must visibly retain keyboard focus"
|
||||
);
|
||||
let inspected = effectcraft_ui_egui::control::inspect(h.state(), &h.ctx);
|
||||
assert_eq!(inspected["keyboardFocus"]["automationId"], "panel.tab.EffectControls");
|
||||
key(&mut h, Key::Tab, Modifiers::SHIFT);
|
||||
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::unique(("tab", "aaa".to_string(), 0usize))));
|
||||
assert_eq!(h.ctx.memory(|m| m.focused()), Some(tab_id(PanelKind::Project)));
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert_eq!(h.state().ui.focused, PanelKind::Project);
|
||||
assert!(!h.state().playback.playing);
|
||||
@@ -284,8 +293,8 @@ fn control_root_divider_drag_after_toolbar_and_dock_tab_keyboard_activation() {
|
||||
d.ok("ui.click", json!({"id":"tools.Selection"}), true);
|
||||
// The toolbar can grow as upstream adds controls. Traverse to the actual response
|
||||
// identity rather than assuming a fixed number of focus stops.
|
||||
let target = Id::unique(("tab", "aaa".to_string(), 1usize));
|
||||
for tabs in 1..=100 {
|
||||
let target = tab_id(PanelKind::EffectControls);
|
||||
for tabs in 1..=200 {
|
||||
d.ok("ui.key", json!({"key":"Tab"}), true);
|
||||
assert!(d.h.state().ui.mini_flowchart.is_none(), "dock-tab traversal must not open a foreground popup");
|
||||
if d.h.ctx.memory(|memory| memory.focused()) == Some(target) {
|
||||
@@ -480,7 +489,7 @@ fn failed_save_or_delete_of_a_saved_workspace_keeps_the_file() {
|
||||
let saved = app.ui.dock.clone();
|
||||
let before = store.read("workspaces.json").unwrap();
|
||||
store.full.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
app.ui.dock.close(PanelKind::Timeline);
|
||||
app.close_panel(PanelKind::Timeline);
|
||||
let error = invoke(&mut app, "window.saveWorkspace", json!({})).unwrap_err();
|
||||
assert!(error.contains("synthetic full disk") && error.contains("may be lost after a restart"), "{error}");
|
||||
assert!(invoke(&mut app, "window.editWorkspaces", json!({"name": "Kept", "delete": true})).is_err());
|
||||
|
||||
+59
-16
@@ -186,20 +186,63 @@ including outside the layer's `[in, out)` range. Timing metadata carries outside
|
||||
`prop.get.time` is comp seconds. For these commands, `path` or `prop` identifies a property, not a group.
|
||||
Set a layer's offset with `layer.timing {layer, start}` (comp seconds).
|
||||
|
||||
Panel moves, tab ordering, edge splits and floating geometry use shared docking transactions.
|
||||
`window.floatPanel` requires exactly four finite numbers when `rect` is supplied. Invalid
|
||||
geometry or missing source/anchor panels leaves the layout unchanged. The persisted workspace
|
||||
shape is unchanged: accordion open states and explicit heights, floating groups, and dynamic
|
||||
viewer/ScriptUI identities remain app data. Pointer drops use the same shared placement rules
|
||||
as commands, with floating groups resolved in their actual window stacking order.
|
||||
Panels dock in craft-ui's shared docking (`craft_ui::docking`): split frames, tab groups,
|
||||
stacked panels and floating windows. Every change, by pointer, menu or command, is one
|
||||
transaction that checks the whole result; an invalid one returns an error and leaves the layout
|
||||
unchanged. No move or float may leave the docked area empty; closing its last panel is allowed.
|
||||
Panels name themselves by id (`"EffectControls"`), by title (`"Effect Controls"`) where a command
|
||||
takes a name, or as `{"Viewer":42}` and `{"ScriptPanel":77}` for viewers and ScriptUI panels.
|
||||
|
||||
`window.dockAction` accepts `{action}` using the shared docking action's JSON form. It
|
||||
covers tab moves and ordering, split and floating geometry, activation, and closing.
|
||||
For example, `{"action":{"ResizeSplit":{"path":[],"size":{"FixedFirst":360}}}}`
|
||||
resizes the root split; path entries use `false` for the first child and `true` for
|
||||
the second. `{"action":{"MoveFloating":{"panel":"Info","rect":[45,65,420,320]}}}`
|
||||
repositions and resizes the floating group containing Info. Invalid actions leave the
|
||||
layout unchanged. Pointer gestures dispatch through this same catalog command.
|
||||
EffectCraft also supports `SetStackOpen` and `ResizeStack`. Dynamic panel identities use
|
||||
`{"Viewer":42}` or `{"ScriptPanel":77}` in the action. Closing runs the existing viewer
|
||||
and ScriptUI lifecycle callbacks; opening uses the existing panel creation commands.
|
||||
- `window.dockAction {action}` applies one craft-ui `Action` in its JSON form. For example,
|
||||
`{"action":{"ResizeSplit":{"path":[],"size":{"FixedFirst":360}}}}` resizes the root split
|
||||
(path entries are `false` for the first child and `true` for the second), and
|
||||
`{"action":{"MoveFloating":{"panel":"Info","rect":[45,65,420,320]}}}` moves and resizes the
|
||||
floating window holding Info. `Open` and `OpenAt` are refused: panels open through the panel
|
||||
commands (`window.panel`, `ui.panel.show`), which run the viewer and ScriptUI lifecycles.
|
||||
Closing runs them too. Pointer gestures dispatch through this command.
|
||||
- craft-ui's commands: `window.panel.move`, `.float`, `.dock`, `.activate`, `.close`,
|
||||
`.closeGroup`, `.closeOthers`, `.minimize`, `.iconic`, `.window` and `.layout`, with the params
|
||||
of craft-ui's `DockCommand` (`docs/ui-docking.md` in craft-libs). `window.panel.<Panel>`
|
||||
(`window.panel.Align`) opens and shows that panel. `window.panel.activate` on a closed panel
|
||||
reopens it where it was.
|
||||
- `ui.dock {operation, panel, …}` or `ui.dock {action}`: craft-ui's `ui.dock` operations
|
||||
(`open`, `close`, `activate`, `float {rect}`, `moveFloating {rect}`,
|
||||
`move {target, zone?, before?}`, `setStackOpen {open}`, `resizeStack {height}`,
|
||||
`resizeSplit {path, size}`; `rect` is `[x, y, width, height]`). It returns
|
||||
`{"docking": workspace}`. `ui.dock.reset` returns the current workspace to its saved or
|
||||
built-in layout and returns the same.
|
||||
- `window.dockPanel {panel, anchor?, zone?, before?}` docks as `window.panel.dock`: `zone` is
|
||||
`center`, `tab`, `left`, `right`, `top`, `bottom`, `above` or `below`; without an anchor a
|
||||
side zone docks along that edge of the whole workspace. `window.floatPanel {panel, rect?}`
|
||||
requires exactly four finite numbers when `rect` is given and otherwise floats a 420x320
|
||||
window in the middle of the app window. `window.closePanel {panel}` closes it.
|
||||
- `window.panelWindow {panel, window?}` puts a panel in a floating window of its own with a
|
||||
desktop place over the panel's place in the main window, and returns `{"window": bool}`. A
|
||||
docked panel floats first. `window` false docks it again, as a tab of the first docked group;
|
||||
without `window` it toggles.
|
||||
|
||||
On desktop every floating window is an OS window of its own (craft-ui's `native-windows`), which
|
||||
can go on another monitor; on Wayland, on the web and where egui embeds viewports, floating
|
||||
windows stay in the main window. Keys pressed in a floating window that none of its widgets uses
|
||||
run the main window's shortcuts, and files dropped on one import as on the main window. While a
|
||||
modal dialog is open, floating windows take no input.
|
||||
|
||||
The automation ids of the docking chrome are `panel.tab.<Panel>`, `panel.drag.<Panel>` (a
|
||||
group's drag bar), `panel.tabs.more.<Panel>`, `panel.menu.<Panel>` (≡), `panel.header.<Panel>`
|
||||
(a stacked panel's open and close button), `panel.tab.<Panel>.lock` (viewer lock),
|
||||
`panel.tab.Timeline.<comp>` and `panel.tab.Timeline.close` (the Timeline's comp tabs),
|
||||
`panel.float.<n>.move`, `.close`, `.resize` and `.resize.<Edge>` for floating window `n`,
|
||||
`dock.gutter.<path>` for dividers (`a`/`b` per split level; a stack's gap below entry `i` adds
|
||||
`s<i>`) and `dock.corner.<outer>.<inner>` where two dividers meet.
|
||||
|
||||
Saved workspaces (`workspaces.json`, version 2) hold craft-ui `Workspace`s: the layout, the
|
||||
closed panels with where each goes back, the lock, and each floating window's desktop place. A
|
||||
version 1 file (EffectCraft 0.10 and earlier) and the older unversioned record of
|
||||
named workspaces load with every panel in place: the current layout, the named workspaces,
|
||||
floating windows and their native window places. A column of stacked frames becomes tab groups
|
||||
split top to bottom: each open frame keeps all its tabs and its height (as a fixed size), and a
|
||||
closed frame's tabs join the open frame above it. A native window's place moves from the OS
|
||||
window's outer position to its content's, by the main window's title bar, before the first save.
|
||||
Panels that were closed stay closed and reopen at their default place. The first save writes version 2 and keeps
|
||||
the earlier file as `workspaces.old.json`. A damaged, unknown or newer file loads as the default
|
||||
layout and is left unchanged.
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ are grouped by area; the issue tracker has the rest.
|
||||
| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) |
|
||||
| 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) |
|
||||
| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) |
|
||||
| 20 | **Multi-monitor and video output** | #486: any panel can move into a native window of its own (panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so onto another screen, restored with the workspace; still missing: dragging tabs between those windows, app shortcuts while one has the focus, named workspaces remembering them, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |
|
||||
| 20 | **Multi-monitor and video output** | #486: on Windows, macOS and X11 every floating panel is an OS window of its own (Undock Panel, a drag out of the main window, panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so can go onto another screen (on Wayland, the web and in embedded viewports floating panels stay in the app window, since Wayland reports no window positions); tabs and groups drag between windows, shortcuts work in them, and the current and named workspaces restore their places; still missing: Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |
|
||||
| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), the rebuilt Effect Controls (26.5). Percentage guides (View ▸ Add Percentage Guide, Convert Guides to Percentage / Pixels) and effect colour labels (Effect Controls header bar and right-click Label menu, `effect.setLabel`) are in (#1095), built from the release notes' one-line descriptions and never compared with After Effects: the Timeline's effect rows wear the label as a colour bar on the left edge (#1509), but After Effects' exact UI there is unknown | Users of the current version expect them | 10–20 h left | — · [ui-parity.md](ui-parity.md) |
|
||||
| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372 closed by #778: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) |
|
||||
| 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 and #611 closed | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) |
|
||||
|
||||
+3
-4
@@ -16,7 +16,7 @@ feel and platform differences, and the 26.x interface changes are not in yet.
|
||||
| Area | Status | % | Evidence and gaps |
|
||||
|---|---|---:|---|
|
||||
| Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) |
|
||||
| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; panels move into their own native windows on any screen (#486 open: no dragging tabs between those windows); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed |
|
||||
| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; on Windows, macOS and X11 floating panels are OS windows of their own on any screen (on Wayland, the web and in embedded viewports they stay in the app window), with tabs and groups dragging between windows and shortcuts working in them (#486); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed |
|
||||
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a hidden null, camera or light has no box or handles in the viewer and isn't picked there; other selected hidden layers keep their layer controls and paths, #1769); a row control (a switch, the label swatch, a Modes cell) clicked on one of several selected layers acts on the whole selection, with the layers the switch doesn't apply to left out (#1849); a shape group's inner Contents (After Effects' elided Vectors group) draws no row — the group's path, fill/stroke and Transform list directly under it |
|
||||
| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu |
|
||||
| Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed |
|
||||
@@ -41,9 +41,8 @@ cover shape paths and masks, corner-vertex dragging and undo, and layers without
|
||||
|
||||
## Estimate
|
||||
|
||||
**≈ 60–120 h**: the open interaction issues above (25–45 h, about 0.2–0.5 h each with a scripted
|
||||
test), 3D gizmos 8–15 h, multi-monitor panels 10–20 h, the 26.x interface changes 10–20 h, an
|
||||
accessibility pass 15–30 h. Checking feel against After Effects needs the owner's Mac with After
|
||||
**≈ 60–110 h**: the open interaction issues above (25–45 h, about 0.2–0.5 h each with a scripted
|
||||
test), 3D gizmos 8–15 h, the 26.x interface changes 10–20 h, an accessibility pass 15–30 h. Checking feel against After Effects needs the owner's Mac with After
|
||||
Effects running; most of the rest parallelises.
|
||||
|
||||
## Revision history
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const REV: &str = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef";
|
||||
const REV: &str = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f";
|
||||
const PACKAGES: &[(&str, &str)] = &[("craft-pdf", "0.1.0"), ("hayro-syntax", "0.7.2"), ("hayro-jbig2", "0.3.0")];
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
|
||||
Reference in New Issue
Block a user