diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 549e3ecbc..8ba7f1a01 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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"] } sys-locale = { workspace = true } [dev-dependencies] diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index d2fb9a9e1..185aab781 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -410,7 +410,7 @@ pub trait WorkspaceExt { fn panels(&self) -> Vec; } -fn node_panels(node: &Node, out: &mut Vec) { +pub(crate) fn node_panels(node: &Node, out: &mut Vec) { match node { Node::Split { first, second, .. } => { node_panels(first, out); diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index f8fffd84c..e8594893c 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -226,17 +226,18 @@ impl EffectcraftApp { pos.and_then(|p| self.dock_rects.iter().rev().find(|(_, r)| r.contains(p)).map(|(k, _)| *k)).unwrap_or(self.ui.focused) } - /// Whether panel `p`'s floating window is recorded as its own native window. + /// Whether panel `p` is in a floating window with a desktop place: its own OS window on + /// desktop (craft-ui `native-windows`); on the web and in embedded viewports the window + /// stays in the page and the place is kept for a desktop session. pub fn in_panel_window(&self, p: PanelKind) -> bool { self.ui.dock.layout.floating.iter().any(|w| w.contains(&p) && w.native.is_some()) } - /// Move panel `p`'s floating window into its own native window, which can be placed on - /// another monitor, or back into the main window (`window` false). `None` toggles. A docked - /// panel is undocked first. The window's place is over the panel's place in the main window. - /// Returns whether `p` is now in a window of its own. - // D2: craft-ui's `native-windows` feature draws these windows; until then the place is - // recorded and the window is drawn in the main window. + /// Move panel `p` into a floating window of its own, which on desktop is an OS window that + /// can go on another monitor, or back into the main window (`window` false), where it joins + /// the first docked group. `None` toggles. A docked panel is undocked first, and its window + /// opens over the panel's place in the main window. Returns whether `p` is now in a window + /// of its own. pub fn set_panel_window(&mut self, ctx: &egui::Context, p: PanelKind, window: Option) -> Result { let current = self.in_panel_window(p); let window = window.unwrap_or(!current); @@ -244,7 +245,21 @@ impl EffectcraftApp { return Ok(current); } if !window { - self.apply_dock(Action::SetNative { panel: p, rect: None })?; + // On desktop every floating window is an OS window, so the way back is docking. + let anchor = self.ui.dock.layout.root.as_ref().and_then(|root| { + let mut panels = Vec::new(); + dock::node_panels(root, &mut panels); + panels.into_iter().find(|panel| *panel != p) + }); + let actions = match anchor { + Some(anchor) => vec![Action::Move { panel: p, anchor, placement: craft_ui::docking::Placement::Tab { before: None } }], + // Nothing is docked: the panel becomes the docked layout. + None => vec![Action::Close { panel: p }, Action::Open { panel: p, anchor: None }], + }; + let mut workspace = self.ui.dock.clone(); + // One transaction, so the panel is never reported closed and keeps its state. + let closed = workspace.apply_all(actions, dock::permissions).map_err(|_| format!("can't dock {}", p.title()))?; + self.commit_workspace(workspace, &closed, true); return Ok(false); } if !self.ui.dock.is_floating(p) { @@ -375,7 +390,11 @@ impl EffectcraftApp { } let style = dock_style(ui, &t); let maximized = self.ui.maximized.filter(|m| self.ui.dock.is_docked(*m)); - let mut area = DockArea::new(area_id()).float_bounds(body).maximized(maximized).locked(self.ui.dock.locked); + let mut area = DockArea::new(area_id()) + .float_bounds(body) + .maximized(maximized) + .locked(self.ui.dock.locked) + .input_blocked(self.dialog.is_some() || panels::dialogs::modal_open(&ctx)); if let Some(panel) = self.pending_panel_raise.take() { area = area.raise_panel(panel); } @@ -396,9 +415,10 @@ impl EffectcraftApp { menu: None, bodies: Vec::new(), }; - let output = area.show_content(&mut child, &layout, &style, &mut content); + let mut output = area.show_content(&mut child, &layout, &style, &mut content); let EffectContent { doc_actions, locks, menu, bodies, .. } = content; self.dock_rects = bodies; + self.forward_window_input(&ctx, std::mem::take(&mut output.forwarded_events), std::mem::take(&mut output.window_files)); if let Some(panel) = output.focused { self.focus_panel(&ctx, panel); } @@ -446,6 +466,20 @@ impl EffectcraftApp { panels::panel_menu_popup(self, ui); } + /// Keys and dropped files that reach a floating panel's own OS window go to the main + /// window's next frame ([`crate::EffectcraftApp::raw_input_hook`]), so its shortcuts and + /// file import handle them. Text is not forwarded, and nothing is while a text field in + /// the main window has the keyboard. + fn forward_window_input(&mut self, ctx: &egui::Context, events: Vec, files: Vec>) { + let typing = ctx.egui_wants_keyboard_input(); + let events = events.into_iter().filter(|event| !typing && !matches!(event, egui::Event::Text(_) | egui::Event::Ime(_))); + self.forwarded.events.extend(events); + self.forwarded.files.extend(files.into_iter().flat_map(|window| window.dropped)); + if !self.forwarded.events.is_empty() || !self.forwarded.files.is_empty() { + ctx.request_repaint(); + } + } + /// Automation ids for craft-ui's gesture sources: `panel.tab.`, `panel.drag.` /// (the group's drag bar), `panel.border.` (the top border above its tabs), /// `panel.tabs.more.`, `panel.header.` (a stacked panel's open/close button), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 92e8d0248..d19759947 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -278,6 +278,8 @@ pub struct EffectcraftApp { pub(crate) control_rx: Option>, pub(crate) deferred: Vec<(ControlRequest, f64)>, pub(crate) synthetic: Vec, + /// Input from floating panels' own OS windows for the main window's next frame. + pub(crate) forwarded: ForwardedInput, pub(crate) input_waiters: Vec>, /// Control requests waiting for an offloaded job (`wait: true` in the browser). pub(crate) job_waiters: Vec<(control::JobWaiter, Sender)>, @@ -381,6 +383,7 @@ impl EffectcraftApp { control_rx: None, deferred: vec![], synthetic: vec![], + forwarded: ForwardedInput::default(), input_waiters: vec![], job_waiters: vec![], queued_screenshots: vec![], @@ -1874,6 +1877,13 @@ impl EffectcraftApp { } } +/// Keys and dropped files from floating panels' OS windows (craft-ui `DockOutput`). +#[derive(Default)] +pub(crate) struct ForwardedInput { + pub(crate) events: Vec, + pub(crate) files: Vec, +} + impl eframe::App for EffectcraftApp { fn on_exit(&mut self) { self.save_workspace_state(); @@ -1885,6 +1895,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.is_empty(); self.feed_synthetic(raw_input); if pointer_wrap::prepare_input(ctx, raw_input, injected) { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 07b257e29..d224d1a0f 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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::new("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::(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::new(("modal", title)); // Dragging the title row moves the dialog off what it edits. The offset is kept within the @@ -687,6 +701,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()); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 50ecf2f7f..9ee05e619 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -234,9 +234,8 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { 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. - // D2: craft-ui's native windows draw it; until then the place is recorded and the - // panel floats in the main window. + // 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() { diff --git a/crates/ui-egui/src/workspace_state.rs b/crates/ui-egui/src/workspace_state.rs index 01018bc6f..fe522075a 100644 --- a/crates/ui-egui/src/workspace_state.rs +++ b/crates/ui-egui/src/workspace_state.rs @@ -8,7 +8,11 @@ use serde::{Deserialize, Serialize}; 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; /// Leave room for new viewers without approaching the u32 allocator limit. @@ -94,23 +98,27 @@ 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, pub(crate) error: Option, } impl WorkspaceStore { pub(crate) fn load(config: Option<&dyn ConfigStore>, ui: &mut UiState) -> Self { + let mut original = None; 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())?; - // D2: migrate the version 1 snapshot (EffectCraft's own dock tree, craft-libs - // 5c874d51) and the unversioned `{workspaces, floating}` record here. Until then - // they load as an error: this session keeps the default layout and leaves the - // file untouched. - let version = value.get("version").and_then(serde_json::Value::as_u64); - if version.is_none_or(|v| v < u64::from(VERSION)) { - return Err("this workspace file is from an earlier EffectCraft version".to_string()); - } - let snapshot = serde_json::from_value::(value).map_err(|e| e.to_string())?; + let snapshot = if legacy::is_legacy(&value) { + let snapshot = legacy::migrate(value, ui)?; + original = Some(text); + snapshot + } else if value.get("version").is_none() { + return Err("not a workspace file".to_string()); + } else { + serde_json::from_value::(value).map_err(|e| e.to_string())? + }; snapshot.validate()?; Ok(snapshot) }) @@ -127,10 +135,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 { @@ -145,7 +154,8 @@ 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.focused != ui.focused || self.saved.maximized != ui.maximized @@ -163,7 +173,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) @@ -200,14 +214,35 @@ mod tests { } #[test] - fn corrupt_future_or_earlier_workspace_keeps_defaults_and_original_file() { + fn corrupt_future_or_unknown_workspace_keeps_defaults_and_original_file() { let valid = Snapshot::capture(&UiState::default()); - // An earlier version's file (EffectCraft's own dock tree) waits for its migration. - let earlier = r#"{"version":1,"workspace":"Default","dock":{"Tabs":{"panels":["Composition"],"active":0}},"floating":[],"focused":"Composition","maximized":null,"saved_workspaces":{},"saved_floating":{}}"#; - let legacy = r#"{"workspaces":{"Mine":{"Tabs":{"panels":["Composition"],"active":0}}}}"#; - for text in - ["{".into(), serde_json::to_string(&Snapshot { version: VERSION + 1, ..valid }).unwrap(), " ".repeat(MAX_BYTES + 1), earlier.into(), legacy.into()] - { + // 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(); @@ -220,9 +255,93 @@ mod tests { 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])), + ), + crate::dock::stack(&[(Properties, true, None)]), + ); + assert_eq!(ui.dock.layout.root, Some(root)); + let floating: Vec<_> = ui.dock.layout.floating.iter().map(|w| (w.panels().copied().collect::>(), 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 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); + 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::(&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}]}}}, + "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)); + let stack = crate::dock::stack(&[(PanelKind::Info, true, None), (PanelKind::Audio, false, None), (PanelKind::Preview, true, Some(80.0))]); + assert_eq!(ui.saved_workspaces.get("Mine").and_then(|w| w.layout.root.clone()), Some(stack)); + assert_eq!(ui.saved_workspaces.len(), 1, "floating groups of no named workspace are dropped"); + } + #[test] fn explicit_save_without_a_settings_folder_is_an_error() { let mut ui = UiState::default(); diff --git a/crates/ui-egui/src/workspace_state/legacy.rs b/crates/ui-egui/src/workspace_state/legacy.rs new file mode 100644 index 000000000..ed969575a --- /dev/null +++ b/crates/ui-egui/src/workspace_state/legacy.rs @@ -0,0 +1,179 @@ +//! 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 stacked frame with several tabs becomes one stacked panel per tab; the shown tab keeps +//! the frame's open state and height, the others are collapsed. +//! - A floating group in its own native window (`panel_windows`) keeps that place as the +//! window's native rect. +//! - 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, StackEntry}; +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, + #[serde(default)] + panels: Vec, + #[serde(default)] + active: usize, + open: bool, + #[serde(default)] + height: Option, +} + +#[derive(Deserialize)] +enum OldNode { + Split { vertical: bool, size: OldSplitSize, a: Box, b: Box }, + Tabs { panels: Vec, active: usize }, + Stack { entries: Vec }, +} + +#[derive(Deserialize)] +struct OldFloating { + panels: Vec, + 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, + focused: PanelKind, + maximized: Option, + saved_workspaces: BTreeMap, + saved_floating: BTreeMap>, + #[serde(default)] + panel_windows: Vec, +} + +#[derive(Deserialize)] +struct OldRecord { + #[serde(default)] + workspaces: BTreeMap, + #[serde(default)] + floating: BTreeMap>, +} + +/// 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. `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 { + 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); + } + let old: OldSnapshot = serde_json::from_value(value).map_err(|e| e.to_string())?; + Ok(Snapshot { + version: VERSION, + workspace: old.workspace, + dock: workspace(old.dock, old.floating, &old.panel_windows)?, + focused: old.focused, + maximized: old.maximized, + saved_workspaces: named(old.saved_workspaces, old.saved_floating)?, + }) +} + +/// 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, mut floating: BTreeMap>) -> Result, 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, windows: &[OldPanelWindow]) -> Result { + // 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, 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 } => { + let mut out = Vec::new(); + for entry in entries { + let mut panels = entry.panels; + if let Some(panel) = entry.panel + && !panels.contains(&panel) + { + panels.insert(0, panel); + } + let shown = panels.get(entry.active).or(panels.first()).copied(); + for panel in panels { + let front = Some(panel) == shown; + out.push(StackEntry { panel, open: front && entry.open, height: entry.height.filter(|_| front) }); + } + } + Node::Stack { entries: out } + } + }) +} diff --git a/crates/ui-egui/tests/fixtures/workspaces-v1.json b/crates/ui-egui/tests/fixtures/workspaces-v1.json new file mode 100644 index 000000000..164bf37f0 --- /dev/null +++ b/crates/ui-egui/tests/fixtures/workspaces-v1.json @@ -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 + ] + } + ] +} \ No newline at end of file diff --git a/crates/ui-egui/tests/it/ui_panel_windows.rs b/crates/ui-egui/tests/it/ui_panel_windows.rs index 80a3f5eea..d2750bb05 100644 --- a/crates/ui-egui/tests/it/ui_panel_windows.rs +++ b/crates/ui-egui/tests/it/ui_panel_windows.rs @@ -1,7 +1,7 @@ -//! Multiple monitors (#486): Move Panel to New Window records a floating panel's window as its -//! own native window, which can be placed on another screen, and back. -// D2: craft-ui's `native-windows` feature draws these windows; until then they are drawn in the -// main window and only their place is recorded and saved. +//! 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, WorkspaceExt as _}; @@ -31,9 +31,10 @@ fn panel_window_command_moves_a_docked_panel_into_its_own_window_and_back() { // Asking again changes nothing; no `window` toggles. assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project", "window": true})).unwrap(), json!({"window": true})); 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.dock.is_floating(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. @@ -65,3 +66,41 @@ fn panel_windows_survive_restart_with_hostile_places_made_safe() { assert!(x <= 100_000.0 && y >= -100_000.0 && (160.0..=16_384.0).contains(&w) && (100.0..=16_384.0).contains(&h)); 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)); +} diff --git a/crates/ui-egui/tests/it/ui_workspace_focus.rs b/crates/ui-egui/tests/it/ui_workspace_focus.rs index 7069b9aa1..8ec63092d 100644 --- a/crates/ui-egui/tests/it/ui_workspace_focus.rs +++ b/crates/ui-egui/tests/it/ui_workspace_focus.rs @@ -102,18 +102,20 @@ fn unreadable_native_workspace_is_preserved_after_later_layout_changes() { } #[test] -#[ignore = "D2: the migration of workspace files written with craft-libs 5c874d51"] fn legacy_named_workspaces_migrate_once_and_survive_actual_app_restart() { let store = Arc::new(MemoryConfig::default()); + // 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")); - store.write("workspaces.json", &json!({"workspaces":{"Legacy":saved},"floating":{}}).to_string()).unwrap(); 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::(&text).unwrap()["version"], 1); + assert_eq!(serde_json::from_str::(&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)); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 167f8e888..49cdca8ea 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -216,9 +216,16 @@ takes a name, or as `{"Viewer":42}` and `{"ScriptPanel":77}` for viewers and Scr 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?}` records a floating panel's window as its own native - window (`window` false moves it back; without `window` it toggles). A docked panel floats - first. The window is drawn in the main window in this version. +- `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.drag.` (a group's drag bar), `panel.tabs.more.`, `panel.menu.` (≡), `panel.header.` @@ -229,5 +236,11 @@ group's drag bar), `panel.tabs.more.`, `panel.menu.` (≡), `panel `s`) and `dock.corner..` 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, and the lock. A file from an earlier version loads as -the default layout and is left unchanged. +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 stacked frame with several tabs becomes one +stacked panel per tab, the shown one keeping the frame's open state and height. 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. diff --git a/docs/gaps.md b/docs/gaps.md index f8fcd64c2..dc2af3cd0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 desktop 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; 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) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index df4c5e0ea..55c6b5cdb 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 desktop floating panels are OS windows of their own on any screen, 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 |