mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #787 from vangel76/gap-floating-panel-windows
#486: move a panel into its own native window (multiple monitors) # Conflicts: # crates/ui-egui/src/i18n/ui.rs # docs/gaps.md
This commit is contained in:
commit
225bbad013
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();
|
||||
|
||||
@@ -491,6 +491,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.",
|
||||
@@ -931,6 +933,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 {}", "取消链接 {}"),
|
||||
(
|
||||
@@ -1461,6 +1465,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 {}", "取消鏈接 {}"),
|
||||
(
|
||||
@@ -2006,6 +2012,8 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("URL:", "URL:"),
|
||||
("Undock Panel", "Від’єднати панель"),
|
||||
("Unfreeze", "Розморозити"),
|
||||
("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 {
|
||||
@@ -451,6 +452,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);
|
||||
|
||||
@@ -174,6 +174,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| {
|
||||
@@ -188,6 +192,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 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 any image imports as a sequence; #324 MP3 goes silent | 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 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), 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), 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; single-file intermediates are 8-bit uncompressed QuickTime only (#372: 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 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