mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 18:46:49 -06:00
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:
Generated
+7
-3
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]}}`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user