mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 04:26:55 -06:00
Merge D2b: workspace migration, native windows, dialogs block floating panels
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user