Merge D2b: workspace migration, native windows, dialogs block floating panels

This commit is contained in:
aiongg
2026-10-25 05:42:26 +00:00
14 changed files with 718 additions and 48 deletions
+2
View File
@@ -33,6 +33,8 @@ kurbo = { workspace = true }
image = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Floating panels open in OS windows of their own on desktop; the web keeps them in the page.
craft-ui = { workspace = true, features = ["native-windows"] }
sys-locale = { workspace = true }
[dev-dependencies]
+1 -1
View File
@@ -410,7 +410,7 @@ pub trait WorkspaceExt {
fn panels(&self) -> Vec<PanelKind>;
}
fn node_panels(node: &Node<PanelKind>, out: &mut Vec<PanelKind>) {
pub(crate) fn node_panels(node: &Node<PanelKind>, out: &mut Vec<PanelKind>) {
match node {
Node::Split { first, second, .. } => {
node_panels(first, out);
+44 -10
View File
@@ -226,17 +226,18 @@ impl EffectcraftApp {
pos.and_then(|p| self.dock_rects.iter().rev().find(|(_, r)| r.contains(p)).map(|(k, _)| *k)).unwrap_or(self.ui.focused)
}
/// Whether panel `p`'s floating window is recorded as its own native window.
/// Whether panel `p` is in a floating window with a desktop place: its own OS window on
/// desktop (craft-ui `native-windows`); on the web and in embedded viewports the window
/// stays in the page and the place is kept for a desktop session.
pub fn in_panel_window(&self, p: PanelKind) -> bool {
self.ui.dock.layout.floating.iter().any(|w| w.contains(&p) && w.native.is_some())
}
/// Move panel `p`'s floating window into its own native window, which can be placed on
/// another monitor, or back into the main window (`window` false). `None` toggles. A docked
/// panel is undocked first. The window's place is over the panel's place in the main window.
/// Returns whether `p` is now in a window of its own.
// D2: craft-ui's `native-windows` feature draws these windows; until then the place is
// recorded and the window is drawn in the main window.
/// Move panel `p` into a floating window of its own, which on desktop is an OS window that
/// can go on another monitor, or back into the main window (`window` false), where it joins
/// the first docked group. `None` toggles. A docked panel is undocked first, and its window
/// opens over the panel's place in the main window. Returns whether `p` is now in a window
/// of its own.
pub fn set_panel_window(&mut self, ctx: &egui::Context, p: PanelKind, window: Option<bool>) -> Result<bool, String> {
let current = self.in_panel_window(p);
let window = window.unwrap_or(!current);
@@ -244,7 +245,21 @@ impl EffectcraftApp {
return Ok(current);
}
if !window {
self.apply_dock(Action::SetNative { panel: p, rect: None })?;
// On desktop every floating window is an OS window, so the way back is docking.
let anchor = self.ui.dock.layout.root.as_ref().and_then(|root| {
let mut panels = Vec::new();
dock::node_panels(root, &mut panels);
panels.into_iter().find(|panel| *panel != p)
});
let actions = match anchor {
Some(anchor) => vec![Action::Move { panel: p, anchor, placement: craft_ui::docking::Placement::Tab { before: None } }],
// Nothing is docked: the panel becomes the docked layout.
None => vec![Action::Close { panel: p }, Action::Open { panel: p, anchor: None }],
};
let mut workspace = self.ui.dock.clone();
// One transaction, so the panel is never reported closed and keeps its state.
let closed = workspace.apply_all(actions, dock::permissions).map_err(|_| format!("can't dock {}", p.title()))?;
self.commit_workspace(workspace, &closed, true);
return Ok(false);
}
if !self.ui.dock.is_floating(p) {
@@ -375,7 +390,11 @@ impl EffectcraftApp {
}
let style = dock_style(ui, &t);
let maximized = self.ui.maximized.filter(|m| self.ui.dock.is_docked(*m));
let mut area = DockArea::new(area_id()).float_bounds(body).maximized(maximized).locked(self.ui.dock.locked);
let mut area = DockArea::new(area_id())
.float_bounds(body)
.maximized(maximized)
.locked(self.ui.dock.locked)
.input_blocked(self.dialog.is_some() || panels::dialogs::modal_open(&ctx));
if let Some(panel) = self.pending_panel_raise.take() {
area = area.raise_panel(panel);
}
@@ -396,9 +415,10 @@ impl EffectcraftApp {
menu: None,
bodies: Vec::new(),
};
let output = area.show_content(&mut child, &layout, &style, &mut content);
let mut output = area.show_content(&mut child, &layout, &style, &mut content);
let EffectContent { doc_actions, locks, menu, bodies, .. } = content;
self.dock_rects = bodies;
self.forward_window_input(&ctx, std::mem::take(&mut output.forwarded_events), std::mem::take(&mut output.window_files));
if let Some(panel) = output.focused {
self.focus_panel(&ctx, panel);
}
@@ -446,6 +466,20 @@ impl EffectcraftApp {
panels::panel_menu_popup(self, ui);
}
/// Keys and dropped files that reach a floating panel's own OS window go to the main
/// window's next frame ([`crate::EffectcraftApp::raw_input_hook`]), so its shortcuts and
/// file import handle them. Text is not forwarded, and nothing is while a text field in
/// the main window has the keyboard.
fn forward_window_input(&mut self, ctx: &egui::Context, events: Vec<egui::Event>, files: Vec<craft_ui::docking::WindowFiles<PanelKind>>) {
let typing = ctx.egui_wants_keyboard_input();
let events = events.into_iter().filter(|event| !typing && !matches!(event, egui::Event::Text(_) | egui::Event::Ime(_)));
self.forwarded.events.extend(events);
self.forwarded.files.extend(files.into_iter().flat_map(|window| window.dropped));
if !self.forwarded.events.is_empty() || !self.forwarded.files.is_empty() {
ctx.request_repaint();
}
}
/// Automation ids for craft-ui's gesture sources: `panel.tab.<Panel>`, `panel.drag.<Panel>`
/// (the group's drag bar), `panel.border.<Panel>` (the top border above its tabs),
/// `panel.tabs.more.<Panel>`, `panel.header.<Panel>` (a stacked panel's open/close button),
+12
View File
@@ -278,6 +278,8 @@ pub struct EffectcraftApp {
pub(crate) control_rx: Option<Receiver<ControlRequest>>,
pub(crate) deferred: Vec<(ControlRequest, f64)>,
pub(crate) synthetic: Vec<egui::Event>,
/// Input from floating panels' own OS windows for the main window's next frame.
pub(crate) forwarded: ForwardedInput,
pub(crate) input_waiters: Vec<Sender<serde_json::Value>>,
/// Control requests waiting for an offloaded job (`wait: true` in the browser).
pub(crate) job_waiters: Vec<(control::JobWaiter, Sender<serde_json::Value>)>,
@@ -381,6 +383,7 @@ impl EffectcraftApp {
control_rx: None,
deferred: vec![],
synthetic: vec![],
forwarded: ForwardedInput::default(),
input_waiters: vec![],
job_waiters: vec![],
queued_screenshots: vec![],
@@ -1874,6 +1877,13 @@ impl EffectcraftApp {
}
}
/// Keys and dropped files from floating panels' OS windows (craft-ui `DockOutput`).
#[derive(Default)]
pub(crate) struct ForwardedInput {
pub(crate) events: Vec<egui::Event>,
pub(crate) files: Vec<egui::DroppedFileHandle>,
}
impl eframe::App for EffectcraftApp {
fn on_exit(&mut self) {
self.save_workspace_state();
@@ -1885,6 +1895,8 @@ impl eframe::App for EffectcraftApp {
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
raw_input.events.append(&mut self.forwarded.events);
raw_input.dropped_files.append(&mut self.forwarded.files);
let injected = !self.synthetic.is_empty();
self.feed_synthetic(raw_input);
if pointer_wrap::prepare_input(ctx, raw_input, injected) {
+37
View File
@@ -170,6 +170,18 @@ pub(crate) fn modal_space(ctx: &egui::Context) -> egui::Vec2 {
(ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0))
}
fn modal_pass_id() -> egui::Id {
egui::Id::new("effectcraft-modal-pass")
}
/// Whether a modal dialog showed in this pass or the one before: egui's `Modal` blocks input
/// only in its own window, so floating panels in OS windows of their own are blocked by the
/// dock area while this holds. Dialogs draw after the dock, so the previous pass counts.
pub(crate) fn modal_open(ctx: &egui::Context) -> bool {
let shown = ctx.data(|d| d.get_temp::<u64>(modal_pass_id()));
shown.is_some_and(|pass| pass.saturating_add(1) >= ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT))
}
pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
modal_with_scroll(ctx, title, size, t, false, body);
}
@@ -190,6 +202,8 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok
/// only its content: `body` gets the height left under the title, with `size` kept within the
/// window.
pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) {
let pass = ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT);
ctx.data_mut(|d| d.insert_temp(modal_pass_id(), pass));
let size = size.min(modal_space(ctx));
let id = egui::Id::new(("modal", title));
// Dragging the title row moves the dialog off what it edits. The offset is kept within the
@@ -687,6 +701,29 @@ fn palette(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
mod tests {
use super::*;
#[test]
fn modal_open_holds_from_a_shown_dialog_until_the_pass_after_it_closes() {
let (app, ctx) = app();
let t = app.tokens;
// Asked as the dock asks it: during the pass, before the dialogs draw.
let pass = |show: bool| {
let mut open = false;
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
open = modal_open(ui.ctx());
if show {
modal(ui.ctx(), "Test", vec2(200.0, 100.0), &t, |_| {});
}
});
output.textures_delta.clear();
open
};
assert!(!pass(false));
assert!(!pass(true), "the first pass of a dialog finds none shown yet");
assert!(pass(true));
assert!(pass(false), "the pass after the last one shown still counts");
assert!(!pass(false));
}
fn app() -> (EffectcraftApp, egui::Context) {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
+2 -3
View File
@@ -234,9 +234,8 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
let _ = app.dock_command(ui.ctx(), craft_ui::docking::Action::Float { panel, rect: r });
close = true;
}
// Multiple monitors: a native window of its own, which can go on another screen.
// D2: craft-ui's native windows draw it; until then the place is recorded and the
// panel floats in the main window.
// Multiple monitors: an OS window of its own, which can go on another screen. Where
// egui embeds viewports (the web), floating windows stay in the page.
if !ui.ctx().embed_viewports() {
let windowed = app.in_panel_window(panel);
if ui.button(if windowed { tr("Move Panel to Main Window") } else { tr("Move Panel to New Window") }).clicked() {
+137 -18
View File
@@ -8,7 +8,11 @@ use serde::{Deserialize, Serialize};
use crate::dock::{PanelKind, Workspace};
use crate::state::UiState;
mod legacy;
const FILE: &str = "workspaces.json";
/// An earlier version's file, kept when it is first rewritten in the current format.
const BACKUP: &str = "workspaces.old.json";
const MAX_BYTES: usize = 1024 * 1024;
const MAX_WORKSPACES: usize = 64;
/// Leave room for new viewers without approaching the u32 allocator limit.
@@ -94,23 +98,27 @@ pub(crate) struct WorkspaceStore {
saved: Snapshot,
last_attempt: f64,
writable: bool,
/// The text of an earlier version's file that loaded: the first save copies it to
/// [`BACKUP`] and writes the file in the current format.
migrated_from: Option<String>,
pub(crate) error: Option<String>,
}
impl WorkspaceStore {
pub(crate) fn load(config: Option<&dyn ConfigStore>, ui: &mut UiState) -> Self {
let mut original = None;
let loaded = config.map_or(Ok(None), |c| c.read_bounded(FILE, MAX_BYTES)).map_err(|e| e.to_string()).and_then(|text| {
text.map(|text| {
let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
// D2: migrate the version 1 snapshot (EffectCraft's own dock tree, craft-libs
// 5c874d51) and the unversioned `{workspaces, floating}` record here. Until then
// they load as an error: this session keeps the default layout and leaves the
// file untouched.
let version = value.get("version").and_then(serde_json::Value::as_u64);
if version.is_none_or(|v| v < u64::from(VERSION)) {
return Err("this workspace file is from an earlier EffectCraft version".to_string());
}
let snapshot = serde_json::from_value::<Snapshot>(value).map_err(|e| e.to_string())?;
let snapshot = if legacy::is_legacy(&value) {
let snapshot = legacy::migrate(value, ui)?;
original = Some(text);
snapshot
} else if value.get("version").is_none() {
return Err("not a workspace file".to_string());
} else {
serde_json::from_value::<Snapshot>(value).map_err(|e| e.to_string())?
};
snapshot.validate()?;
Ok(snapshot)
})
@@ -127,10 +135,11 @@ impl WorkspaceStore {
if let Some(error) = &error {
log::warn!("{error}");
ui.status = error.clone();
original = None;
}
// An unreadable or newer file is never replaced by this session, even after a later
// layout edit. Ordinary failed writes remain dirty and retryable.
Self { saved: Snapshot::capture(ui), last_attempt: f64::NEG_INFINITY, writable: error.is_none(), error }
Self { saved: Snapshot::capture(ui), last_attempt: f64::NEG_INFINITY, writable: error.is_none(), migrated_from: original, error }
}
pub(crate) fn due(&mut self, now: f64) -> bool {
@@ -145,7 +154,8 @@ impl WorkspaceStore {
if !self.writable {
return false;
}
self.saved.workspace != ui.workspace
self.migrated_from.is_some()
|| self.saved.workspace != ui.workspace
|| self.saved.dock != ui.dock
|| self.saved.focused != ui.focused
|| self.saved.maximized != ui.maximized
@@ -163,7 +173,11 @@ impl WorkspaceStore {
if text.len() > MAX_BYTES {
return Err("workspace file exceeds 1 MiB".into());
}
if let Some(original) = &self.migrated_from {
config.write(BACKUP, original).map_err(|e| format!("keep the earlier workspace file: {e}"))?;
}
config.write(FILE, &text).map_err(|e| format!("save workspaces: {e}"))?;
self.migrated_from = None;
self.saved = snapshot;
self.error = None;
Ok(true)
@@ -200,14 +214,35 @@ mod tests {
}
#[test]
fn corrupt_future_or_earlier_workspace_keeps_defaults_and_original_file() {
fn corrupt_future_or_unknown_workspace_keeps_defaults_and_original_file() {
let valid = Snapshot::capture(&UiState::default());
// An earlier version's file (EffectCraft's own dock tree) waits for its migration.
let earlier = r#"{"version":1,"workspace":"Default","dock":{"Tabs":{"panels":["Composition"],"active":0}},"floating":[],"focused":"Composition","maximized":null,"saved_workspaces":{},"saved_floating":{}}"#;
let legacy = r#"{"workspaces":{"Mine":{"Tabs":{"panels":["Composition"],"active":0}}}}"#;
for text in
["{".into(), serde_json::to_string(&Snapshot { version: VERSION + 1, ..valid }).unwrap(), " ".repeat(MAX_BYTES + 1), earlier.into(), legacy.into()]
{
// Damaged files of the earlier version: a panel in two places, a bad split, an unknown
// panel, a tree deeper than craft-ui allows, a missing field.
let earlier = |dock: &str| {
format!(
r#"{{"version":1,"workspace":"Default","dock":{dock},"floating":[],"focused":"Composition","maximized":null,"saved_workspaces":{{}},"saved_floating":{{}}}}"#
)
};
let mut deep = r#"{"Tabs":{"panels":["Composition"],"active":0}}"#.to_string();
for _ in 0..=craft_ui::docking::MAX_DEPTH {
deep = format!(r#"{{"Split":{{"vertical":true,"size":{{"Ratio":0.5}},"a":{deep},"b":{{"Tabs":{{"panels":[],"active":0}}}}}}}}"#);
}
let twice =
r#"{"Split":{"vertical":true,"size":{"Ratio":0.5},"a":{"Tabs":{"panels":["Project"],"active":0}},"b":{"Tabs":{"panels":["Project"],"active":0}}}}"#;
let bad_split = r#"{"Split":{"vertical":true,"size":{"Ratio":7.0},"a":{"Tabs":{"panels":["Project"],"active":0}},"b":{"Tabs":{"panels":["Timeline"],"active":0}}}}"#;
for text in [
"{".into(),
"{}".into(),
"[1, 2]".into(),
serde_json::to_string(&Snapshot { version: VERSION + 1, ..valid }).unwrap(),
" ".repeat(MAX_BYTES + 1),
earlier(twice),
earlier(bad_split),
earlier(r#"{"Tabs":{"panels":["NoSuchPanel"],"active":0}}"#),
earlier(&deep),
r#"{"version":1,"workspace":"Default"}"#.into(),
r#"{"workspaces":{"Mine":{"Tabs":{"panels":["Composition"],"active":3}}}}"#.into(),
] {
let config = MemoryConfig::default();
config.write(FILE, &text).unwrap();
let mut ui = UiState::default();
@@ -220,9 +255,93 @@ mod tests {
assert!(!store.save(Some(&config), &ui).unwrap());
assert!(store.save_now(Some(&config), &ui).unwrap_err().contains("couldn't be loaded"));
assert_eq!(config.read(FILE).as_deref(), Some(text.as_str()));
assert_eq!(config.read(BACKUP), None);
}
}
/// A file written by EffectCraft 0.10 (craft-libs 5c874d51): the Default workspace with
/// Preview floating, Render Queue floating in its own native window, Align closed,
/// Effects & Presets moved into the Project group and Audio split beside the Timeline,
/// saved as "My Layout"; then Layer closed.
const V1: &str = include_str!("../tests/fixtures/workspaces-v1.json");
#[test]
fn version_one_file_loads_with_every_panel_in_place() {
let config = MemoryConfig::default();
config.write(FILE, V1).unwrap();
let mut ui = UiState::default();
let store = WorkspaceStore::load(Some(&config), &mut ui);
assert_eq!(store.error, None);
assert_eq!(ui.workspace, "My Layout");
assert_eq!(ui.focused, PanelKind::Composition);
use PanelKind::*;
let root = crate::dock::hsplit(
SplitSize::FixedSecond(300.0),
crate::dock::vsplit(
SplitSize::Ratio(0.56),
crate::dock::hsplit(SplitSize::FixedFirst(300.0), crate::dock::tabs(&[Project, EffectControls, EffectsPresets], 2), tabs(&[Composition])),
crate::dock::hsplit(SplitSize::Ratio(0.5), tabs(&[Timeline]), tabs(&[Audio])),
),
crate::dock::stack(&[(Properties, true, None)]),
);
assert_eq!(ui.dock.layout.root, Some(root));
let floating: Vec<_> = ui.dock.layout.floating.iter().map(|w| (w.panels().copied().collect::<Vec<_>>(), w.rect, w.native)).collect();
assert_eq!(
floating,
[
(vec![Preview], [900.0, 120.0, 300.0, 260.0], None),
(vec![RenderQueue], [200.0, 500.0, 520.0, 240.0], Some(crate::dock_ui::sanitized_window_rect([200.0, 500.0, 520.0, 240.0]))),
]
);
// Closed panels stay closed.
for panel in [Align, Layer] {
assert!(!ui.dock.is_open(panel), "{panel:?}");
}
// The named workspace still has Layer, closed only after it was saved.
let saved = ui.saved_workspaces.get("My Layout").unwrap();
assert!(saved.is_docked(Layer) && saved.is_floating(Preview) && !saved.is_open(Align));
assert_eq!(saved.layout.floating.iter().filter_map(|w| w.native).count(), 0);
}
#[test]
fn version_one_file_is_rewritten_once_in_the_current_format_and_kept_as_a_backup() {
let config = MemoryConfig::default();
config.write(FILE, V1).unwrap();
let mut ui = UiState::default();
let mut store = WorkspaceStore::load(Some(&config), &mut ui);
let migrated = Snapshot::capture(&ui);
assert!(store.has_changes(&ui), "the migrated layout is written without waiting for an edit");
assert!(store.save(Some(&config), &ui).unwrap());
assert_eq!(config.read(BACKUP).as_deref(), Some(V1));
let text = config.read(FILE).unwrap();
assert_eq!(serde_json::from_str::<serde_json::Value>(&text).unwrap()["version"], VERSION);
assert!(!store.has_changes(&ui));
let mut restored = UiState::default();
let reloaded = WorkspaceStore::load(Some(&config), &mut restored);
assert_eq!(reloaded.error, None);
assert!(!reloaded.has_changes(&restored));
assert_eq!(Snapshot::capture(&restored), migrated);
}
#[test]
fn stacked_frames_with_tabs_and_an_empty_root_migrate() {
let text = r#"{"version":1,"workspace":"Mine","dock":{"Tabs":{"panels":[],"active":0}},
"floating":[{"panels":["Project"],"active":0,"rect":[10.0,10.0,300.0,200.0]}],
"focused":"Project","maximized":null,
"saved_workspaces":{"Mine":{"Stack":{"entries":[{"panel":"Info","open":true},{"panels":["Audio","Preview"],"active":1,"open":true,"height":80.0}]}}},
"saved_floating":{"Gone":[{"panels":["Align"],"active":0,"rect":[0.0,0.0,100.0,100.0]}]}}"#;
let config = MemoryConfig::default();
config.write(FILE, text).unwrap();
let mut ui = UiState::default();
let store = WorkspaceStore::load(Some(&config), &mut ui);
assert_eq!(store.error, None);
assert_eq!(ui.dock.layout.root, None);
assert!(ui.dock.is_floating(PanelKind::Project));
let stack = crate::dock::stack(&[(PanelKind::Info, true, None), (PanelKind::Audio, false, None), (PanelKind::Preview, true, Some(80.0))]);
assert_eq!(ui.saved_workspaces.get("Mine").and_then(|w| w.layout.root.clone()), Some(stack));
assert_eq!(ui.saved_workspaces.len(), 1, "floating groups of no named workspace are dropped");
}
#[test]
fn explicit_save_without_a_settings_folder_is_an_error() {
let mut ui = UiState::default();
@@ -0,0 +1,179 @@
//! Workspace files of earlier versions: EffectCraft's own dock tree (craft-libs 5c874d51 and
//! before), read into private copies of its types and turned into craft-ui workspaces.
//!
//! - Version 1 (`{version: 1, workspace, dock, floating, …, panel_windows}`) keeps the current
//! arrangement, the named workspaces, focus and the maximized panel.
//! - The unversioned record (`{workspaces, floating}`) holds only named workspaces; the
//! current arrangement stays the default.
//! - A stacked frame with several tabs becomes one stacked panel per tab; the shown tab keeps
//! the frame's open state and height, the others are collapsed.
//! - A floating group in its own native window (`panel_windows`) keeps that place as the
//! window's native rect.
//! - Closed panels had no recorded place: they stay closed and reopen at their default place.
use std::collections::BTreeMap;
use craft_ui::docking::{Floating, Node, StackEntry};
use craft_ui::layout::{SplitAxis, SplitSize};
use serde::Deserialize;
use super::{Snapshot, VERSION};
use crate::dock::{Layout, PanelKind, Workspace};
use crate::state::UiState;
#[derive(Deserialize)]
enum OldSplitSize {
Ratio(f32),
FixedA(f32),
FixedB(f32),
}
/// A stacked frame; files from before stacked frames held tabs name one `panel`.
#[derive(Deserialize)]
struct OldStackEntry {
#[serde(default)]
panel: Option<PanelKind>,
#[serde(default)]
panels: Vec<PanelKind>,
#[serde(default)]
active: usize,
open: bool,
#[serde(default)]
height: Option<f32>,
}
#[derive(Deserialize)]
enum OldNode {
Split { vertical: bool, size: OldSplitSize, a: Box<OldNode>, b: Box<OldNode> },
Tabs { panels: Vec<PanelKind>, active: usize },
Stack { entries: Vec<OldStackEntry> },
}
#[derive(Deserialize)]
struct OldFloating {
panels: Vec<PanelKind>,
active: usize,
rect: [f32; 4],
}
/// The floating group holding `panel` shows in its own native window at `rect`.
#[derive(Deserialize)]
struct OldPanelWindow {
panel: PanelKind,
rect: [f32; 4],
}
#[derive(Deserialize)]
struct OldSnapshot {
workspace: String,
dock: OldNode,
floating: Vec<OldFloating>,
focused: PanelKind,
maximized: Option<PanelKind>,
saved_workspaces: BTreeMap<String, OldNode>,
saved_floating: BTreeMap<String, Vec<OldFloating>>,
#[serde(default)]
panel_windows: Vec<OldPanelWindow>,
}
#[derive(Deserialize)]
struct OldRecord {
#[serde(default)]
workspaces: BTreeMap<String, OldNode>,
#[serde(default)]
floating: BTreeMap<String, Vec<OldFloating>>,
}
/// Whether `value` is a file of an earlier version that [`migrate`] reads.
pub(super) fn is_legacy(value: &serde_json::Value) -> bool {
match value.get("version") {
Some(version) => version.as_u64() == Some(1),
None => value.as_object().is_some_and(|fields| fields.contains_key("workspaces") || fields.contains_key("floating")),
}
}
/// The snapshot an earlier version's file describes. `ui` supplies what an unversioned record
/// leaves out (the current arrangement). The caller validates the result.
pub(super) fn migrate(value: serde_json::Value, ui: &UiState) -> Result<Snapshot, String> {
if value.get("version").is_none() {
let record: OldRecord = serde_json::from_value(value).map_err(|e| e.to_string())?;
let mut snapshot = Snapshot::capture(ui);
snapshot.saved_workspaces = named(record.workspaces, record.floating)?;
return Ok(snapshot);
}
let old: OldSnapshot = serde_json::from_value(value).map_err(|e| e.to_string())?;
Ok(Snapshot {
version: VERSION,
workspace: old.workspace,
dock: workspace(old.dock, old.floating, &old.panel_windows)?,
focused: old.focused,
maximized: old.maximized,
saved_workspaces: named(old.saved_workspaces, old.saved_floating)?,
})
}
/// Named workspaces with their floating groups; floating groups of no named workspace are
/// dropped, as the earlier version never showed them.
fn named(roots: BTreeMap<String, OldNode>, mut floating: BTreeMap<String, Vec<OldFloating>>) -> Result<BTreeMap<String, Workspace>, String> {
roots
.into_iter()
.map(|(name, root)| {
let groups = floating.remove(&name).unwrap_or_default();
workspace(root, groups, &[]).map(|workspace| (name, workspace))
})
.collect()
}
fn workspace(root: OldNode, floating: Vec<OldFloating>, windows: &[OldPanelWindow]) -> Result<Workspace, String> {
// Closing the last docked panel left an empty tab group as the root.
let root = match root {
OldNode::Tabs { panels, .. } if panels.is_empty() => None,
root => Some(node(root, 0)?),
};
let floating = floating
.into_iter()
.map(|group| {
let native = windows.iter().find(|window| group.panels.contains(&window.panel)).map(|window| window.rect);
let mut window = Floating::single(group.panels, group.active, group.rect);
window.native = native.map(crate::dock_ui::sanitized_window_rect);
window
})
.collect();
Ok(Workspace::new(Layout { root, floating }))
}
fn node(old: OldNode, depth: usize) -> Result<Node<PanelKind>, String> {
if depth > craft_ui::docking::MAX_DEPTH {
return Err("workspace dock tree exceeds supported limits".into());
}
Ok(match old {
OldNode::Tabs { panels, active } => Node::Tabs { panels, active },
OldNode::Split { vertical, size, a, b } => Node::Split {
axis: if vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal },
size: match size {
OldSplitSize::Ratio(value) => SplitSize::Ratio(value),
OldSplitSize::FixedA(value) => SplitSize::FixedFirst(value),
OldSplitSize::FixedB(value) => SplitSize::FixedSecond(value),
},
first: Box::new(node(*a, depth.saturating_add(1))?),
second: Box::new(node(*b, depth.saturating_add(1))?),
},
OldNode::Stack { entries } => {
let mut out = Vec::new();
for entry in entries {
let mut panels = entry.panels;
if let Some(panel) = entry.panel
&& !panels.contains(&panel)
{
panels.insert(0, panel);
}
let shown = panels.get(entry.active).or(panels.first()).copied();
for panel in panels {
let front = Some(panel) == shown;
out.push(StackEntry { panel, open: front && entry.open, height: entry.height.filter(|_| front) });
}
}
Node::Stack { entries: out }
}
})
}
+234
View File
@@ -0,0 +1,234 @@
{
"version": 1,
"workspace": "My Layout",
"dock": {
"Split": {
"vertical": false,
"size": {
"FixedB": 300.0
},
"a": {
"Split": {
"vertical": true,
"size": {
"Ratio": 0.56
},
"a": {
"Split": {
"vertical": false,
"size": {
"FixedA": 300.0
},
"a": {
"Tabs": {
"panels": [
"Project",
"EffectControls",
"EffectsPresets"
],
"active": 2
}
},
"b": {
"Tabs": {
"panels": [
"Composition"
],
"active": 0
}
}
}
},
"b": {
"Split": {
"vertical": false,
"size": {
"Ratio": 0.5
},
"a": {
"Tabs": {
"panels": [
"Timeline"
],
"active": 0
}
},
"b": {
"Tabs": {
"panels": [
"Audio"
],
"active": 0
}
}
}
}
}
},
"b": {
"Stack": {
"entries": [
{
"panels": [
"Properties"
],
"active": 0,
"open": true,
"height": null
}
]
}
}
}
},
"floating": [
{
"panels": [
"Preview"
],
"active": 0,
"rect": [
900.0,
120.0,
300.0,
260.0
]
},
{
"panels": [
"RenderQueue"
],
"active": 0,
"rect": [
200.0,
500.0,
520.0,
240.0
]
}
],
"focused": "Composition",
"maximized": null,
"saved_workspaces": {
"My Layout": {
"Split": {
"vertical": false,
"size": {
"FixedB": 300.0
},
"a": {
"Split": {
"vertical": true,
"size": {
"Ratio": 0.56
},
"a": {
"Split": {
"vertical": false,
"size": {
"FixedA": 300.0
},
"a": {
"Tabs": {
"panels": [
"Project",
"EffectControls",
"EffectsPresets"
],
"active": 2
}
},
"b": {
"Tabs": {
"panels": [
"Composition",
"Layer"
],
"active": 0
}
}
}
},
"b": {
"Split": {
"vertical": false,
"size": {
"Ratio": 0.5
},
"a": {
"Tabs": {
"panels": [
"Timeline"
],
"active": 0
}
},
"b": {
"Tabs": {
"panels": [
"Audio"
],
"active": 0
}
}
}
}
}
},
"b": {
"Stack": {
"entries": [
{
"panels": [
"Properties"
],
"active": 0,
"open": true,
"height": null
}
]
}
}
}
}
},
"saved_floating": {
"My Layout": [
{
"panels": [
"Preview"
],
"active": 0,
"rect": [
900.0,
120.0,
300.0,
260.0
]
},
{
"panels": [
"RenderQueue"
],
"active": 0,
"rect": [
200.0,
500.0,
520.0,
240.0
]
}
]
},
"panel_windows": [
{
"panel": "RenderQueue",
"rect": [
200.0,
500.0,
520.0,
240.0
]
}
]
}
+45 -6
View File
@@ -1,7 +1,7 @@
//! Multiple monitors (#486): Move Panel to New Window records a floating panel's window as its
//! own native window, which can be placed on another screen, and back.
// D2: craft-ui's `native-windows` feature draws these windows; until then they are drawn in the
// main window and only their place is recorded and saved.
//! Multiple monitors (#486): Move Panel to New Window puts a panel in a floating window of its
//! own, which on desktop is an OS window that can go on another screen, and Move Panel to Main
//! Window docks it again. The test harness embeds viewports, so these windows draw in the main
//! window here, as on the web.
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::{PanelKind, WorkspaceExt as _};
@@ -31,9 +31,10 @@ fn panel_window_command_moves_a_docked_panel_into_its_own_window_and_back() {
// Asking again changes nothing; no `window` toggles.
assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project", "window": true})).unwrap(), json!({"window": true}));
assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project"})).unwrap(), json!({"window": false}));
// Back in the main window, still floating where it was.
// Back in the main window: docked in the first docked group.
assert!(!app.in_panel_window(PanelKind::Project));
assert!(app.ui.dock.is_floating(PanelKind::Project));
assert!(!app.ui.dock.is_floating(PanelKind::Project));
assert!(app.ui.dock.is_docked(PanelKind::Project));
// The serialized panel form works too.
assert_eq!(invoke(&mut app, &ctx, json!({"panel": PanelKind::Project})).unwrap(), json!({"window": true}));
// Bad requests are errors and change nothing.
@@ -65,3 +66,41 @@ fn panel_windows_survive_restart_with_hostile_places_made_safe() {
assert!(x <= 100_000.0 && y >= -100_000.0 && (160.0..=16_384.0).contains(&w) && (100.0..=16_384.0).contains(&h));
assert!(sanitized_window_rect([f32::NAN, f32::INFINITY, f32::NAN, f32::NEG_INFINITY]).iter().all(|v| v.is_finite()));
}
#[test]
fn a_click_on_a_floating_panel_does_nothing_while_a_dialog_is_open() {
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
use egui_kittest::Harness;
let mut app = EffectcraftApp::new(Session::default());
app.ui.start_screen = false;
app.apply_dock(craft_ui::docking::Action::Float { panel: PanelKind::Project, rect: [80.0, 120.0, 320.0, 240.0] }).unwrap();
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app);
h.run_steps(3);
let close = |h: &Harness<'_, EffectcraftApp>| {
let e = h.state().auto.elements.iter().find(|e| e.id.starts_with("panel.float.") && e.id.ends_with(".close")).expect("the floating window's ×");
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
};
let click = |h: &mut Harness<'_, EffectcraftApp>, pos| {
h.event(Event::PointerMoved(pos));
h.step();
for pressed in [true, false] {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
};
let at = close(&h);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "general"})).unwrap();
h.run_steps(3);
let before = h.state().ui.dock.clone();
click(&mut h, at);
assert_eq!(h.state().ui.dock, before, "the floating panel takes no click while Settings is open");
assert!(h.state().ui.dock.is_floating(PanelKind::Project));
// The same click closes the window once the dialog is gone.
h.state_mut().dialog = None;
h.run_steps(3);
let at = close(&h);
click(&mut h, at);
assert!(!h.state().ui.dock.is_open(PanelKind::Project));
}
@@ -102,18 +102,20 @@ fn unreadable_native_workspace_is_preserved_after_later_layout_changes() {
}
#[test]
#[ignore = "D2: the migration of workspace files written with craft-libs 5c874d51"]
fn legacy_named_workspaces_migrate_once_and_survive_actual_app_restart() {
let store = Arc::new(MemoryConfig::default());
// An unversioned record from an earlier EffectCraft: its Minimal layout saved as "Legacy".
let legacy = r#"{"floating":{},"workspaces":{"Legacy":{"Split":{"a":{"Tabs":{"active":0,"panels":["Composition"]}},"b":{"Tabs":{"active":0,"panels":["Timeline"]}},"size":{"Ratio":0.6000000238418579},"vertical":true}}}}"#;
store.write("workspaces.json", legacy).unwrap();
let saved = effectcraft_ui_egui::dock::Workspace::new(effectcraft_ui_egui::dock::workspace("Minimal"));
store.write("workspaces.json", &json!({"workspaces":{"Legacy":saved},"floating":{}}).to_string()).unwrap();
let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() });
assert_eq!(app.ui.saved_workspaces.get("Legacy"), Some(&saved));
app.set_workspace("Legacy");
let ctx = egui::Context::default();
effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name":"Migrated"})).unwrap();
let text = store.read("workspaces.json").unwrap();
assert_eq!(serde_json::from_str::<serde_json::Value>(&text).unwrap()["version"], 1);
assert_eq!(serde_json::from_str::<serde_json::Value>(&text).unwrap()["version"], 2);
assert_eq!(store.read("workspaces.old.json").as_deref(), Some(legacy));
let restarted = EffectcraftApp::new(Session { config: Some(store), ..Default::default() });
assert_eq!(restarted.ui.saved_workspaces.get("Legacy"), Some(&saved));
assert_eq!(restarted.ui.saved_workspaces.get("Migrated"), Some(&saved));
+18 -5
View File
@@ -216,9 +216,16 @@ takes a name, or as `{"Viewer":42}` and `{"ScriptPanel":77}` for viewers and Scr
side zone docks along that edge of the whole workspace. `window.floatPanel {panel, rect?}`
requires exactly four finite numbers when `rect` is given and otherwise floats a 420x320
window in the middle of the app window. `window.closePanel {panel}` closes it.
- `window.panelWindow {panel, window?}` records a floating panel's window as its own native
window (`window` false moves it back; without `window` it toggles). A docked panel floats
first. The window is drawn in the main window in this version.
- `window.panelWindow {panel, window?}` puts a panel in a floating window of its own with a
desktop place over the panel's place in the main window, and returns `{"window": bool}`. A
docked panel floats first. `window` false docks it again, as a tab of the first docked group;
without `window` it toggles.
On desktop every floating window is an OS window of its own (craft-ui's `native-windows`), which
can go on another monitor; on Wayland, on the web and where egui embeds viewports, floating
windows stay in the main window. Keys pressed in a floating window that none of its widgets uses
run the main window's shortcuts, and files dropped on one import as on the main window. While a
modal dialog is open, floating windows take no input.
The automation ids of the docking chrome are `panel.tab.<Panel>`, `panel.drag.<Panel>` (a
group's drag bar), `panel.tabs.more.<Panel>`, `panel.menu.<Panel>` (≡), `panel.header.<Panel>`
@@ -229,5 +236,11 @@ group's drag bar), `panel.tabs.more.<Panel>`, `panel.menu.<Panel>` (≡), `panel
`s<i>`) and `dock.corner.<outer>.<inner>` where two dividers meet.
Saved workspaces (`workspaces.json`, version 2) hold craft-ui `Workspace`s: the layout, the
closed panels with where each goes back, and the lock. A file from an earlier version loads as
the default layout and is left unchanged.
closed panels with where each goes back, the lock, and each floating window's desktop place. A
version 1 file (EffectCraft 0.10 and earlier) and the older unversioned record of
named workspaces load with every panel in place: the current layout, the named workspaces,
floating windows and their native window places. A stacked frame with several tabs becomes one
stacked panel per tab, the shown one keeping the frame's open state and height. Panels that were
closed stay closed and reopen at their default place. The first save writes version 2 and keeps
the earlier file as `workspaces.old.json`. A damaged, unknown or newer file loads as the default
layout and is left unchanged.
+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 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) |
| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) |
| 20 | **Multi-monitor and video output** | #486: any panel can move into a native window of its own (panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so onto another screen, restored with the workspace; still missing: dragging tabs between those windows, app shortcuts while one has the focus, named workspaces remembering them, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |
| 20 | **Multi-monitor and video output** | #486: on desktop every floating panel is an OS window of its own (Undock Panel, a drag out of the main window, panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so can go onto another screen; tabs and groups drag between windows, shortcuts work in them, and the current and named workspaces restore their places; still missing: Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |
| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), the rebuilt Effect Controls (26.5). Percentage guides (View ▸ Add Percentage Guide, Convert Guides to Percentage / Pixels) and effect colour labels (Effect Controls header bar and right-click Label menu, `effect.setLabel`) are in (#1095), built from the release notes' one-line descriptions and never compared with After Effects: the Timeline's effect rows wear the label as a colour bar on the left edge (#1509), but After Effects' exact UI there is unknown | Users of the current version expect them | 10–20 h left | — · [ui-parity.md](ui-parity.md) |
| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372 closed by #778: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 and #611 closed | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) |
+1 -1
View File
@@ -16,7 +16,7 @@ feel and platform differences, and the 26.x interface changes are not in yet.
| Area | Status | % | Evidence and gaps |
|---|---|---:|---|
| Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) |
| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; panels move into their own native windows on any screen (#486 open: no dragging tabs between those windows); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed |
| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; on desktop floating panels are OS windows of their own on any screen, with tabs and groups dragging between windows and shortcuts working in them (#486); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed |
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a hidden null, camera or light has no box or handles in the viewer and isn't picked there; other selected hidden layers keep their layer controls and paths, #1769); a row control (a switch, the label swatch, a Modes cell) clicked on one of several selected layers acts on the whole selection, with the layers the switch doesn't apply to left out (#1849); a shape group's inner Contents (After Effects' elided Vectors group) draws no row — the group's path, fill/stroke and Transform list directly under it |
| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu |
| Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed |