diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 3e802487..dfdd01b5 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -670,6 +670,37 @@ pub struct Floating { pub rect: [f32; 4], } +/// A floating group moved out of the main window into its own native window (which can sit on +/// another monitor): the group holding `panel` shows there. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct PanelWindow { + pub panel: PanelKind, + /// The window's [x, y] outer position and [w, h] inner size, in desktop points. + pub rect: [f32; 4], +} + +/// The smallest and largest native panel window, in points. +pub const PANEL_WINDOW_MIN: [f32; 2] = [160.0, 100.0]; +pub const PANEL_WINDOW_MAX: f32 = 16_384.0; +/// The farthest a native panel window may sit from the desktop origin, in points. +pub const PANEL_WINDOW_MAX_POS: f32 = 100_000.0; + +impl PanelWindow { + /// The rect made safe to hand to the windowing system: finite, with a size within + /// [`PANEL_WINDOW_MIN`]..=[`PANEL_WINDOW_MAX`] and a position within + /// ±[`PANEL_WINDOW_MAX_POS`] (hostile workspace files and bad monitor reports included). + pub fn sanitized(rect: [f32; 4]) -> [f32; 4] { + let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; + let [x, y, w, h] = rect; + [ + finite(x, 0.0).clamp(-PANEL_WINDOW_MAX_POS, PANEL_WINDOW_MAX_POS), + finite(y, 0.0).clamp(-PANEL_WINDOW_MAX_POS, PANEL_WINDOW_MAX_POS), + finite(w, 420.0).clamp(PANEL_WINDOW_MIN[0], PANEL_WINDOW_MAX), + finite(h, 320.0).clamp(PANEL_WINDOW_MIN[1], PANEL_WINDOW_MAX), + ] + } +} + impl DockNode { /// Insert panel `p` relative to the group that holds `anchor`: as a tab next to it /// (Center) or in a new group split off to one side. In a stack, the group is `anchor`'s diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 9c28f353..dc697baa 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -2,11 +2,13 @@ //! and body, floating (undocked) panel groups over it, and tab drag-and-drop: drag a tab onto a //! group to tab it there (center) or split it off to a side (After Effects' drop zones, with a //! highlight of where it will land); hold Cmd/Ctrl when releasing, or drop outside every group, -//! to float it. `` ` `` maximizes the panel under the pointer. +//! to float it. `` ` `` maximizes the panel under the pointer. A floating group can also move +//! out of the main window into its own native window ([`EffectcraftApp::set_panel_window`]), +//! which can sit on another monitor. use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; -use crate::dock::{self, DockAction, DockNode, Group, Layout, PanelKind}; +use crate::dock::{self, DockAction, DockNode, Group, Layout, PanelKind, PanelWindow}; use crate::{EffectcraftApp, panels}; use craft_ui::docking::{Action, DropTarget, resolve_drop}; @@ -245,7 +247,15 @@ impl EffectcraftApp { }) .unwrap_or(usize::MAX) }); + // A native window is kept only for a panel that still floats. + let ui_state = &mut self.ui; + ui_state.panel_windows.retain(|w| ui_state.floating.iter().any(|group| group.panels.contains(&w.panel))); for i in floating_order { + // Groups in their own native window take no tab drops from the main window. + if let Some(window) = self.ui.floating.get(i).and_then(|group| self.panel_window_of(&group.panels)) { + self.panel_window(&ctx, i, window, &info, &mut actions); + continue; + } if let Some((g, tabs)) = self.floating_window(&ctx, i, &info, &mut actions) { tab_rects.push((g.path.clone(), tabs)); float_groups.push(g); @@ -376,6 +386,135 @@ impl EffectcraftApp { group } + /// The native window a floating group (its `panels`) shows in, if any. + pub(crate) fn panel_window_of(&self, panels: &[PanelKind]) -> Option { + self.ui.panel_windows.iter().find(|w| panels.contains(&w.panel)).copied() + } + + /// Whether panel `p` shows in its own native window. + pub fn in_panel_window(&self, p: PanelKind) -> bool { + self.ui.floating.iter().any(|group| group.panels.contains(&p) && self.panel_window_of(&group.panels).is_some()) + } + + /// The viewport panel `p` is drawn in: its native window's, or the main window's (also when + /// the platform embeds viewports, as the browser build does). + pub(crate) fn panel_viewport(&self, ctx: &egui::Context, p: PanelKind) -> egui::ViewportId { + if ctx.embed_viewports() { + return egui::ViewportId::ROOT; + } + self.ui + .floating + .iter() + .find(|group| group.panels.contains(&p)) + .and_then(|group| self.panel_window_of(&group.panels)) + .map_or(egui::ViewportId::ROOT, |w| panel_window_id(w.panel)) + } + + /// Move panel `p` (and the floating group it is in) into its own native window, which can be + /// placed on another monitor, or back into the main window (`window` false; it floats there + /// where it was before). `None` toggles. A docked panel is undocked first. The window opens + /// over the place the panel had in the main window. Returns whether `p` is now in a window. + pub fn set_panel_window(&mut self, ctx: &egui::Context, p: PanelKind, window: Option) -> Result { + let group_of = |app: &Self| app.ui.floating.iter().find(|group| group.panels.contains(&p)).map(|group| (group.panels.clone(), group.rect)); + let current = self.in_panel_window(p); + let window = window.unwrap_or(!current); + if window == current { + return Ok(current); + } + if !window { + if let Some((panels, _)) = group_of(self) { + self.ui.panel_windows.retain(|w| !panels.contains(&w.panel)); + } + return Ok(false); + } + if group_of(self).is_none() { + if !self.ui.dock.contains(p) { + return Err(format!("{} is not open", p.title())); + } + let rect = default_float_rect(ctx.input_for(egui::ViewportId::ROOT, |i| i.content_rect())); + if !self.edit_layout(|layout| layout.float(p, rect)) { + return Err(format!("can't undock {}", p.title())); + } + } + let Some((_, [x, y, w, h])) = group_of(self) else { return Err(format!("can't undock {}", p.title())) }; + // Over the panel's place in the main window (its content's position on the desktop). + let origin = ctx.input_for(egui::ViewportId::ROOT, |i| i.viewport().inner_rect.map(|r| r.min)).unwrap_or(pos2(0.0, 0.0)); + let rect = PanelWindow::sanitized([origin.x + x, origin.y + y, w, h]); + ctx.data_mut(|d| d.remove::<[f32; 4]>(panel_window_spawn_id(p))); + self.ui.panel_windows.push(PanelWindow { panel: p, rect }); + if self.ui.maximized == Some(p) { + self.ui.maximized = None; + } + Ok(true) + } + + /// One floating group in its own native window: the group's tab strip and active panel fill + /// the window. Closing the window brings the group back into the main window. + fn panel_window(&mut self, ctx: &egui::Context, i: usize, window: PanelWindow, info: &dock::TabInfo, actions: &mut Vec) { + let t = self.tokens; + let Some(f) = self.ui.floating.get(i).cloned() else { return }; + let active = f.active.min(f.panels.len().saturating_sub(1)); + let Some(shown) = f.panels.get(active).copied() else { return }; + // The window opens at its saved place; after that the window system owns its position and + // size (handing them back every frame would fight the user dragging it). + let spawn_id = panel_window_spawn_id(window.panel); + let spawn = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(spawn_id, || PanelWindow::sanitized(window.rect))); + let builder = egui::ViewportBuilder::default() + .with_title((info.title)(shown)) + .with_position([spawn[0], spawn[1]]) + .with_inner_size([spawn[2], spawn[3]]) + .with_min_inner_size(dock::PANEL_WINDOW_MIN); + let mut closed = false; + let mut placed = None; + ctx.show_viewport_immediate(panel_window_id(window.panel), builder, |ui, class| { + let native = matches!(class, egui::ViewportClass::Immediate | egui::ViewportClass::Deferred); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel_bg)).show(ui, |ui| { + let r = ui.max_rect(); + let g = Group { + path: format!("w{i}"), + rect: r, + content: Rect::from_min_max(pos2(r.min.x, (r.min.y + t.tab_h).min(r.max.y)), r.max), + panels: f.panels.clone(), + active, + stacked: None, + }; + let c = dock::draw_group_chrome(ui, &g, &t, &mut self.auto, info); + // Tabs can't be dragged between native windows yet. + actions.extend(c.actions.into_iter().filter(|a| !matches!(a, DockAction::BeginDrag(_)))); + self.panel_body(ui, shown, g.content); + if shown == self.ui.focused { + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.focus), StrokeKind::Inside); + } + if native { + panels::panel_menu_popup(self, ui); + } + }); + // An embedded window (no native windows on this platform) reads the main window's + // input: its close request and place aren't this window's. + if native { + closed = ui.input(|i| i.viewport().close_requested()); + placed = ui.input(|i| { + let v = i.viewport(); + let inner = v.inner_rect?; + let min = v.outer_rect.map_or(pos2(window.rect[0], window.rect[1]), |outer| outer.min); + Some([min.x, min.y, inner.width(), inner.height()]) + }); + } + }); + if closed { + self.ui.panel_windows.retain(|w| !f.panels.contains(&w.panel)); + ctx.data_mut(|d| d.remove::<[f32; 4]>(spawn_id)); + return; + } + // Remember where the window is, so the workspace reopens it there. + if let Some(rect) = placed.map(PanelWindow::sanitized) + && rect != window.rect + && let Some(w) = self.ui.panel_windows.iter_mut().find(|w| w.panel == window.panel) + { + w.rect = rect; + } + } + /// A tab being dragged. Over a tab strip (or the middle of a group) it shows where the tab /// will land: an insertion mark between two tabs (a drop there reorders a group's own tabs /// too); near a group's edge, the half it will split off. On release it lands there (or @@ -460,6 +599,16 @@ fn insertion_mark(painter: &egui::Painter, x: f32, strip: Rect, col: Color32) { } } +/// The native window of the floating group whose window entry names `panel`. +pub fn panel_window_id(panel: PanelKind) -> egui::ViewportId { + egui::ViewportId::from_hash_of(("effectcraft-panel-window", panel.id())) +} + +/// Where a native panel window was told to open (kept for as long as it stays open). +fn panel_window_spawn_id(panel: PanelKind) -> egui::Id { + egui::Id::new(("panel-window-spawn", panel.id())) +} + /// A new floating group's default rect near the centre of `screen`. pub fn default_float_rect(screen: Rect) -> [f32; 4] { let c = screen.center(); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index f7c77905..b84a4903 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -483,6 +483,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Type a command or effect…", "コマンドまたはエフェクトを入力…"), ("URL:", "URL:"), ("Undock Panel", "パネルのドッキングを解除"), + ("Move Panel to New Window", "新規ウィンドウにパネルを移動"), + ("Move Panel to Main Window", "メインウィンドウにパネルを移動"), ("Unlink {}", "{}のリンクを解除"), ( "Use this option to place the currently selected layers together into a new intermediate composition.", @@ -918,6 +920,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Type a command or effect…", "输入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), + ("Move Panel to New Window", "将面板移至新窗口"), + ("Move Panel to Main Window", "将面板移回主窗口"), ("Unfreeze", "解冻"), ("Unlink {}", "取消链接 {}"), ( @@ -1443,6 +1447,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Type a command or effect…", "輸入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), + ("Move Panel to New Window", "將面板移至新視窗"), + ("Move Panel to Main Window", "將面板移回主視窗"), ("Unfreeze", "解凍"), ("Unlink {}", "取消鏈接 {}"), ( @@ -1963,6 +1969,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Type a command or effect…", "Введіть команду або ефект…"), ("URL:", "URL:"), ("Undock Panel", "Від’єднати панель"), + ("Move Panel to New Window", "Перемістити панель у нове вікно"), + ("Move Panel to Main Window", "Перемістити панель у головне вікно"), ("Unlink {}", "Від’єднати {}"), ( "Use this option to place the currently selected layers together into a new intermediate composition.", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c61b497..a572fdbb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -127,6 +127,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("window.dockPanel", "Dock Panel", [], None), uic!("window.floatPanel", "Undock Panel", [], None), uic!("window.closePanel", "Close Panel", [], None), + uic!("window.panelWindow", "Move Panel to New Window", [], None), ]; pub fn panel_command_id(p: PanelKind) -> String { @@ -442,6 +443,22 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V } 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::(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); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2f0ca804..304aa9e5 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -173,6 +173,10 @@ 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::new("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| { @@ -187,6 +191,14 @@ 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. + 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() { + let _ = crate::menus::invoke(app, &ui.ctx().clone(), "window.panelWindow", serde_json::json!({"panel": panel, "window": !windowed})); + close = true; + } + } if !floating && ui.button(if app.ui.maximized == Some(panel) { tr("Restore Panel Size") } else { tr("Maximize Panel") }).clicked() { app.toggle_maximize(panel); close = true; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0cce2c11..ae0711b9 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -566,6 +566,10 @@ pub struct UiState { /// Floating panels of the saved workspaces. #[serde(default)] pub saved_floating: std::collections::BTreeMap>, + /// 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, /// The panel maximized to fill the dock area (`~`), if any. #[serde(default)] pub maximized: Option, @@ -680,6 +684,7 @@ impl Default for UiState { saved_workspaces: Default::default(), floating: vec![], saved_floating: Default::default(), + panel_windows: vec![], maximized: None, locked_tabs: BTreeSet::new(), viewers: Default::default(), diff --git a/crates/ui-egui/src/workspace_state.rs b/crates/ui-egui/src/workspace_state.rs index 9262266d..b7233a80 100644 --- a/crates/ui-egui/src/workspace_state.rs +++ b/crates/ui-egui/src/workspace_state.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use effectcraft_engine::config::ConfigStore; use serde::{Deserialize, Serialize}; -use crate::dock::{DockNode, Floating, PanelKind, SplitSize}; +use crate::dock::{DockNode, Floating, PanelKind, PanelWindow, SplitSize}; use crate::state::UiState; const FILE: &str = "workspaces.json"; @@ -28,6 +28,9 @@ struct Snapshot { maximized: Option, saved_workspaces: BTreeMap, saved_floating: BTreeMap>, + /// Floating groups in their own native window (absent from files of earlier versions). + #[serde(default)] + panel_windows: Vec, } impl Snapshot { @@ -41,6 +44,7 @@ impl Snapshot { maximized: ui.maximized, saved_workspaces: ui.saved_workspaces.clone(), saved_floating: ui.saved_floating.clone(), + panel_windows: ui.panel_windows.clone(), } } @@ -52,6 +56,14 @@ impl Snapshot { 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(); @@ -89,7 +101,7 @@ 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)) { + if !panel_ok(self.focused) || self.maximized.is_some_and(|panel| !panel_ok(panel)) || self.panel_windows.iter().any(|w| !panel_ok(w.panel)) { return Err("workspace viewer ID is outside the supported range 1–65535".into()); } let mut nodes = 0usize; @@ -134,6 +146,9 @@ impl Snapshot { } } } + 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()); @@ -229,6 +244,7 @@ impl WorkspaceStore { || 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 { diff --git a/crates/ui-egui/tests/ui_panel_windows.rs b/crates/ui-egui/tests/ui_panel_windows.rs new file mode 100644 index 00000000..60ae3f9c --- /dev/null +++ b/crates/ui-egui/tests/ui_panel_windows.rs @@ -0,0 +1,87 @@ +//! 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. +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::{PanelKind, PanelWindow}; +use egui::vec2; +use egui_kittest::Harness; +use serde_json::json; + +fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, params: serde_json::Value) -> Result { + effectcraft_ui_egui::menus::invoke(app, ctx, "window.panelWindow", params) +} + +#[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_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.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)); + // 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. + 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])); + // 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(); + 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::new(("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)); +} + +#[test] +fn panel_windows_survive_restart_with_hostile_places_made_safe() { + use effectcraft_engine::config::MemoryConfig; + use std::sync::Arc; + let store = Arc::new(MemoryConfig::default()); + 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] }); + 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!(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())); +} diff --git a/docs/gaps.md b/docs/gaps.md index acb22a63..a4f1fd09 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 TGA, DPX, HDR, AVI, MXF; `media::is_importable` has none of them; #431 any image imports as a sequence; #324 MP3 goes silent | Confusing failures on common VFX formats (DPX, TGA) | 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 multi-screen, floating panels on other screens, 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: 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) | | 21 | **Small 26.x features** | Proportional Scrubbing (26.2), copy frame to clipboard and paste SVG/AI as shapes (26.3), variable-font filter (26.3), percentage guides and effect colour labels (26.5), the rebuilt Effect Controls (26.5) | Users of the current version expect them | 15–25 h | — · [ui-parity.md](ui-parity.md) | | 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; #372 uncompressed single-file output; #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 Scale exposes X and Y; #611 FilmCraft rejects exported `.fcgt` | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 922f75e6..145e5b24 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -22,7 +22,7 @@ monitors are missing, and two GPU start-up failures are open. | Video output devices | Mercury Transmit to AJA, Blackmagic, second display | second preview window only (Video Preview) | same | same | — | No SDI/HDMI device output | | HDR display | HDR monitoring on EDR / HDR displays | tone-mapped SDR view; no EDR output | no HDR output | no | no | Colour engine handles HDR maths (Rec. 2100 PQ/HLG output files) | | Pen tablets (pressure, tilt) | yes, for Paint and Roto | Brushes panel has pressure options, but no tablet pressure is read | same | same | same | Paint strokes store pressure per vertex; input never fills it | -| Multiple monitors | panels and windows across screens | floating panels within one window only | same | same (#486) | — | gap 20 | +| Multiple monitors | panels and windows across screens | panels move into their own native windows on any screen (Move Panel to New Window; desktop builds) | same | same (#486) | — | gap 20 | | Touch / trackpad gestures | pinch zoom in viewers | egui pointer and scroll; no pinch-to-zoom audited | — | — | — | — | | Audio hardware | Core Audio / ASIO / WASAPI, device choice, latency | cpal output; preview sound late on Linux (#554) and after a start offset (#504) | cpal | cpal (ALSA) | Web Audio | No input or device latency settings | | Machine learning acceleration | Apple Neural Engine / GPU for Object Matte, Roto Brush 3 | CPU inference (pure Rust, rayon) | CPU | CPU | CPU | No GPU inference for MobileSAM or MediaPipe |