mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 11:07:54 -06:00
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.
This commit is contained in:
+35
-19
@@ -238,6 +238,12 @@ fn workspace_floating(name: &str) -> Vec<Floating<PanelKind>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<PanelKind> {
|
||||
),
|
||||
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<PanelKind> {
|
||||
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<PanelKind> {
|
||||
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<PanelKind>) -> 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]
|
||||
|
||||
@@ -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<Rect>, inner: Option<Rect>, 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"));
|
||||
|
||||
@@ -577,6 +577,11 @@ pub struct UiState {
|
||||
/// The panel maximized to fill the dock area (`~`), if any.
|
||||
#[serde(default)]
|
||||
pub maximized: Option<PanelKind>,
|
||||
/// 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<u64>,
|
||||
/// 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(),
|
||||
|
||||
@@ -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<bool, String> {
|
||||
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::<Vec<_>>(), 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]
|
||||
|
||||
@@ -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<Snapshot, String> {
|
||||
pub(super) fn migrate(value: serde_json::Value, ui: &UiState) -> Result<(Snapshot, Vec<u64>), 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<Node<PanelKind>, 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<PanelKind>,
|
||||
active: usize,
|
||||
height: Option<f32>,
|
||||
}
|
||||
|
||||
/// A column of stacked frames as tab groups split top to bottom (see the module docs).
|
||||
fn column(entries: Vec<OldStackEntry>) -> Result<Node<PanelKind>, String> {
|
||||
let mut frames: Vec<Frame> = Vec::new();
|
||||
// Tabs of closed frames above the first open one.
|
||||
let mut waiting: Vec<PanelKind> = 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<Node<PanelKind>> = 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())
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
// 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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<Option<f32>> {
|
||||
fn rec(n: &Node<PanelKind>, p: PanelKind) -> Option<Option<f32>> {
|
||||
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<SplitSize> {
|
||||
fn rec(n: &Node<PanelKind>) -> Option<SplitSize> {
|
||||
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::<Vec<_>>(),
|
||||
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::<Workspace>(&s).unwrap(), dock(&h));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user