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:
Brandon Thomas committed 2026-10-12 03:22:53 -04:00
commit 225bbad013
10 files changed
+331 -6

No files matched your search

+31
View File
@@ -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
+151 -2
View File
@@ -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();
+8
View File
@@ -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.",
+17
View File
@@ -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);
+12
View File
@@ -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;
+5
View File
@@ -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(),
+18 -2
View File
@@ -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> {
+87
View File
@@ -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
View File
@@ -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) |
+1 -1
View File
@@ -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 |