From 0411cb205ce96a720f585c9bb2fe3d1b08e45438 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 06:14:43 +0000 Subject: [PATCH] D3: right columns are tab-group frames split vertically; migrated native windows keep their place - Default's right column is a short Preview frame (fixed 76 pt: tab strip and a 46 pt body) over a Properties frame with Align, Audio and Effects & Presets as tabs. Essential Graphics' right column is one Properties / Effects & Presets frame. No built-in workspace uses accordions, so a frame there takes tabs and Maximize acts on one frame. - Workspace files of version 1: a column of stacked frames becomes tab groups split top to bottom. Each open frame keeps all its tabs and its height as a fixed size; a closed frame's tabs join the open frame above it. - Version 1 stored a native panel window's outer position; craft-ui places windows by their content. The migrated rect moves by the main window's frame offset once the main window reports it, and workspaces are not saved before. --- crates/ui-egui/src/dock.rs | 54 ++++++--- crates/ui-egui/src/dock_ui.rs | 28 +++++ crates/ui-egui/src/state.rs | 6 + crates/ui-egui/src/workspace_state.rs | 43 +++++-- crates/ui-egui/src/workspace_state/legacy.rs | 120 ++++++++++++++----- crates/ui-egui/tests/it/ui_agent_audit.rs | 2 - crates/ui-egui/tests/it/ui_fx_editors.rs | 2 - crates/ui-egui/tests/it/ui_scrolling.rs | 3 +- crates/ui-egui/tests/it/ui_tabs.rs | 114 +++++++++++------- crates/ui-egui/tests/it/ui_text_edit.rs | 2 - docs/control-protocol.md | 8 +- 11 files changed, 278 insertions(+), 104 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 185aab78..a19ef5ac 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -238,6 +238,12 @@ fn workspace_floating(name: &str) -> Vec> { } } +/// The height of a frame's tab strip ([`Tokens::tab_h`]). +pub const TAB_STRIP: f32 = 30.0; +/// The height of Default's Preview frame: its tab strip over a 46-point body that fits the +/// playback controls. +pub const PREVIEW_FRAME: f32 = TAB_STRIP + 46.0; + /// The built-in layout of a named workspace (an unknown name gets Default's). pub fn workspace(name: &str) -> Layout { Layout { root: Some(workspace_root(name)), floating: workspace_floating(name) } @@ -318,7 +324,7 @@ fn workspace_root(name: &str) -> Node { ), tabs(&[Timeline], 0), ), - stack(&[(Properties, true, None), (EffectsPresets, false, None)]), + tabs(&[Properties, EffectsPresets], 0), ), // Color: wide Effect Controls for grading effects, Info for colour readouts. "Color" => vsplit( @@ -381,7 +387,7 @@ fn workspace_root(name: &str) -> Node { tabs(&[Timeline, RenderQueue], 0), ), // Default (as in After Effects): Project/Effect Controls | Composition over the Timeline, - // and a full-height right column with Preview above the Properties panel. + // and a full-height right column: a short Preview frame above the Properties frame. _ => hsplit( FixedSecond(300.0), vsplit( @@ -389,7 +395,7 @@ fn workspace_root(name: &str) -> Node { hsplit(FixedFirst(300.0), tabs(&[Project, EffectControls], 0), tabs(&[Composition, Layer], 0)), tabs(&[Timeline, RenderQueue], 0), ), - stack(&[(Preview, true, Some(46.0)), (Properties, true, None), (Align, false, None), (Audio, false, None), (EffectsPresets, false, None)]), + vsplit(FixedFirst(PREVIEW_FRAME), tabs(&[Preview], 0), tabs(&[Properties, Align, Audio, EffectsPresets], 0)), ), } } @@ -634,25 +640,35 @@ mod tests { for p in PanelKind::ALL.iter().filter(|p| !matches!(p, ScriptConsole | EssentialGraphics | MaskInterpolation)) { assert!(all.is_open(*p), "All Panels lacks {p:?}"); } - // Default: After Effects' right column of stacked panels, Preview open at its own height. - let default = workspace("Default"); - let Some(Node::Split { second, size: SplitSize::FixedSecond(300.0), .. }) = &default.root else { panic!("{default:?}") }; - let Node::Stack { entries } = second.as_ref() else { panic!("{second:?}") }; - assert_eq!(entries[0], StackEntry { panel: Preview, open: true, height: Some(46.0) }); - assert_eq!(stack_gutter_id(&default, Preview).as_deref(), Some("dock.gutter.bs0")); - assert_eq!(stack_gutter_id(&default, Align).as_deref(), Some("dock.gutter.bs2")); + // No built-in workspace uses accordions: right columns are frames split vertically. + fn no_stack(node: &Node) -> bool { + match node { + Node::Split { first, second, .. } => no_stack(first) && no_stack(second), + Node::Stack { .. } => false, + _ => true, + } + } + for name in WORKSPACES { + let layout = workspace(name); + assert!(layout.root.iter().chain(layout.floating.iter().map(|f| &f.root)).all(no_stack), "{name}"); + } } #[test] - fn stacked_panels_open_close_and_join_as_groups() { - let mut w = Workspace::new(workspace("Default")); - assert!(!w.is_visible(Align)); - apply(&mut w, Action::SetStackOpen { panel: Align, open: true }); - assert!(w.is_visible(Align) && w.is_visible(Properties)); - apply(&mut w, Action::SetStackOpen { panel: Align, open: false }); - assert!(!w.is_visible(Align) && w.is_open(Align)); - // A stacked panel is a group of its own. - assert_eq!(w.group_of(Audio), vec![Audio]); + fn default_right_column_is_a_short_preview_frame_over_the_properties_frame() { + let default = workspace("Default"); + let Some(Node::Split { second, size: SplitSize::FixedSecond(300.0), .. }) = &default.root else { panic!("{default:?}") }; + let Node::Split { axis: SplitAxis::Vertical, size: SplitSize::FixedFirst(PREVIEW_FRAME), first, second } = second.as_ref() else { + panic!("{second:?}") + }; + assert_eq!(**first, tabs(&[Preview], 0)); + assert_eq!(**second, tabs(&[Properties, Align, Audio, EffectsPresets], 0)); + let mut w = Workspace::new(default); + assert!(w.is_visible(Preview) && w.is_visible(Properties) && !w.is_visible(Align)); + // The Properties frame takes tabs; the Preview frame stays a frame of its own. + apply(&mut w, Action::Move { panel: Project, anchor: Properties, placement: Placement::Tab { before: None } }); + assert_eq!(w.group_of(Project), vec![Properties, Align, Audio, EffectsPresets, Project]); + assert_eq!(w.group_of(Preview), vec![Preview]); } #[test] diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index e8594893..8001f9ee 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -6,6 +6,7 @@ //! through its command path (`window.dockAction`), so pointer, menu and control-channel changes //! share one route. +use crate::state::UiState; use craft_ui::docking::{Action, Divider, DockArea, DockCommand, DockContent, DockError, DockEvent, DockStyle, FloatSpace, Hit, ResizeEdge, UiDock}; use egui::{Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value, json}; @@ -45,6 +46,29 @@ pub fn sanitized_window_rect(rect: [f32; 4]) -> [f32; 4] { ] } +/// How long to wait for the main window to report its frame before migrated native windows +/// keep their places as they are, in seconds. +const FRAME_REPORT_WAIT: f64 = 2.0; + +/// Moves the native rects of [`UiState::native_outer`]'s windows from the OS window's outer +/// position to its content position, by the main window's frame offset (`inner` less `outer`, +/// the main window's desktop rects): the earlier version's windows had the same OS title bar. +/// Without a report by [`FRAME_REPORT_WAIT`] (a backend without window positions), the places +/// stay as they are. +pub(crate) fn native_outer_to_content(ui: &mut UiState, outer: Option, inner: Option, now: f64) { + let offset = match (outer, inner) { + (Some(outer), Some(inner)) if outer.min.is_finite() && inner.min.is_finite() => inner.min - outer.min, + _ if now < FRAME_REPORT_WAIT => return, + _ => egui::Vec2::ZERO, + }; + let ids = std::mem::take(&mut ui.native_outer); + for window in ui.dock.layout.floating.iter_mut().filter(|w| ids.contains(&w.id)) { + if let Some([x, y, w, h]) = window.native { + window.native = Some(sanitized_window_rect([x + offset.x, y + offset.y, w, h])); + } + } +} + /// A new floating group's default rect near the centre of `screen`. pub fn default_float_rect(screen: Rect) -> [f32; 4] { let c = screen.center(); @@ -381,6 +405,10 @@ impl EffectcraftApp { pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) { let t = self.tokens; let ctx = ui.ctx().clone(); + if !self.ui.native_outer.is_empty() { + let (outer, inner, now) = ctx.input_for(egui::ViewportId::ROOT, |i| (i.viewport().outer_rect, i.viewport().inner_rect, i.time)); + native_outer_to_content(&mut self.ui, outer, inner, now); + } if self.ui.start_screen { // Home covers the whole workspace, as in After Effects (the panels wait behind it). let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("home")); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9b14e771..0d69c45c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -577,6 +577,11 @@ pub struct UiState { /// The panel maximized to fill the dock area (`~`), if any. #[serde(default)] pub maximized: Option, + /// Floating windows of [`Self::dock`] whose native rect holds the OS window's outer + /// position, from a workspace file of an earlier version (see + /// [`crate::dock_ui::native_outer_to_content`]). Workspaces are not saved while any remain. + #[serde(skip)] + pub native_outer: Vec, /// Locked Composition / Timeline tabs (panel ids): opening another composition does not /// bring a locked panel forward (the tab's lock icon, as After Effects' viewer lock). #[serde(default)] @@ -694,6 +699,7 @@ impl Default for UiState { workspace: "Default".into(), dock: crate::dock::Workspace::new(crate::dock::workspace("Default")), saved_workspaces: Default::default(), + native_outer: Vec::new(), maximized: None, locked_tabs: BTreeSet::new(), viewers: Default::default(), diff --git a/crates/ui-egui/src/workspace_state.rs b/crates/ui-egui/src/workspace_state.rs index fe522075..92e41fde 100644 --- a/crates/ui-egui/src/workspace_state.rs +++ b/crates/ui-egui/src/workspace_state.rs @@ -107,12 +107,14 @@ pub(crate) struct WorkspaceStore { impl WorkspaceStore { pub(crate) fn load(config: Option<&dyn ConfigStore>, ui: &mut UiState) -> Self { let mut original = None; + let mut outer = Vec::new(); 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())?; let snapshot = if legacy::is_legacy(&value) { - let snapshot = legacy::migrate(value, ui)?; + let (snapshot, windows) = legacy::migrate(value, ui)?; original = Some(text); + outer = windows; snapshot } else if value.get("version").is_none() { return Err("not a workspace file".to_string()); @@ -127,6 +129,7 @@ impl WorkspaceStore { let error = match loaded { Ok(Some(snapshot)) => { snapshot.apply(ui); + ui.native_outer = outer; None } Ok(None) => None, @@ -164,7 +167,8 @@ impl WorkspaceStore { pub(crate) fn save(&mut self, config: Option<&dyn ConfigStore>, ui: &UiState) -> Result { let Some(config) = config else { return Ok(false) }; - if !self.has_changes(ui) { + // A window still at its outer position would be saved as if that were its content's. + if !self.has_changes(ui) || !ui.native_outer.is_empty() { return Ok(false); } let snapshot = Snapshot::capture(ui); @@ -282,7 +286,7 @@ mod tests { 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)]), + tabs(&[Properties]), ); assert_eq!(ui.dock.layout.root, Some(root)); let floating: Vec<_> = ui.dock.layout.floating.iter().map(|w| (w.panels().copied().collect::>(), w.rect, w.native)).collect(); @@ -297,6 +301,17 @@ mod tests { for panel in [Align, Layer] { assert!(!ui.dock.is_open(panel), "{panel:?}"); } + // The native window's place is its outer position until the main window reports its + // frame (here a 28-point title bar); the file is not rewritten before. + let id = ui.dock.layout.floating[1].id; + assert_eq!(ui.native_outer, [id]); + let mut store = store; + assert!(!store.save(Some(&config), &ui).unwrap()); + let outer = egui::Rect::from_min_size(egui::pos2(10.0, 20.0), egui::vec2(1600.0, 1000.0)); + crate::dock_ui::native_outer_to_content(&mut ui, Some(outer), Some(outer.translate(egui::vec2(0.0, 28.0))), 0.1); + assert_eq!(ui.dock.layout.floating[1].native, Some([200.0, 528.0, 520.0, 240.0])); + assert!(ui.native_outer.is_empty()); + assert!(store.save(Some(&config), &ui).unwrap()); // 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)); @@ -309,6 +324,13 @@ mod tests { config.write(FILE, V1).unwrap(); let mut ui = UiState::default(); let mut store = WorkspaceStore::load(Some(&config), &mut ui); + // A backend that reports no window positions: the native place stays as it was. + let native = ui.dock.layout.floating[1].native; + crate::dock_ui::native_outer_to_content(&mut ui, None, None, 1.0); + assert!(!ui.native_outer.is_empty(), "waits for a report"); + crate::dock_ui::native_outer_to_content(&mut ui, None, None, 5.0); + assert!(ui.native_outer.is_empty()); + assert_eq!(ui.dock.layout.floating[1].native, native); 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()); @@ -328,7 +350,8 @@ mod tests { 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_workspaces":{"Mine":{"Stack":{"entries":[{"panel":"Info","open":true},{"panels":["Audio","Preview"],"active":1,"open":true,"height":80.0}]}}, + "Column":{"Stack":{"entries":[{"panel":"Align","open":false},{"panel":"Preview","open":true,"height":46.0},{"panels":["Properties","Paint"],"active":1,"open":false,"height":300.0},{"panel":"Info","open":true}]}}}, "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(); @@ -337,9 +360,15 @@ mod tests { 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"); + use PanelKind::*; + // Each open frame is a tab group with all its tabs; a frame's height is a fixed size + // (with its tab strip), and the frame without one takes the rest. + let column = crate::dock::vsplit(SplitSize::FixedSecond(80.0 + crate::dock::TAB_STRIP), tabs(&[Info]), crate::dock::tabs(&[Audio, Preview], 1)); + assert_eq!(ui.saved_workspaces.get("Mine").and_then(|w| w.layout.root.clone()), Some(column)); + // Closed frames' tabs join the open frame above them, or below at the top. + let column = crate::dock::vsplit(SplitSize::FixedFirst(46.0 + crate::dock::TAB_STRIP), tabs(&[Preview, Properties, Paint, Align]), tabs(&[Info])); + assert_eq!(ui.saved_workspaces.get("Column").and_then(|w| w.layout.root.clone()), Some(column)); + assert_eq!(ui.saved_workspaces.len(), 2, "floating groups of no named workspace are dropped"); } #[test] diff --git a/crates/ui-egui/src/workspace_state/legacy.rs b/crates/ui-egui/src/workspace_state/legacy.rs index ed969575..93d71fd7 100644 --- a/crates/ui-egui/src/workspace_state/legacy.rs +++ b/crates/ui-egui/src/workspace_state/legacy.rs @@ -5,15 +5,19 @@ //! 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 column of stacked frames becomes tab groups split top to bottom. Each open frame is a +//! group with all its tabs; a frame with a height keeps it as a fixed size (its tab strip +//! added), and the others share the rest. A closed frame's tabs join the open frame above it +//! (below it, at the top of the column), as a split has no collapsed frames. //! - A floating group in its own native window (`panel_windows`) keeps that place as the -//! window's native rect. +//! window's native rect. The earlier version stored the OS window's outer position, so the +//! window's id is returned for [`crate::dock_ui::native_outer_to_content`] to move it to its +//! content position once the main window reports its frame. //! - 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::docking::{Floating, Node}; use craft_ui::layout::{SplitAxis, SplitSize}; use serde::Deserialize; @@ -92,24 +96,28 @@ pub(super) fn is_legacy(value: &serde_json::Value) -> bool { } } -/// The snapshot an earlier version's file describes. `ui` supplies what an unversioned record +/// The snapshot an earlier version's file describes, and the ids of its floating windows whose +/// native rect holds the OS window's outer position. `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 { +pub(super) fn migrate(value: serde_json::Value, ui: &UiState) -> Result<(Snapshot, Vec), 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); + return Ok((snapshot, Vec::new())); } let old: OldSnapshot = serde_json::from_value(value).map_err(|e| e.to_string())?; - Ok(Snapshot { + let dock = workspace(old.dock, old.floating, &old.panel_windows)?; + let outer = dock.layout.floating.iter().filter(|window| window.native.is_some()).map(|window| window.id).collect(); + let snapshot = Snapshot { version: VERSION, workspace: old.workspace, - dock: workspace(old.dock, old.floating, &old.panel_windows)?, + dock, focused: old.focused, maximized: old.maximized, saved_workspaces: named(old.saved_workspaces, old.saved_floating)?, - }) + }; + Ok((snapshot, outer)) } /// Named workspaces with their floating groups; floating groups of no named workspace are @@ -158,22 +166,80 @@ fn node(old: OldNode, depth: usize) -> Result, String> { 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 } - } + OldNode::Stack { entries } => column(entries)?, }) } + +/// The space between two frames of a column, in points ([`crate::theme::Tokens::gap`]). +const FRAME_GAP: f32 = 4.0; +/// The largest frame height a migrated column keeps, in points. +const MAX_FRAME: f32 = 10_000.0; + +/// A tab group of a column: its panels, shown tab, and body height when it has one. +struct Frame { + panels: Vec, + active: usize, + height: Option, +} + +/// A column of stacked frames as tab groups split top to bottom (see the module docs). +fn column(entries: Vec) -> Result, String> { + let mut frames: Vec = Vec::new(); + // Tabs of closed frames above the first open one. + let mut waiting: Vec = Vec::new(); + for entry in entries { + let mut panels = entry.panels; + if let Some(panel) = entry.panel + && !panels.contains(&panel) + { + panels.insert(0, panel); + } + if panels.is_empty() { + continue; + } + let active = entry.active.min(panels.len().saturating_sub(1)); + if entry.open { + let height = entry.height.filter(|h| h.is_finite() && *h > 0.0).map(|h| h.min(MAX_FRAME)); + frames.push(Frame { panels, active, height }); + } else if let Some(above) = frames.last_mut() { + above.panels.extend(panels); + } else { + waiting.extend(panels); + } + } + match frames.first_mut() { + Some(top) => top.panels.extend(waiting), + None if !waiting.is_empty() => frames.push(Frame { panels: waiting, active: 0, height: None }), + None => return Err("a stacked frame holds no panels".into()), + } + let frame_size = |frame: &Frame| frame.height.map(|h| h + crate::dock::TAB_STRIP); + let mut node: Option> = None; + // Below the frame being added: the frames' fixed sizes and gaps, or None when one shares. + let mut below_fixed = Some(0.0_f32); + let mut below_flexible = 0_usize; + for frame in frames.into_iter().rev() { + let own = frame_size(&frame); + let gap = if node.is_some() { FRAME_GAP } else { 0.0 }; + let tabs = Node::Tabs { panels: frame.panels, active: frame.active }; + node = Some(match node { + None => tabs, + Some(rest) => { + let size = match (own, below_fixed) { + (Some(own), _) => SplitSize::FixedFirst(own), + (None, Some(below)) => SplitSize::FixedSecond(below), + // Shares the rest with the flexible frames below. + (None, None) => SplitSize::Ratio(1.0 / (below_flexible as f32 + 1.0)), + }; + Node::Split { axis: SplitAxis::Vertical, size, first: Box::new(tabs), second: Box::new(rest) } + } + }); + below_fixed = match (own, below_fixed) { + (Some(own), Some(below)) => Some(below + gap + own), + _ => None, + }; + if own.is_none() { + below_flexible = below_flexible.saturating_add(1); + } + } + node.ok_or_else(|| "a stacked frame holds no panels".into()) +} diff --git a/crates/ui-egui/tests/it/ui_agent_audit.rs b/crates/ui-egui/tests/it/ui_agent_audit.rs index 17b40b9b..a14f7783 100644 --- a/crates/ui-egui/tests/it/ui_agent_audit.rs +++ b/crates/ui-egui/tests/it/ui_agent_audit.rs @@ -96,8 +96,6 @@ fn covered(w: Rect, elements: &[Rect]) -> bool { /// (panel, widget rect, label) of every interactive widget no automation element covers, for /// one panel shown maximized. fn gaps_of(h: &mut Harness<'_, EffectcraftApp>, p: PanelKind) -> Vec { - // Docked alone: maximizing a stacked panel maximizes its whole stack. - h.state_mut().ui.dock = effectcraft_ui_egui::dock::docked(effectcraft_ui_egui::dock::tabs(&[p], 0)); h.state_mut().show_panel(p); h.state_mut().ui.maximized = Some(p); h.run_steps(4); diff --git a/crates/ui-egui/tests/it/ui_fx_editors.rs b/crates/ui-egui/tests/it/ui_fx_editors.rs index 436bbc2a..b9530fdf 100644 --- a/crates/ui-egui/tests/it/ui_fx_editors.rs +++ b/crates/ui-egui/tests/it/ui_fx_editors.rs @@ -288,8 +288,6 @@ fn composition_tabs_and_scrolling_preview() { let mut s = Session::default(); s.execute("file.openDemoProject", json!({})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); - // A short Preview panel scrolls its controls (stacked panels share the column by height). - h.state_mut().apply_dock(craft_ui::docking::Action::ResizeStack { panel: PanelKind::Properties, height: Some(650.0) }).unwrap(); h.run_steps(4); rect(&h, "panel.tab.Timeline.close"); rect(&h, "preview.scroll"); diff --git a/crates/ui-egui/tests/it/ui_scrolling.rs b/crates/ui-egui/tests/it/ui_scrolling.rs index b97adb84..a55e49bf 100644 --- a/crates/ui-egui/tests/it/ui_scrolling.rs +++ b/crates/ui-egui/tests/it/ui_scrolling.rs @@ -182,9 +182,8 @@ fn panels_taller_than_their_space_have_a_scroll_bar_to_drag() { for _ in 0..6 { app.session.execute("renderQueue.add", json!({})).unwrap(); } - // The panel alone in the window (maximizing a stacked panel maximizes its whole stack). - app.ui.dock = effectcraft_ui_egui::dock::docked(effectcraft_ui_egui::dock::tabs(&[panel], 0)); app.show_panel(panel); + app.ui.maximized = Some(panel); h.run_steps(3); let (_, max) = scrolled(&h, id); assert!(max > 0.0, "{id}: the rows overflow"); diff --git a/crates/ui-egui/tests/it/ui_tabs.rs b/crates/ui-egui/tests/it/ui_tabs.rs index 5f3f6d4a..52d3258e 100644 --- a/crates/ui-egui/tests/it/ui_tabs.rs +++ b/crates/ui-egui/tests/it/ui_tabs.rs @@ -6,7 +6,7 @@ use effectcraft_engine::Session; use effectcraft_engine::project::ItemId; use effectcraft_ui_egui::EffectcraftApp; use effectcraft_ui_egui::dock::WorkspaceExt as _; -use effectcraft_ui_egui::dock::{Node, PanelKind, Workspace}; +use effectcraft_ui_egui::dock::{Node, PanelKind, SplitSize, Workspace}; use egui::{Event, Pos2, pos2}; use egui_kittest::Harness; use serde_json::json; @@ -204,46 +204,83 @@ fn panels_dock_beside_others_and_gutters_resize_them() { assert!((wider.width() - project.width() - 60.0).abs() < 2.0, "{} → {}", project.width(), wider.width()); } -/// Issue #171: the gaps between the right column's stacked panels drag to resize them (Preview -/// was stuck at its default height), not below a minimum; the heights stay in the layout. +/// The divider below the Preview frame: the horizontal gutter between it and the Properties +/// frame. +fn preview_divider(h: &Harness<'_, EffectcraftApp>) -> egui::Rect { + let (preview, props) = (rect(h, "panel.Preview"), rect(h, "panel.Properties")); + h.state() + .auto + .previous + .iter() + .filter(|e| e.id.starts_with("dock.gutter.") && e.rect[2] > e.rect[3]) + .map(|e| egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))) + .find(|g| g.x_range().contains(preview.center().x) && g.center().y >= preview.max.y - 1.0 && g.center().y <= props.min.y + 1.0) + .expect("a gutter between Preview and Properties") +} + +/// Default's right column: a short Preview frame (46 points of body) and the Properties frame +/// below it taking the rest of the column, at any window height. #[test] -fn stacked_panel_gaps_resize_the_panels() { +fn the_default_preview_frame_is_short_and_properties_takes_the_rest() { let (mut h, _, _) = harness(); - fn height(w: &Workspace, p: PanelKind) -> Option> { - fn rec(n: &Node, p: PanelKind) -> Option> { + let body = |h: &Harness<'_, EffectcraftApp>| rect(h, "panel.Preview").height(); + assert!((body(&h) - 46.0).abs() < 1.5, "Preview {}", body(&h)); + let props = rect(&h, "panel.Properties"); + assert!(props.height() > 300.0, "Properties {props:?}"); + // A taller window gives the room to Properties. + let size = h.ctx.content_rect().size(); + h.set_size(size + egui::vec2(0.0, 200.0)); + h.run_steps(3); + assert!((body(&h) - 46.0).abs() < 1.5, "Preview {}", body(&h)); + assert!((rect(&h, "panel.Properties").height() - props.height() - 200.0).abs() < 2.0); +} + +/// Issue #171: the divider below the Preview frame drags to resize it (Preview was stuck at its +/// default height), not below a minimum; the new height stays in the layout. +#[test] +fn the_preview_frame_divider_resizes_it() { + let (mut h, _, _) = harness(); + fn preview_size(w: &Workspace) -> Option { + fn rec(n: &Node) -> Option { match n { - Node::Split { first, second, .. } => rec(first, p).or_else(|| rec(second, p)), - Node::Stack { entries } => entries.iter().find(|e| e.panel == p).map(|e| e.height), + Node::Split { first, second, size, .. } => match first.as_ref() { + Node::Tabs { panels, .. } if panels.contains(&PanelKind::Preview) => Some(*size), + _ => rec(first).or_else(|| rec(second)), + }, _ => None, } } - rec(w.layout.root.as_ref()?, p) + rec(w.layout.root.as_ref()?) } let (preview, props) = (rect(&h, "panel.Preview"), rect(&h, "panel.Properties")); - let gap = rect(&h, "dock.gutter.bs0"); - assert!(gap.center().y >= preview.max.y - 1.0 && gap.center().y <= props.min.y + 1.0, "the gap below Preview: {gap:?}"); + let gap = preview_divider(&h); drag(&mut h, gap.center(), gap.center() + egui::vec2(0.0, 150.0)); let (p2, q2) = (rect(&h, "panel.Preview"), rect(&h, "panel.Properties")); assert!((p2.height() - preview.height() - 150.0).abs() < 2.0, "Preview {} → {}", preview.height(), p2.height()); assert!((props.height() - q2.height() - 150.0).abs() < 2.0, "Properties {} → {}", props.height(), q2.height()); assert!((q2.max.y - props.max.y).abs() < 1.0, "the column stays filled"); - // The saved heights share the column as the panels do. - let saved = |p| height(&h.state().ui.dock, p).flatten().expect("the height is in the layout"); - let (sp, sq) = (saved(PanelKind::Preview), saved(PanelKind::Properties)); - assert!((sp / sq - p2.height() / q2.height()).abs() < 0.02, "{sp}/{sq} vs {}/{}", p2.height(), q2.height()); - // Collapsing and expanding Preview keeps its height. - let toggle = rect(&h, "panel.header.Preview"); - click_n(&mut h, toggle.center(), 1); - assert!(!h.state().ui.dock.is_visible(PanelKind::Preview)); - click_n(&mut h, toggle.center(), 1); - assert!((rect(&h, "panel.Preview").height() - p2.height()).abs() < 1.0); + // The frame keeps a fixed height, 150 points more. + let Some(SplitSize::FixedFirst(size)) = preview_size(&h.state().ui.dock) else { panic!("{:?}", preview_size(&h.state().ui.dock)) }; + assert!((size - effectcraft_ui_egui::dock::PREVIEW_FRAME - 150.0).abs() < 2.0, "{size}"); // Dragged up to Preview's top, it stops at its minimum. - let gap = rect(&h, "dock.gutter.bs0"); + let gap = preview_divider(&h); let top = rect(&h, "panel.Preview").min.y; drag(&mut h, gap.center(), pos2(gap.center().x, top)); assert!((rect(&h, "panel.Preview").height() - 40.0).abs() < 1.0, "{}", rect(&h, "panel.Preview").height()); } +/// Maximize acts on one frame: the Preview frame fills the dock area, Properties hides. +#[test] +fn maximizing_preview_maximizes_its_frame_alone() { + let (mut h, _, _) = harness(); + let preview = rect(&h, "panel.Preview"); + h.state_mut().ui.maximized = Some(PanelKind::Preview); + h.run_steps(3); + let big = rect(&h, "panel.Preview"); + assert!(big.height() > preview.height() + 300.0 && big.width() > preview.width() + 300.0, "{preview:?} → {big:?}"); + assert!(h.state().auto.find("panel.Properties").is_none(), "the Properties frame is not shown"); +} + /// Issue #45: the Project panel's details (name, size, duration) stay inside the panel with a /// margin, cut short with "…" when they don't fit; so does the hint shown with nothing selected. #[test] @@ -340,6 +377,10 @@ fn dragging_the_viewer_timeline_divider_far_up_does_not_crash() { #[test] fn a_stacked_panels_whole_header_bar_toggles_it() { let (mut h, _, _) = harness(); + use effectcraft_ui_egui::dock; + let stack = dock::stack(&[(PanelKind::Preview, true, Some(46.0)), (PanelKind::Properties, true, None)]); + h.state_mut().ui.dock = dock::docked(dock::hsplit(SplitSize::FixedSecond(300.0), dock::tabs(&[PanelKind::Composition], 0), stack)); + h.run_steps(3); let open = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.is_visible(PanelKind::Preview); assert!(open(&h)); let toggle = rect(&h, "panel.header.Preview"); @@ -357,30 +398,23 @@ fn a_stacked_panels_whole_header_bar_toggles_it() { assert!(open(&h), "a click on the bar away from the button opens it"); } -/// #302: a panel dropped onto a stacked panel joins the stack as a stacked panel of its own -/// below it (each stacked entry holds one panel), and opens and closes on its own. +/// #302: a panel dropped onto the Properties frame becomes a tab in that frame; its tabs switch +/// the frame's panel, and the Preview frame above keeps showing. #[test] -fn a_tab_dropped_on_a_stacked_panel_joins_its_frame() { +fn a_tab_dropped_on_a_frame_joins_it() { let (mut h, _, _) = harness(); let dock = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.clone(); - let stacked = |h: &Harness<'_, EffectcraftApp>| match &h.state().ui.dock.layout.root { - Some(Node::Split { second, .. }) => match second.as_ref() { - Node::Stack { entries } => entries.iter().map(|e| e.panel).collect::>(), - other => panic!("{other:?}"), - }, - other => panic!("{other:?}"), - }; let fx = rect(&h, "panel.tab.EffectControls"); let props = rect(&h, "panel.Properties"); drag(&mut h, fx.center(), props.center()); - let order = stacked(&h); - let at = order.iter().position(|p| *p == PanelKind::Properties).unwrap(); - assert_eq!(order.get(at + 1), Some(&PanelKind::EffectControls), "{order:?}"); - assert!(dock(&h).is_visible(PanelKind::EffectControls) && dock(&h).is_visible(PanelKind::Properties)); - // Its header button closes it alone. - let toggle = rect(&h, "panel.header.EffectControls"); - click_n(&mut h, toggle.center(), 1); - assert!(!dock(&h).is_visible(PanelKind::EffectControls) && dock(&h).is_visible(PanelKind::Properties)); + let group = group_of(&dock(&h), PanelKind::Properties).unwrap(); + assert!(group.starts_with(&[PanelKind::Properties]) && group.contains(&PanelKind::EffectControls), "{group:?}"); + assert!(dock(&h).is_visible(PanelKind::EffectControls) && !dock(&h).is_visible(PanelKind::Properties)); + assert!(dock(&h).is_visible(PanelKind::Preview)); + // The frame's other tab shows Properties again, the frame stays open. + let tab = rect(&h, "panel.tab.Properties"); + click_n(&mut h, tab.center(), 1); + assert!(dock(&h).is_visible(PanelKind::Properties) && !dock(&h).is_visible(PanelKind::EffectControls)); // The layout (saved workspaces) round-trips. let s = serde_json::to_string(&dock(&h)).unwrap(); assert_eq!(serde_json::from_str::(&s).unwrap(), dock(&h)); diff --git a/crates/ui-egui/tests/it/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs index 4b7a13ac..ed5baca2 100644 --- a/crates/ui-egui/tests/it/ui_text_edit.rs +++ b/crates/ui-egui/tests/it/ui_text_edit.rs @@ -147,8 +147,6 @@ fn type_tool_click_type_edit_and_commit() { let (_, _, a, c) = edited(&h); assert_eq!((a, c), (0, 10)); h.state_mut().show_panel(PanelKind::Character); - // Room for the whole Character panel in the stack. - h.state_mut().apply_dock(craft_ui::docking::Action::SetStackOpen { panel: PanelKind::Properties, open: false }).unwrap(); h.run_steps(3); let bold = rect(&h, "character.fauxBold"); click(&mut h, bold.center(), Modifiers::NONE); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 49cdca8e..c0229931 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -239,8 +239,10 @@ Saved workspaces (`workspaces.json`, version 2) hold craft-ui `Workspace`s: the 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 +floating windows and their native window places. A column of stacked frames becomes tab groups +split top to bottom: each open frame keeps all its tabs and its height (as a fixed size), and a +closed frame's tabs join the open frame above it. A native window's place moves from the OS +window's outer position to its content's, by the main window's title bar, before the first save. +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.