ui: move panel docking onto craft-ui's shared Workspace and DockCommand (#1035)

* ui: move panel docking onto craft-ui's shared Workspace and DockCommand

Pin every craft-libs crate at 16fb3d4 (craft-libs main after #285).

The Edit and Mix workspaces are craft_ui::docking::Workspace<Panel>.
ui.dock and ui.dock.reset parse into DockCommand; the app keeps its
panels, permissions, defaults, the document-view and Mix rules, the
beside-the-canvas fallback for panels without a recorded place, and
Arrange. The canvas accepts splits so panels still open beside it.

Saved v1 workspaces (no version, floating windows as
{panels, active, rect}) load unchanged. Tests cover loading one and
dragging a group by its drag bar.

* ui: test that activating a hidden panel shows it

* ui: name the field in ui.dock operation errors; document the shared panel menu and zones
This commit is contained in:
aiongg
2026-10-24 16:18:19 -04:00
committed by GitHub
parent aa74bffac1
commit ee050db59c
11 changed files with 373 additions and 295 deletions
Generated
+7 -3
View File
@@ -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",
]
+3 -3
View File
@@ -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 }
+1 -1
View File
@@ -387,4 +387,4 @@ Forks and modified versions must remove them.
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
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.
+16 -15
View File
@@ -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<Option<sound
let mut saved: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| format!("Settings could not be restored: {error}"))?;
if recover_docking && let Some(fields) = saved.as_object_mut() {
for (field, mix) in [("docking", false), ("mix_docking", true)] {
let unreadable =
fields.get(field).is_some_and(|value| match serde_json::from_value::<soundcraft_ui_egui::docking::Workspace>(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::<docking::Workspace>(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());
+203 -256
View File
@@ -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, String> {
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<Panel>;
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<Panel> {
@@ -68,232 +62,149 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Pane
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Workspace {
pub layout: Layout<Panel>,
/// Closed panels retain their most recent group, side, size or floating rectangle.
pub hidden: Vec<(Panel, Location<Panel>)>,
/// 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<Panel>) -> 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<Panel> = 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<bool> =
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<Panel>) -> Option<Result<(), &'static str>> {
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<Panel>) -> 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<Panel>) -> 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<Value, String> {
/// 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<String, Value>) -> Result<DockOperation<Panel>, 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::<Panel>(json!(id)).map_err(|_| format!("Unknown panel: {id}"))?;
}
}
if let Some(zone) = fields.get("zone")
&& serde_json::from_value::<ZoneName>(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<Value, String> {
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::<Panel>(ctx, egui::Id::new(("soundcraft-docking", edit)));
craft_ui::docking::cancel_drag::<Panel>(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<Value, String> {
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<Panel>, edit: bool) -> Result<Value, String> {
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}");
}
}
+1 -1
View File
@@ -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,
+3 -3
View File
@@ -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<Result<Value, String>> {
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<Result<
}
"window.configurations" => 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<Result<
}
match serde_json::from_value::<crate::UiState>(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);
+1 -1
View File
@@ -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::<crate::UiState>(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();
+136 -10
View File
@@ -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: Panel) -> Option<Vec<Panel>> {
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::<Panel>::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);
}
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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]}}`.