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]}}`.