mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
#486: move a panel into its own native window (multiple monitors)
Problem: floating panels lived inside the main window only, so on a two-screen setup no panel could go onto the second monitor (#486). Fix: panel menu > Move Panel to New Window (and the agent/control command `window.panelWindow {panel, window?}`) undocks the panel if needed and shows its floating group in a native egui viewport the user can drag to any screen; Move Panel to Main Window or closing the window brings it back. The window's place and size are kept in the workspace (sanitised against hostile values) and restored on the next start. Browser and test builds, which embed viewports, show it as an in-app window. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9a7d3e7699
commit
e7ada5bd16
10 files changed
+331
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<PanelWindow> {
|
||||
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<bool>) -> Result<bool, String> {
|
||||
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<DockAction>) {
|
||||
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();
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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::<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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -566,6 +566,10 @@ pub struct UiState {
|
||||
/// 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>,
|
||||
/// The panel maximized to fill the dock area (`~`), if any.
|
||||
#[serde(default)]
|
||||
pub maximized: Option<PanelKind>,
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<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>,
|
||||
}
|
||||
|
||||
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<bool, String> {
|
||||
|
||||
@@ -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<serde_json::Value, String> {
|
||||
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()));
|
||||
}
|
||||
+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 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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user