diff --git a/Cargo.lock b/Cargo.lock
index 94af97cf..103770e8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -904,12 +904,12 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
+source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3"
[[package]]
name = "craft-media"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4"
+source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3"
dependencies = [
"thiserror 2.0.21",
]
@@ -917,9 +917,13 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
+source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3"
dependencies = [
"egui",
+ "log",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
"serde",
]
diff --git a/Cargo.toml b/Cargo.toml
index f5637624..1c1c0988 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -45,13 +45,13 @@ zip = { version = "9", default-features = false }
cpal = "0.18"
# Which file paths stored in a session may be opened.
-craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
+craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
# Video containers and bitstreams, shared with the other craft apps.
-craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" }
+craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
# ui
-craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
+craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", features = ["serde"] }
# egui 0.37 needs craft-libs#199 (craft-ui) first; pin 0.36 until it lands.
egui = "0.36"
eframe = { version = "0.36", default-features = false }
diff --git a/README.md b/README.md
index 5d5e3a7b..67c73049 100644
--- a/README.md
+++ b/README.md
@@ -387,4 +387,4 @@ Forks and modified versions must remove them.
Made by the ArtCraft team and community.
-Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
+Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float Panel, Move Panel to Group, and Close Panel; the tab group's menu adds Float Tab Group and Close Tab Group. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
diff --git a/apps/soundcraft/src/config.rs b/apps/soundcraft/src/config.rs
index 5efab9a7..bd8f6639 100644
--- a/apps/soundcraft/src/config.rs
+++ b/apps/soundcraft/src/config.rs
@@ -3,6 +3,8 @@
use std::ffi::OsString;
use std::path::{Path, PathBuf};
+use soundcraft_ui_egui::docking;
+
/// Portable mode (#268): this file beside the executable keeps settings, autosaves, presets and
/// logs in [`PORTABLE_DATA_DIR`] next to it instead of the user profile.
const PORTABLE_MARKER: &str = "portable.txt";
@@ -88,19 +90,18 @@ fn read_ui(path: &std::path::Path, recover_docking: bool) -> Result(value.clone()) {
- Ok(workspace) => if mix { workspace.validate_mix() } else { workspace.validate() }.is_err(),
- Err(_) => true,
- });
+ let unreadable = fields.get(field).is_some_and(|value| match serde_json::from_value::(value.clone()) {
+ Ok(workspace) => if mix { docking::validate_mix(&workspace) } else { docking::validate(&workspace) }.is_err(),
+ Err(_) => true,
+ });
if unreadable {
fields.remove(field);
}
}
}
let state: soundcraft_ui_egui::UiState = serde_json::from_value(saved).map_err(|error| format!("Settings could not be restored: {error}"))?;
- state.docking.validate()?;
- state.mix_docking.validate_mix()?;
+ docking::validate(&state.docking)?;
+ docking::validate_mix(&state.mix_docking)?;
Ok(Some(state))
}
@@ -111,8 +112,8 @@ pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Resu
use std::io::Write as _;
// Loading can recover a future layout for this session, but saving must preserve its source.
read_ui(path, false)?;
- ui.docking.validate()?;
- ui.mix_docking.validate_mix()?;
+ docking::validate(&ui.docking)?;
+ docking::validate_mix(&ui.mix_docking)?;
let mut saved = ui.clone();
saved.plugin_windows.clear();
saved.status.clear();
@@ -160,18 +161,18 @@ mod config_tests {
app.ui = load_ui(&path).unwrap().unwrap();
assert_eq!(app.ui.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), edit);
- assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]);
+ assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]);
app.ui.window = MainWindow::Edit;
app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap();
- assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]);
+ assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]);
let mut invalid_mix = app.ui.clone();
- invalid_mix.mix_docking = soundcraft_ui_egui::docking::Workspace::default();
+ invalid_mix.mix_docking = soundcraft_ui_egui::docking::edit_default();
let invalid_bytes = serde_json::to_vec(&invalid_mix).unwrap();
std::fs::write(&path, &invalid_bytes).unwrap();
let recovered = load_ui(&path).unwrap().unwrap();
assert_eq!(recovered.theme, app.ui.theme);
assert_eq!(serde_json::to_value(recovered.docking).unwrap(), serde_json::to_value(&app.ui.docking).unwrap());
- recovered.mix_docking.validate_mix().unwrap();
+ soundcraft_ui_egui::docking::validate_mix(&recovered.mix_docking).unwrap();
assert!(save_ui(&path, &app.ui).is_err());
assert_eq!(std::fs::read(&path).unwrap(), invalid_bytes);
std::fs::write(&path, b"damaged settings").unwrap();
@@ -201,8 +202,8 @@ mod config_tests {
let recovered = load_ui(&path).unwrap().unwrap();
assert_eq!(recovered.theme, app.ui.theme);
assert!(recovered.narrow_mix);
- recovered.docking.validate().unwrap();
- recovered.mix_docking.validate_mix().unwrap();
+ soundcraft_ui_egui::docking::validate(&recovered.docking).unwrap();
+ soundcraft_ui_egui::docking::validate_mix(&recovered.mix_docking).unwrap();
let other = if field == "docking" { "mix_docking" } else { "docking" };
assert_eq!(serde_json::to_value(&recovered).unwrap()[other], saved[other]);
assert!(save_ui(&path, &recovered).is_err());
diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs
index f2668fa8..3e0a5f28 100644
--- a/crates/ui-egui/src/docking.rs
+++ b/crates/ui-egui/src/docking.rs
@@ -1,7 +1,10 @@
//! Application panel identities and persistence around the shared docking renderer.
//! Document contents remain owned by their existing view implementations.
-use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
+use craft_ui::docking::{
+ Action, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Floating, Layout, Node, PanelLimits, Permissions, Placement,
+ UiDock, Zone, ZoneName,
+};
use craft_ui::layout::{SplitAxis, SplitSize};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -19,16 +22,6 @@ pub enum Panel {
impl Panel {
pub const ALL: &[Self] = &[Self::Canvas, Self::Tracks, Self::Clips, Self::Midi, Self::Memory, Self::History];
- pub fn id(self) -> &'static str {
- match self {
- Self::Canvas => "canvas",
- Self::Tracks => "tracks",
- Self::Clips => "clips",
- Self::Midi => "midi",
- Self::Memory => "memory",
- Self::History => "history",
- }
- }
fn label(self) -> &'static str {
match self {
Self::Canvas => "Session",
@@ -49,15 +42,16 @@ impl Panel {
Self::History => Zone::Right,
}
}
- fn parse(id: &str) -> Result {
- Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
- }
}
+/// An Edit or Mix panel arrangement, saved with the UI preferences.
+pub type Workspace = craft_ui::docking::Workspace;
+
const MIDI_TAB_HEIGHT: f32 = 26.0;
+/// The document view stays docked and open, and panels open in splits beside it.
fn permissions(panel: &Panel) -> Permissions {
- if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
+ if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() }
}
fn tabs(panel: Panel) -> Node {
@@ -68,232 +62,149 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node,
- /// Closed panels retain their most recent group, side, size or floating rectangle.
- pub hidden: Vec<(Panel, Location)>,
+/// The dock area that draws the Edit or Mix workspace.
+pub fn area_id(window: crate::MainWindow) -> egui::Id {
+ egui::Id::new(("soundcraft-docking", window == crate::MainWindow::Edit))
}
-impl Default for Workspace {
- fn default() -> Self {
- Self {
- layout: Layout {
- root: Some(split(
- SplitAxis::Vertical,
- SplitSize::FixedSecond(320.0 + MIDI_TAB_HEIGHT),
- split(
- SplitAxis::Horizontal,
- SplitSize::FixedFirst(168.0),
- tabs(Panel::Tracks),
- split(SplitAxis::Horizontal, SplitSize::FixedSecond(230.0), tabs(Panel::Canvas), tabs(Panel::Clips)),
- ),
- tabs(Panel::Midi),
- )),
- floating: vec![
- craft_ui::docking::Floating { panels: vec![Panel::Memory], active: 0, rect: [160.0, 120.0, 480.0, 280.0] },
- craft_ui::docking::Floating { panels: vec![Panel::History], active: 0, rect: [680.0, 120.0, 260.0, 300.0] },
- ],
- },
- hidden: Vec::new(),
- }
- }
+fn area(edit: bool) -> egui::Id {
+ area_id(if edit { crate::MainWindow::Edit } else { crate::MainWindow::Mix })
}
-impl Workspace {
- pub fn validate(&self) -> Result<(), String> {
- self.layout.validate().map_err(|error| error.to_string())?;
- if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() {
- return Err("The panel layout must contain its document view and bounded hidden panels".into());
- }
- // Saved settings cannot turn the protected document view into a floating or closable tab.
- if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
- return Err("The document view cannot float".into());
- }
- let mut nodes: Vec<_> = self.layout.root.iter().collect();
- while let Some(node) = nodes.pop() {
- match node {
- Node::Split { first, second, .. } => {
- nodes.push(first);
- nodes.push(second);
- }
- Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
- return Err("The document view cannot share a tab group".into());
- }
- Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
- return Err("The document view cannot be collapsed".into());
- }
- _ => {}
- }
- }
- let mut seen = std::collections::HashSet::new();
- for (panel, location) in &self.hidden {
- if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
- return Err("A hidden panel has an invalid or duplicate identity".into());
- }
- if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
- return Err("A hidden panel cannot restore over the document view".into());
- }
- let mut probe = self.layout.clone();
- if let Err(error) = probe.restore(*panel, location)
- && error != craft_ui::docking::DockError::MissingTarget
- {
- return Err(error.to_string());
- }
- }
- Ok(())
- }
-
- pub fn validate_mix(&self) -> Result<(), String> {
- self.validate()?;
- if self.layout.contains(&Panel::Clips) || self.layout.contains(&Panel::Midi) {
- return Err("Clips and MIDI panels belong to the Edit window".into());
- }
- Ok(())
- }
-
- pub fn apply(&mut self, action: Action) -> Result<(), String> {
- self.validate()?;
- let mut next = self.clone();
- if let Action::Close { panel } = &action {
- let location = next.layout.location(panel).map_err(|error| error.to_string())?;
- next.hidden.retain(|(id, _)| id != panel);
- next.hidden.push((*panel, location));
- } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
- next.hidden.retain(|(id, _)| id != panel);
- }
- next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
- next.validate()?;
- *self = next;
- Ok(())
- }
-
- pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
- if self.layout.contains(&panel) == visible {
- return Ok(());
- }
- if !visible {
- return self.apply(Action::Close { panel });
- }
- self.validate()?;
- let mut next = self.clone();
- let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
- next.hidden.retain(|(id, _)| *id != panel);
- if let Some(saved) = &mut location
- && saved.floating.is_some()
- && saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor)))
- {
- // The other members of a floating group may have been closed too.
- saved.anchor = None;
- saved.placement = Placement::Tab { before: None };
- }
- let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
- if !restored {
- next.layout
- .apply_with_permissions(
- Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
- permissions,
- )
- .map_err(|error| error.to_string())?;
- }
- next.validate()?;
- *self = next;
- Ok(())
- }
-
- fn command(&mut self, params: &Value) -> Result<(), String> {
- self.validate()?;
- if let Some(action) = params.get("action") {
- let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
- return self.apply(action);
- }
- let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
- if operation == "resizeSplit" {
- let path: Vec =
- serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
- let size: SplitSize =
- serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
- return self.apply(Action::ResizeSplit { path, size });
- }
- let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
- let action = match operation {
- "open" => return self.set_visible(panel, true),
- "close" => Action::Close { panel },
- "activate" => Action::Activate { panel },
- "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
- "resizeStack" => {
- let value = params.get("height").ok_or("height is required")?;
- let height = if value.is_null() {
- None
- } else {
- Some(
- value
- .as_f64()
- .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
- .ok_or("height must be finite and bounded")? as f32,
- )
- };
- Action::ResizeStack { panel, height }
- }
- "float" | "moveFloating" => {
- let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
- let mut values = [0.0f32; 4];
- for (output, value) in values.iter_mut().zip(rect) {
- *output = value
- .as_f64()
- .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
- .ok_or("rect values must be finite and bounded")? as f32;
- }
- if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
- }
- "move" => {
- let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
- let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
- "center" => Placement::Split(Zone::Center),
- "left" => Placement::Split(Zone::Left),
- "right" => Placement::Split(Zone::Right),
- "top" => Placement::Split(Zone::Top),
- "bottom" => Placement::Split(Zone::Bottom),
- _ => return Err("zone must be center, left, right, top or bottom".into()),
- };
- let placement = if let Some(before) = params.get("before") {
- if !matches!(placement, Placement::Split(Zone::Center)) {
- return Err("before only applies to a center move".into());
- }
- Placement::Tab {
- before: if before.is_null() {
- None
- } else {
- Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
- },
- }
- } else {
- placement
- };
- Action::Move { panel, anchor, placement }
- }
- _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
- };
- self.apply(action)
- }
+/// The Edit window's built-in arrangement.
+pub fn edit_default() -> Workspace {
+ Workspace::new(Layout {
+ root: Some(split(
+ SplitAxis::Vertical,
+ SplitSize::FixedSecond(320.0 + MIDI_TAB_HEIGHT),
+ split(
+ SplitAxis::Horizontal,
+ SplitSize::FixedFirst(168.0),
+ tabs(Panel::Tracks),
+ split(SplitAxis::Horizontal, SplitSize::FixedSecond(230.0), tabs(Panel::Canvas), tabs(Panel::Clips)),
+ ),
+ tabs(Panel::Midi),
+ )),
+ // Fixed ids: the default is rebuilt every frame and must stay the same windows.
+ floating: vec![
+ Floating::single(vec![Panel::Memory], 0, [160.0, 120.0, 480.0, 280.0]).with_id(1),
+ Floating::single(vec![Panel::History], 0, [680.0, 120.0, 260.0, 300.0]).with_id(2),
+ ],
+ })
}
/// Mix retains its own arrangement; the timeline-only Clips and MIDI panels stay in Edit.
pub fn mix_default() -> Workspace {
- let mut workspace = Workspace::default();
- let _ = workspace.set_visible(Panel::Clips, false);
- let _ = workspace.set_visible(Panel::Midi, false);
+ let mut workspace = edit_default();
+ let _ = set_visible(&mut workspace, Panel::Clips, false);
+ let _ = set_visible(&mut workspace, Panel::Midi, false);
workspace
}
+/// The document view where the Edit window's rules keep it: in the docked frames, alone in
+/// its tab group. `None` when `node` does not hold it.
+fn canvas_placement(node: &Node) -> Option> {
+ match node {
+ Node::Split { first, second, .. } => canvas_placement(first).or_else(|| canvas_placement(second)),
+ Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) => {
+ Some(if panels.len() == 1 { Ok(()) } else { Err("The document view cannot share a tab group") })
+ }
+ Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => Some(Err("The document view cannot be collapsed")),
+ Node::Dock { columns, .. } if columns.iter().flat_map(|column| &column.groups).any(|group| group.panels.contains(&Panel::Canvas)) => {
+ Some(Err("The document view cannot join a dock column"))
+ }
+ _ => None,
+ }
+}
+
+/// Check a workspace against craft-ui's rules and SoundCraft's own: saved settings cannot
+/// float, hide, collapse or share the protected document view.
+pub fn validate(workspace: &Workspace) -> Result<(), String> {
+ workspace.validate().map_err(|error| error.to_string())?;
+ let layout = &workspace.layout;
+ if !layout.contains(&Panel::Canvas) || workspace.hidden.len() > Panel::ALL.len() {
+ return Err("The panel layout must contain its document view and bounded hidden panels".into());
+ }
+ if layout.floating.iter().any(|window| window.contains(&Panel::Canvas)) {
+ return Err("The document view cannot float".into());
+ }
+ layout.root.as_ref().and_then(canvas_placement).unwrap_or(Err("The document view cannot float"))?;
+ for (panel, location) in &workspace.hidden {
+ if *panel == Panel::Canvas {
+ return Err("A hidden panel has an invalid or duplicate identity".into());
+ }
+ if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
+ return Err("A hidden panel cannot restore over the document view".into());
+ }
+ let mut probe = layout.clone();
+ if let Err(error) = probe.restore(*panel, location)
+ && error != DockError::MissingTarget
+ {
+ return Err(error.to_string());
+ }
+ }
+ Ok(())
+}
+
+/// [`validate`] plus the Mix window's rule: Clips and MIDI stay in Edit.
+pub fn validate_mix(workspace: &Workspace) -> Result<(), String> {
+ validate(workspace)?;
+ if workspace.layout.contains(&Panel::Clips) || workspace.layout.contains(&Panel::Midi) {
+ return Err("Clips and MIDI panels belong to the Edit window".into());
+ }
+ Ok(())
+}
+
+/// Change a copy of `workspace` and keep it only when it passes [`validate`].
+fn checked(workspace: &mut Workspace, change: impl FnOnce(&mut Workspace) -> Result<(), DockError>) -> Result<(), String> {
+ validate(workspace)?;
+ let mut next = workspace.clone();
+ change(&mut next).map_err(|error| error.to_string())?;
+ validate(&next)?;
+ *workspace = next;
+ Ok(())
+}
+
+fn apply(workspace: &mut Workspace, action: Action) -> Result<(), String> {
+ checked(workspace, |next| next.apply(action, permissions).map(drop))
+}
+
+/// Show a panel at its recorded place, else beside the document view on its default side.
+fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> {
+ if workspace.layout.contains(&panel) == visible {
+ return Ok(());
+ }
+ checked(workspace, |next| {
+ if !visible {
+ return next.apply(Action::Close { panel }, permissions).map(drop);
+ }
+ if next.set_visible(panel, true, None, permissions).is_ok() {
+ return Ok(());
+ }
+ next.apply(Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, permissions).map(drop)
+ })
+}
+
+/// Run a shared docking command. A hidden panel that is opened or activated comes back with
+/// [`set_visible`], so it gets SoundCraft's default place when it has no recorded one.
+fn run(workspace: &mut Workspace, command: &DockCommand) -> Result<(), String> {
+ if let DockCommand::Activate { panel } | DockCommand::UiDock(UiDock::Operation(DockOperation::Open { panel } | DockOperation::Activate { panel })) =
+ command
+ && !workspace.layout.contains(panel)
+ {
+ return set_visible(workspace, *panel, true);
+ }
+ // Only `window.panel.float` reads the float space, and SoundCraft does not register it.
+ checked(workspace, |next| command.apply(next, false, &FloatSpace::new(egui::Rect::NOTHING), permissions).map(drop))
+}
+
fn sync(workspace: &mut Workspace, app: &crate::SoundApp, edit: bool) -> Result<(), String> {
- workspace.set_visible(Panel::Tracks, app.ui.show_tracks_list)?;
- workspace.set_visible(Panel::Memory, app.ui.show_memory_locations)?;
- workspace.set_visible(Panel::History, app.ui.show_undo_history)?;
+ set_visible(workspace, Panel::Tracks, app.ui.show_tracks_list)?;
+ set_visible(workspace, Panel::Memory, app.ui.show_memory_locations)?;
+ set_visible(workspace, Panel::History, app.ui.show_undo_history)?;
if edit {
- workspace.set_visible(Panel::Clips, app.ui.show_clip_list)?;
- workspace.set_visible(Panel::Midi, app.ui.show_midi_editor)?;
+ set_visible(workspace, Panel::Clips, app.ui.show_clip_list)?;
+ set_visible(workspace, Panel::Midi, app.ui.show_midi_editor)?;
}
Ok(())
}
@@ -316,20 +227,57 @@ fn store(app: &mut crate::SoundApp, workspace: Workspace, edit: bool) {
}
}
-pub fn command(app: &mut crate::SoundApp, reset: bool, params: &Value) -> Result {
+/// A `ui.dock` operation. Panels and zones are checked first so an error names the field;
+/// parsed through the untagged [`UiDock`], every mistake reads "did not match any variant".
+fn operation(fields: &serde_json::Map) -> Result, String> {
+ let name = fields.get("operation").ok_or("operation is required")?.as_str().ok_or("operation must be a string")?;
+ if name != "resizeSplit" && !fields.contains_key("panel") {
+ return Err("panel is required".into());
+ }
+ for field in ["panel", "target"] {
+ if let Some(value) = fields.get(field) {
+ let id = value.as_str().ok_or_else(|| format!("{field} must be a panel ID"))?;
+ serde_json::from_value::(json!(id)).map_err(|_| format!("Unknown panel: {id}"))?;
+ }
+ }
+ if let Some(zone) = fields.get("zone")
+ && serde_json::from_value::(zone.clone()).is_err()
+ {
+ return Err("zone must be tab, center, left, right, top, bottom, above or below".into());
+ }
+ serde_json::from_value(Value::Object(fields.clone())).map_err(|error| error.to_string())
+}
+
+/// `ui.dock` and `ui.dock.reset`: the shared [`DockCommand`] vocabulary, applied to the
+/// front window's workspace.
+pub fn command(app: &mut crate::SoundApp, id: &str, params: &Value) -> Result {
let edit = app.ui.window == crate::MainWindow::Edit;
+ if !edit && ["panel", "target"].iter().any(|field| matches!(params.get(*field).and_then(Value::as_str), Some("clips" | "midi"))) {
+ return Err("Clips and MIDI panels belong to the Edit window".into());
+ }
+ let mut fields = match params {
+ Value::Object(fields) => fields.clone(),
+ Value::Null => serde_json::Map::new(),
+ _ => return Err("Docking params must be an object".into()),
+ };
+ let command = if id == "ui.dock" && !fields.contains_key("action") {
+ DockCommand::UiDock(UiDock::Operation(operation(&fields)?))
+ } else {
+ fields.insert("command".into(), json!(id));
+ serde_json::from_value(Value::Object(fields)).map_err(|error| error.to_string())?
+ };
let previous = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() };
- let result = command_for(app, reset, params, edit)?;
+ let result = command_for(app, &command, edit)?;
let next = if edit { &app.ui.docking } else { &app.ui.mix_docking };
cancel_replaced_workspace(app, &previous, next, edit);
Ok(result)
}
fn cancel_replaced_workspace(app: &crate::SoundApp, previous: &Workspace, next: &Workspace, edit: bool) {
- if (previous.layout != next.layout || previous.hidden != next.hidden)
+ if previous != next
&& let Some(ctx) = &app.docking_context
{
- craft_ui::docking::cancel_drag::(ctx, egui::Id::new(("soundcraft-docking", edit)));
+ craft_ui::docking::cancel_drag::(ctx, area(edit));
}
}
@@ -339,22 +287,23 @@ pub(crate) fn cancel_replaced_state(app: &crate::SoundApp, next: &crate::UiState
cancel_replaced_workspace(app, &app.ui.mix_docking, &next.mix_docking, false);
}
-fn command_for(app: &mut crate::SoundApp, reset: bool, params: &Value, edit: bool) -> Result {
- if !edit && ["panel", "target"].iter().any(|field| matches!(params.get(*field).and_then(Value::as_str), Some("clips" | "midi"))) {
- return Err("Clips and MIDI panels belong to the Edit window".into());
- }
+fn command_for(app: &mut crate::SoundApp, command: &DockCommand, edit: bool) -> Result {
let mut workspace = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() };
- if !reset && !edit {
- workspace.validate_mix()?;
- }
- if reset {
- workspace = if edit { Workspace::default() } else { mix_default() };
+ if command.is_app_owned() {
+ // Reset is the only application-owned command SoundCraft registers.
+ if !matches!(command, DockCommand::ResetWorkspace {}) {
+ return Err("Named panel workspaces are not supported".into());
+ }
+ workspace = if edit { edit_default() } else { mix_default() };
sync(&mut workspace, app, edit)?;
} else {
- sync(&mut workspace, app, edit)?;
- workspace.command(params)?;
if !edit {
- workspace.validate_mix()?;
+ validate_mix(&workspace)?;
+ }
+ sync(&mut workspace, app, edit)?;
+ run(&mut workspace, command)?;
+ if !edit {
+ validate_mix(&workspace)?;
}
}
publish_visibility(app, &workspace, edit);
@@ -368,16 +317,16 @@ pub(crate) fn arrange(app: &mut crate::SoundApp) -> Result<(), String> {
let edit = app.ui.window == crate::MainWindow::Edit;
let mut workspace = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() };
if edit {
- workspace.validate()?;
+ validate(&workspace)?;
} else {
- workspace.validate_mix()?;
+ validate_mix(&workspace)?;
}
let previous = workspace.clone();
- let floating: Vec<_> = workspace.layout.floating.iter().filter_map(|group| group.panels.first().map(|panel| (*panel, group.rect))).collect();
+ let floating: Vec<_> = workspace.layout.floating.iter().filter_map(|window| window.panels().next().map(|panel| (*panel, window.rect))).collect();
for (index, (panel, mut rect)) in floating.into_iter().enumerate() {
rect[0] = 32.0 + index as f32 * 28.0;
rect[1] = 80.0 + index as f32 * 28.0;
- workspace.apply(Action::MoveFloating { panel, rect })?;
+ apply(&mut workspace, Action::MoveFloating { panel, rect })?;
}
cancel_replaced_workspace(app, &previous, &workspace, edit);
store(app, workspace, edit);
@@ -388,11 +337,12 @@ pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWin
app.docking_context = Some(ui.ctx().clone());
let t = crate::theme::Tokens::current();
let edit = window == crate::MainWindow::Edit;
- let mut workspace = if edit { std::mem::take(&mut app.ui.docking) } else { std::mem::replace(&mut app.ui.mix_docking, mix_default()) };
+ let mut workspace =
+ if edit { std::mem::replace(&mut app.ui.docking, edit_default()) } else { std::mem::replace(&mut app.ui.mix_docking, mix_default()) };
let before_sync = workspace.clone();
- if let Err(error) = (if edit { workspace.validate() } else { workspace.validate_mix() }).and_then(|()| sync(&mut workspace, app, edit)) {
+ if let Err(error) = (if edit { validate(&workspace) } else { validate_mix(&workspace) }).and_then(|()| sync(&mut workspace, app, edit)) {
app.ui.status = format!("Panel layout could not be restored: {error}");
- workspace = if edit { Workspace::default() } else { mix_default() };
+ workspace = if edit { edit_default() } else { mix_default() };
let _ = sync(&mut workspace, app, edit);
}
cancel_replaced_workspace(app, &before_sync, &workspace, edit);
@@ -407,7 +357,7 @@ pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWin
style.border = egui::Stroke::new(1.0, t.border);
style.accent = t.accent;
style.font = crate::theme::regular(11.5);
- let output = DockArea::new(egui::Id::new(("soundcraft-docking", edit))).show_with_limits(
+ let output = DockArea::new(area(edit)).show_with_limits(
ui,
&workspace.layout,
&style,
@@ -437,10 +387,7 @@ pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWin
publish_visibility(app, &workspace, edit);
store(app, workspace, edit);
for action in output.actions {
- if let Err(error) = serde_json::to_value(action)
- .map_err(|error| error.to_string())
- .and_then(|action| command_for(app, false, &json!({"action": action}), edit))
- {
+ if let Err(error) = command_for(app, &DockCommand::Layout { action }, edit) {
app.ui.status = format!("Panel move was not applied: {error}");
}
}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 140355c8..0d2dea2c 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -133,7 +133,7 @@ impl Default for UiState {
fn default() -> Self {
UiState {
recent_sessions: Vec::new(),
- docking: docking::Workspace::default(),
+ docking: docking::edit_default(),
mix_docking: docking::mix_default(),
window: MainWindow::Edit,
show_tracks_list: true,
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index cd879a72..a2a01304 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -504,7 +504,7 @@ fn wants_dialog(id: &str) -> bool {
/// Handle UI-layer commands. Returns None when `id` is not a UI command.
pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option> {
if matches!(id, "ui.dock" | "ui.dock.reset") {
- return Some(crate::docking::command(app, id == "ui.dock.reset", p));
+ return Some(crate::docking::command(app, id, p));
}
if let Some(v) = crate::extra_windows::run(app, id, p) {
return Some(Ok(v));
@@ -591,7 +591,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option toggle(&mut app.ui.show_configurations),
"window.config_new" | "window.config_update" => {
- if let Err(error) = app.ui.docking.validate().and_then(|()| app.ui.mix_docking.validate_mix()) {
+ if let Err(error) = crate::docking::validate(&app.ui.docking).and_then(|()| crate::docking::validate_mix(&app.ui.mix_docking)) {
return Some(Err(error));
}
let mut snap = app.ui.clone();
@@ -687,7 +687,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option(cur) {
Ok(ns) => {
- if let Err(error) = ns.docking.validate().and_then(|()| ns.mix_docking.validate_mix()) {
+ if let Err(error) = crate::docking::validate(&ns.docking).and_then(|()| crate::docking::validate_mix(&ns.mix_docking)) {
return Some(Err(error));
}
crate::docking::cancel_replaced_state(app, &ns);
diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs
index 3b60cd75..7cd3467f 100644
--- a/crates/ui-egui/src/windows.rs
+++ b/crates/ui-egui/src/windows.rs
@@ -391,7 +391,7 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
if ui.button(format!("{} {name}", i + 1)).clicked()
&& let Ok(mut st) = serde_json::from_value::(v.clone())
{
- if let Err(error) = st.docking.validate().and_then(|()| st.mix_docking.validate_mix()) {
+ if let Err(error) = crate::docking::validate(&st.docking).and_then(|()| crate::docking::validate_mix(&st.mix_docking)) {
app.ui.status = format!("Window configuration could not be restored: {error}");
} else {
st.configurations = app.ui.configurations.clone();
diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs
index c2cdf33a..d0013494 100644
--- a/crates/ui-egui/tests/it/docking_workflows.rs
+++ b/crates/ui-egui/tests/it/docking_workflows.rs
@@ -43,25 +43,29 @@ fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() {
assert_eq!(h.state().ui.docking.layout.location(&Panel::Clips).unwrap().anchor, Some(Panel::Tracks));
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").click_button(PointerButton::Secondary);
h.run_steps(3);
- h.get_by_label("Float panel").click();
+ h.get_by_label("Float Panel").click();
h.run_steps(5);
- let rect = h.state().ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Clips)).unwrap().rect;
+ let rect = h.state().ui.docking.layout.floating.iter().find(|g| g.contains(&Panel::Clips)).unwrap().rect;
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").click_button(PointerButton::Secondary);
h.run_steps(3);
- h.get_by_label("Close panel").click();
+ h.get_by_label("Close Panel").click();
h.run_steps(4);
assert!(!h.state().ui.show_clip_list);
h.state_mut().run("ui.dock", json!({"operation":"open","panel":"clips"})).unwrap();
h.run_steps(4);
- assert_eq!(h.state().ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Clips)).unwrap().rect, rect);
+ assert_eq!(h.state().ui.docking.layout.floating.iter().find(|g| g.contains(&Panel::Clips)).unwrap().rect, rect);
let saved = serde_json::to_string(&h.state().ui).unwrap();
let expected = serde_json::to_value(&h.state().ui.docking).unwrap();
let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0);
assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected);
reloaded.state_mut().run("ui.dock", json!({"operation":"move","panel":"clips","target":"tracks","zone":"center"})).unwrap();
reloaded.run_steps(4);
- reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").click();
+ // The narrow Tracks frame shows the active Clips tab and lists Tracks under "…".
+ reloaded.get_by_label("Panels").click();
reloaded.run_steps(3);
+ reloaded.get_by_label("Tracks and Groups").click();
+ reloaded.run_steps(3);
+ assert_eq!(reloaded.state().ui.docking.layout.panel_state(&Panel::Tracks), craft_ui::docking::PanelState::Front);
reloaded.get_by_label("Trim (F6)").click();
reloaded.run_steps(3);
assert_eq!(reloaded.state().engine.session().edit.tool, soundcraft_model::Tool::Trim);
@@ -82,6 +86,18 @@ fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() {
assert!(!reloaded.state().engine.transport.recording);
}
+#[test]
+fn activating_a_hidden_panel_shows_it_and_its_window_flag() {
+ let mut h = harness(None, 1400.0, 1.0);
+ h.state_mut().run("ui.dock", json!({"operation":"close","panel":"tracks"})).unwrap();
+ h.run_steps(4);
+ assert!(!h.state().ui.show_tracks_list);
+ h.state_mut().run("ui.dock", json!({"operation":"activate","panel":"tracks"})).unwrap();
+ h.run_steps(4);
+ assert!(h.state().ui.show_tracks_list);
+ assert_eq!(h.state().ui.docking.layout.panel_state(&Panel::Tracks), craft_ui::docking::PanelState::Front);
+}
+
#[test]
fn invalid_docking_requests_are_atomic_in_edit_and_mix() {
let mut h = harness(None, 1400.0, 1.0);
@@ -118,8 +134,8 @@ fn docking_renders_edit_and_mix_default_floating_and_redocked() {
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"tracks","target":"canvas","zone":"left"})).unwrap();
}
h.run_steps(5);
- h.state().ui.docking.validate().unwrap();
- h.state().ui.mix_docking.validate().unwrap();
+ soundcraft_ui_egui::docking::validate(&h.state().ui.docking).unwrap();
+ soundcraft_ui_egui::docking::validate(&h.state().ui.mix_docking).unwrap();
if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
std::fs::create_dir_all(&directory).unwrap();
h.render().unwrap().save(format!("{directory}/sound-{window:?}-{width}-{scale}-{state}.png")).unwrap();
@@ -143,7 +159,7 @@ fn reset_is_enabled_and_resolves_in_native_and_in_window_menu_trees() {
assert!(menus::item_state(&app, path, Some("ui.dock.reset")).0);
app.run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[50,60,260,420]})).unwrap();
menus::invoke_menu(&mut app, "ui.dock.reset", path);
- assert!(!app.ui.docking.layout.floating.iter().any(|group| group.panels.contains(&Panel::Tracks)));
+ assert!(!app.ui.docking.layout.floating.iter().any(|group| group.contains(&Panel::Tracks)));
}
#[test]
@@ -154,6 +170,116 @@ fn legacy_preferences_restore_the_theme_and_validate_default_docking() {
object.remove("mix_docking");
let restored: UiState = serde_json::from_value(value).unwrap();
assert_eq!(restored.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
- restored.docking.validate().unwrap();
- restored.mix_docking.validate_mix().unwrap();
+ soundcraft_ui_egui::docking::validate(&restored.docking).unwrap();
+ soundcraft_ui_egui::docking::validate_mix(&restored.mix_docking).unwrap();
+}
+
+/// The tab group holding `panel` in the docked frames.
+fn docked_group(node: &craft_ui::docking::Node, panel: Panel) -> Option> {
+ use craft_ui::docking::Node;
+ match node {
+ Node::Split { first, second, .. } => docked_group(first, panel).or_else(|| docked_group(second, panel)),
+ Node::Tabs { panels, .. } if panels.contains(&panel) => Some(panels.clone()),
+ _ => None,
+ }
+}
+
+#[test]
+fn v1_workspace_settings_load_with_their_layout_and_hidden_places() {
+ use craft_ui::docking::Node;
+ // A workspace as releases before the shared docking model saved it: no version, and a
+ // floating window as one `{panels, active, rect}` group.
+ let v1 = json!({
+ "layout": {
+ "root": {"Split": {
+ "axis": "Horizontal",
+ "size": {"FixedFirst": 200.0},
+ "first": {"Tabs": {"panels": ["tracks"], "active": 0}},
+ "second": {"Tabs": {"panels": ["canvas"], "active": 0}}
+ }},
+ "floating": [{"panels": ["clips", "midi"], "active": 1, "rect": [300.0, 120.0, 420.0, 360.0]}]
+ },
+ "hidden": [
+ ["memory", {"floating": [160.0, 120.0, 480.0, 280.0], "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null}],
+ ["history", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 260.0}, "stack": null, "sibling": ["canvas"]}]
+ ]
+ });
+ let mut settings = serde_json::to_value(UiState::default()).unwrap();
+ settings["docking"] = v1;
+ settings["show_memory_locations"] = json!(false);
+ settings["show_undo_history"] = json!(false);
+ settings["show_midi_editor"] = json!(true);
+ let state: UiState = serde_json::from_value(settings).unwrap();
+ soundcraft_ui_egui::docking::validate(&state.docking).unwrap();
+ let floating = &state.docking.layout.floating;
+ assert_eq!(floating.len(), 1);
+ assert_eq!(floating[0].root, Node::Tabs { panels: vec![Panel::Clips, Panel::Midi], active: 1 });
+ assert_eq!(floating[0].rect, [300.0, 120.0, 420.0, 360.0]);
+ let root = state.docking.layout.root.clone().unwrap();
+ assert!(matches!(&root, Node::Split { size: craft_ui::layout::SplitSize::FixedFirst(200.0), .. }));
+
+ let mut h = harness(Some(state), 1400.0, 1.0);
+ assert_eq!(h.state().ui.docking.layout.root.as_ref(), Some(&root), "drawing the loaded layout must not rearrange it");
+ assert_eq!(h.state().ui.docking.layout.floating[0].rect, [300.0, 120.0, 420.0, 360.0]);
+ h.state_mut().run("ui.dock", json!({"operation":"open","panel":"memory"})).unwrap();
+ h.state_mut().run("ui.dock", json!({"operation":"open","panel":"history"})).unwrap();
+ h.run_steps(3);
+ let layout = &h.state().ui.docking.layout;
+ assert_eq!(layout.floating.iter().find(|w| w.contains(&Panel::Memory)).unwrap().rect, [160.0, 120.0, 480.0, 280.0]);
+ assert_eq!(docked_group(layout.root.as_ref().unwrap(), Panel::History), Some(vec![Panel::History]));
+ assert!(h.state().ui.show_memory_locations && h.state().ui.show_undo_history);
+
+ // Written back, a one-group window keeps the v1 shape older builds read.
+ let saved = serde_json::to_value(&h.state().ui.docking).unwrap();
+ let window = saved["layout"]["floating"].as_array().unwrap().iter().find(|w| w["panels"] == json!(["clips", "midi"])).unwrap();
+ assert_eq!(window["active"], json!(1));
+ assert_eq!(window["rect"], json!([300.0, 120.0, 420.0, 360.0]));
+}
+
+#[test]
+fn dragging_a_group_by_its_drag_bar_joins_it_to_another_group() {
+ use craft_ui::docking::{DockArea, Hit};
+ let mut h = harness(None, 1400.0, 1.0);
+ let root = h.state().ui.docking.layout.root.clone().unwrap();
+ assert_eq!(docked_group(&root, Panel::Tracks), Some(vec![Panel::Tracks]));
+ let hits = DockArea::::hit_rects(&h.ctx, soundcraft_ui_egui::docking::area_id(MainWindow::Edit));
+ let bar = hits.iter().find(|(hit, _)| *hit == Hit::DragBar(Panel::Clips)).expect("the Clips group has a drag bar").1;
+ let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").rect().center();
+ drag(&mut h, bar.center(), target);
+ let layout = &h.state().ui.docking.layout;
+ let group = docked_group(layout.root.as_ref().unwrap(), Panel::Tracks).unwrap();
+ assert_eq!(group.len(), 2, "the dropped group joins the Tracks tabs: {group:?}");
+ assert!(group.contains(&Panel::Clips));
+ assert!(h.state().ui.show_clip_list);
+ soundcraft_ui_egui::docking::validate(&h.state().ui.docking).unwrap();
+}
+
+#[test]
+fn malformed_dock_operations_name_the_field() {
+ let mut h = harness(None, 1400.0, 1.0);
+ for (params, error) in [
+ (json!({"operation":"close"}), "panel is required"),
+ (json!({"operation":"close","panel":"mixer"}), "Unknown panel: mixer"),
+ (
+ json!({"operation":"move","panel":"clips","target":"tracks","zone":"middle"}),
+ "zone must be tab, center, left, right, top, bottom, above or below",
+ ),
+ ] {
+ assert_eq!(h.state_mut().run("ui.dock", params).unwrap_err(), error);
+ }
+}
+
+#[test]
+fn a_panel_without_a_recorded_place_opens_beside_the_session_view() {
+ use craft_ui::docking::{Action, Permissions, Placement, Zone};
+ let mut state = UiState::default();
+ state.docking.apply(Action::Close { panel: Panel::Tracks }, |_| Permissions::default()).unwrap();
+ state.docking.hidden.clear();
+ state.show_tracks_list = false;
+ let mut h = harness(Some(state), 1400.0, 1.0);
+ h.state_mut().run("ui.dock", json!({"operation":"open","panel":"tracks"})).unwrap();
+ h.run_steps(4);
+ let location = h.state().ui.docking.layout.location(&Panel::Tracks).unwrap();
+ assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(Zone::Left)));
+ assert!(h.state().ui.show_tracks_list);
}
diff --git a/crates/ui-egui/tests/theme_docking.rs b/crates/ui-egui/tests/theme_docking.rs
index ed324153..54495c22 100644
--- a/crates/ui-egui/tests/theme_docking.rs
+++ b/crates/ui-egui/tests/theme_docking.rs
@@ -36,7 +36,7 @@ fn shared_panels_and_controls_follow_manual_and_live_system_appearance() {
assert_eq!(theme::is_light(), light);
assert_eq!(h.state().ui.theme, mode);
assert_eq!(*h.state().engine.session(), session);
- h.state().ui.docking.validate().unwrap();
+ soundcraft_ui_egui::docking::validate(&h.state().ui.docking).unwrap();
if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) {
std::fs::create_dir_all(&directory).unwrap();
h.render().unwrap().save(directory.join(format!("sound-{name}-{width_name}-{scale}x.png"))).unwrap();
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
index 72904533..a8428179 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -99,7 +99,7 @@ Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use mo
Panel IDs: `tracks`, `clips`, `midi`, `memory`, `history`. The protected center is `canvas`.
-`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
+`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: tab|center|left|right|top|bottom|above|below`; PascalCase names such as `Left` also parse). `open` and `activate` show a hidden panel at its saved location, or beside the session view when it has none, and bring a visible panel to the front. Center and `tab` group with the target; side zones split beside it; `above` and `below` stack the panel in a new group above or below a floating target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tracks","rect":[100,100,320,440]}}`.