Compare commits

..
Author SHA1 Message Date
aiongg 7dc56a4dbe ui: Tracks and Groups and Clips in full-height dock columns (#1088)
* ui: keep SoundCraft's beside-the-session-view place for panels craft-ui has no record of

* ui: Tracks and Groups and Clips in full-height dock columns

The Edit window docks Tracks and Groups (168 pt) at its start edge and Clips
(230 pt) at its end edge, with the MIDI editor under the session view between
them; Mix docks Tracks and Groups at its start edge. Panels dragged to an
empty window edge make a dock there.

Saved preferences and window configurations carry docking_version. Split-frame
layouts saved without it load as the new defaults, keeping every other
setting. A list with no recorded place reopens in its dock group, making the
dock again at its side and width when it is gone.

Pins craft-ui, craft-docpath and craft-media to craft-libs#312.

* Pin craft-libs at d24e4bc for the Mac round 3

* ui: test that a floating list reopens floating while its dock is shown; ui-parity revision
2026-10-25 21:33:15 -04:00
Dylan M. Taylor 088e270a5e refactor: split auto_pan process and audiosuite frame limit into helpers (#1104)
Reduces cognitive complexity flagged by SonarCloud (rust:S3776). Behaviour is unchanged:
per-sample order is envelope update, envelope trigger, LFO phase, modulator, width/manual/position
smoothing, output write.
2026-10-25 21:14:17 -04:00
13 changed files with 514 additions and 153 deletions
Generated
+4 -4
View File
@@ -934,12 +934,12 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3"
source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b"
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3"
source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b"
dependencies = [
"thiserror 2.0.21",
]
@@ -947,7 +947,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=650b1c0b058017c4eb3af06d5b39586d6732e07b#650b1c0b058017c4eb3af06d5b39586d6732e07b"
source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b"
dependencies = [
"egui",
"log",
@@ -960,7 +960,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b"
dependencies = [
"chrono",
"dunce",
+4 -8
View File
@@ -45,18 +45,14 @@ 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 = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "db19c373c8d59a545972ca340987aecfb3611a4b" }
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "db19c373c8d59a545972ca340987aecfb3611a4b" }
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] }
# ui
# 650b1c0b058017c4eb3af06d5b39586d6732e07b is the revision produced by the separate craft-libs current-main-0135
# integration: 25da's egui-0.37 port + 6e7f's docking Workspace/DockCommand + final egui/winit
# pins. Raw 6e7f/16fb3d46 contain the older egui 0.36 UI; keep this reviewed
# immutable revision to retain the required docking APIs on egui 0.37.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "650b1c0b058017c4eb3af06d5b39586d6732e07b", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["serde"] }
egui = { version = "0.37" }
eframe = { version = "0.37", default-features = false }
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
@@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track
## Progress log
- **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change.
- **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.
- **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
- **2026-10-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change.
@@ -175,6 +176,7 @@ mutated and truncated movies. Field reports from the first public week are track
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged |
+31
View File
@@ -88,6 +88,7 @@ fn read_ui(path: &std::path::Path, recover_docking: bool) -> Result<Option<sound
return Err("Settings file exceeds the 4 MiB limit".into());
}
let mut saved: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| format!("Settings could not be restored: {error}"))?;
docking::reset_old_layouts(&mut saved);
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::<docking::Workspace>(value.clone()) {
@@ -117,6 +118,7 @@ pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Resu
let mut saved = ui.clone();
saved.plugin_windows.clear();
saved.status.clear();
saved.docking_version = docking::LAYOUT_VERSION;
let bytes = serde_json::to_vec_pretty(&saved).map_err(|error| error.to_string())?;
if bytes.len() as u64 > MAX_UI_BYTES {
return Err("Settings exceed the 4 MiB limit".into());
@@ -213,6 +215,35 @@ mod config_tests {
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn settings_from_before_dock_columns_load_with_the_default_layouts() {
let directory = std::env::temp_dir().join(format!("soundcraft-split-frame-prefs-{}", std::process::id()));
std::fs::create_dir_all(&directory).unwrap();
let path = directory.join("ui.json");
let split_frames = serde_json::json!({"layout": {"root": {"Split": {
"axis": "Horizontal",
"size": {"FixedFirst": 200.0},
"first": {"Tabs": {"panels": ["tracks"], "active": 0}},
"second": {"Tabs": {"panels": ["canvas"], "active": 0}}
}}, "floating": []}});
let ui = soundcraft_ui_egui::UiState { theme: soundcraft_ui_egui::theme::ThemeMode::Light, narrow_mix: true, ..Default::default() };
let mut saved = serde_json::to_value(&ui).unwrap();
let fields = saved.as_object_mut().unwrap();
fields.remove("docking_version");
fields.insert("docking".into(), split_frames.clone());
fields.insert("mix_docking".into(), split_frames);
std::fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap();
let loaded = load_ui(&path).unwrap().unwrap();
assert_eq!(loaded.docking, docking::edit_default());
assert_eq!(loaded.mix_docking, docking::mix_default());
assert_eq!(loaded.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
assert!(loaded.narrow_mix);
save_ui(&path, &loaded).unwrap();
let written: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(written["docking_version"], serde_json::json!(docking::LAYOUT_VERSION));
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn oversized_settings_are_rejected_and_preserved() {
let directory = std::env::temp_dir().join(format!("soundcraft-bounded-prefs-{}", std::process::id()));
+114 -36
View File
@@ -2,8 +2,8 @@
//! Document contents remain owned by their existing view implementations.
use craft_ui::docking::{
Action, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Floating, Layout, Location, Node, PanelLimits, Permissions,
Placement, UiDock, Zone, ZoneName,
Action, Column, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Floating, Group, Layout, Location, Node, PanelLimits,
Permissions, Placement, Side, Target, UiDock, Zone, ZoneName,
};
use craft_ui::layout::{SplitAxis, SplitSize};
use serde::{Deserialize, Serialize};
@@ -32,18 +32,15 @@ impl Panel {
Self::History => "Undo History",
}
}
fn default_side(self) -> Zone {
match self {
Self::Canvas => Zone::Right,
Self::Tracks => Zone::Left,
Self::Clips => Zone::Right,
Self::Midi => Zone::Bottom,
Self::Memory => Zone::Bottom,
Self::History => Zone::Right,
}
}
}
/// Width of the Tracks and Groups column in the start dock.
pub const TRACKS_WIDTH: f32 = 168.0;
/// Width of the Clips column in the end dock.
pub const CLIPS_WIDTH: f32 = 230.0;
/// Height of the MIDI editor's body under the session view.
pub const MIDI_HEIGHT: f32 = 320.0;
/// An Edit or Mix panel arrangement, saved with the UI preferences.
pub type Workspace = craft_ui::docking::Workspace<Panel>;
@@ -71,37 +68,85 @@ fn area(edit: bool) -> egui::Id {
area_id(if edit { crate::MainWindow::Edit } else { crate::MainWindow::Mix })
}
/// The Edit window's built-in arrangement.
/// A dock on `side` with one column holding `panel`.
fn dock(side: Side, panel: Panel, width: f32) -> Node<Panel> {
Node::Dock { side, columns: vec![Column::new(vec![Group::new(vec![panel], 0)], width)] }
}
const MEMORY_RECT: [f32; 4] = [160.0, 120.0, 480.0, 280.0];
const HISTORY_RECT: [f32; 4] = [680.0, 120.0, 260.0, 300.0];
/// The floating windows both windows start with. Fixed ids: the default is rebuilt every frame
/// and must stay the same windows.
fn default_floating() -> Vec<Floating<Panel>> {
vec![Floating::single(vec![Panel::Memory], 0, MEMORY_RECT).with_id(1), Floating::single(vec![Panel::History], 0, HISTORY_RECT).with_id(2)]
}
/// Where a panel that floats in the default layouts floats.
fn default_float(panel: Panel) -> Option<[f32; 4]> {
match panel {
Panel::Memory => Some(MEMORY_RECT),
Panel::History => Some(HISTORY_RECT),
_ => None,
}
}
/// The dock side and column width a panel opens in when it has no recorded place.
fn default_dock(panel: Panel) -> Option<(Side, f32)> {
match panel {
Panel::Tracks => Some((Side::Start, TRACKS_WIDTH)),
Panel::Clips => Some((Side::End, CLIPS_WIDTH)),
_ => None,
}
}
/// The Edit window's built-in arrangement: Tracks and Groups in a full-height start dock,
/// Clips in a full-height end dock, and the MIDI editor under the session view between them.
pub fn edit_default() -> Workspace {
let session = split(SplitAxis::Vertical, SplitSize::FixedSecond(MIDI_HEIGHT + MIDI_TAB_HEIGHT), tabs(Panel::Canvas), tabs(Panel::Midi));
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),
SplitAxis::Horizontal,
SplitSize::FixedFirst(TRACKS_WIDTH),
dock(Side::Start, Panel::Tracks, TRACKS_WIDTH),
split(SplitAxis::Horizontal, SplitSize::FixedSecond(CLIPS_WIDTH), session, dock(Side::End, Panel::Clips, CLIPS_WIDTH)),
)),
// 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),
],
floating: default_floating(),
})
}
/// Mix retains its own arrangement; the timeline-only Clips and MIDI panels stay in Edit.
/// The Mix window's built-in arrangement: Tracks and Groups in a start dock beside the mixer.
/// The timeline-only Clips and MIDI panels stay in Edit.
pub fn mix_default() -> Workspace {
let mut workspace = edit_default();
let _ = set_visible(&mut workspace, Panel::Clips, false);
let _ = set_visible(&mut workspace, Panel::Midi, false);
workspace
Workspace::new(Layout {
root: Some(split(
SplitAxis::Horizontal,
SplitSize::FixedFirst(TRACKS_WIDTH),
dock(Side::Start, Panel::Tracks, TRACKS_WIDTH),
tabs(Panel::Canvas),
)),
floating: default_floating(),
})
}
/// The document view where the Edit window's rules keep it: in the docked frames, alone in
/// The version of the saved panel layouts, kept in [`crate::UiState::docking_version`]. Version 1
/// has Tracks and Groups and Clips in dock columns; settings without a version have split frames.
pub const LAYOUT_VERSION: u32 = 1;
/// Drop the panel layouts (with their hidden panels) from saved [`crate::UiState`] settings older
/// than [`LAYOUT_VERSION`], before they are read, so they load as the default layouts. Every other
/// setting stays. Preferences and window configurations go through this.
pub fn reset_old_layouts(saved: &mut Value) {
let Some(fields) = saved.as_object_mut() else { return };
if fields.get("docking_version").and_then(Value::as_u64).is_some_and(|version| version >= u64::from(LAYOUT_VERSION)) {
return;
}
fields.remove("docking");
fields.remove("mix_docking");
fields.insert("docking_version".into(), json!(LAYOUT_VERSION));
}
/// The document view where the Edit window's rules keep it: docked outside the dock columns, alone in
/// its tab group. `None` when `node` does not hold it.
fn canvas_placement(node: &Node<Panel>) -> Option<Result<(), &'static str>> {
match node {
@@ -204,7 +249,20 @@ fn restores(workspace: &Workspace, panel: Panel) -> bool {
.unwrap_or(false)
}
/// Show a panel at its recorded place, else beside the document view on its default side.
/// The first panel of the dock on `side`, when that dock is shown.
fn dock_panel(node: &Node<Panel>, side: Side) -> Option<Panel> {
match node {
Node::Split { first, second, .. } => dock_panel(first, side).or_else(|| dock_panel(second, side)),
Node::Dock { side: found, columns } if *found == side => {
columns.iter().flat_map(|column| &column.groups).find_map(|group| group.panels.first().copied())
}
_ => None,
}
}
/// Show a panel at its recorded place, else at its default place: Tracks and Groups and Clips
/// in their docks (joining the dock's first group when the dock is shown, else making the dock
/// again), the MIDI editor under the session view and the others floating.
fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> {
if workspace.layout.contains(&panel) == visible {
return Ok(());
@@ -213,10 +271,26 @@ fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result
if !visible {
return next.apply(Action::Close { panel }, permissions).map(drop);
}
if next.hidden.iter().any(|(p, _)| *p == panel) && restores(next, panel) && next.set_visible(panel, true, None, permissions).is_ok() {
let dock = default_dock(panel);
if let Some((side, _)) = dock
&& let Some(anchor) = next.layout.root.as_ref().and_then(|root| dock_panel(root, side))
{
return next.set_visible(panel, true, Some(anchor), permissions);
}
// Without an anchor, craft-ui would open a panel it has no record of, or whose record no
// longer restores, in some other panel's group, so the default place is applied here.
if next.hidden.iter().any(|(hidden, _)| *hidden == panel) && restores(next, panel) && 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)
let open = Action::Open { panel, anchor: None };
let actions = match (dock, default_float(panel)) {
(Some((side, width)), _) => vec![open, Action::MovePanel { panel, to: Target::NewDock { side } }, Action::ResizeColumn { panel, width }],
(None, Some(rect)) => vec![open, Action::Float { panel, rect }],
(None, None) => vec![Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(Zone::Bottom) }],
};
next.apply_all(actions, permissions)?;
next.apply(Action::Activate { panel }, permissions).map(drop)
})
}
@@ -394,6 +468,10 @@ 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);
// Free docking: a panel dropped at an empty window edge makes a dock there, and the top
// and bottom edges take frames (`drop_edges` keeps its default).
style.new_dock_zones = true;
style.side_edge_zones = false;
let output = DockArea::new(area(edit)).show_with_limits(
ui,
&workspace.layout,
+4
View File
@@ -63,6 +63,9 @@ pub struct UiState {
pub docking: docking::Workspace,
#[serde(default = "docking::mix_default")]
pub mix_docking: docking::Workspace,
/// [`docking::LAYOUT_VERSION`] of `docking` and `mix_docking`; 0 in settings saved before it.
#[serde(default)]
pub docking_version: u32,
pub window: MainWindow,
pub show_tracks_list: bool,
pub show_clip_list: bool,
@@ -136,6 +139,7 @@ impl Default for UiState {
recent_sessions: Vec::new(),
docking: docking::edit_default(),
mix_docking: docking::mix_default(),
docking_version: docking::LAYOUT_VERSION,
window: MainWindow::Edit,
show_tracks_list: true,
show_clip_list: true,
+1
View File
@@ -606,6 +606,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
}
let mut snap = app.ui.clone();
snap.configurations.clear();
snap.docking_version = crate::docking::LAYOUT_VERSION;
let v = serde_json::to_value(&snap).unwrap_or_default();
if id == "window.config_update" && !app.ui.configurations.is_empty() {
if let Some(last) = app.ui.configurations.last_mut() {
+19 -11
View File
@@ -3,7 +3,7 @@
use crate::SoundApp;
use crate::theme::{Tokens, mono, rgb};
use egui::{Color32, Sense, vec2};
use serde_json::json;
use serde_json::{Value, json};
use soundcraft_model::{AutomationMode, ClipContent};
use soundcraft_playback::AudioConfig;
use soundcraft_playback::devices::{AudioSystem, DeviceInfo};
@@ -384,6 +384,7 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
let n = app.ui.configurations.len() + 1;
let mut snap = app.ui.clone();
snap.configurations.clear();
snap.docking_version = crate::docking::LAYOUT_VERSION;
if let Ok(v) = serde_json::to_value(&snap) {
app.ui.configurations.push((format!("Configuration {n}"), v));
}
@@ -393,17 +394,9 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
for (i, (name, v)) in list.iter().enumerate() {
ui.horizontal(|ui| {
if ui.button(format!("{} {name}", i + 1)).clicked()
&& let Ok(mut st) = serde_json::from_value::<crate::UiState>(v.clone())
&& let Err(error) = recall_configuration(app, v)
{
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();
st.theme = app.ui.theme;
st.show_configurations = true;
crate::docking::cancel_replaced_state(app, &st);
app.ui = st;
}
app.ui.status = format!("Window configuration could not be restored: {error}");
}
if crate::widgets::close_button(ui, "Remove configuration").clicked() {
app.ui.configurations.remove(i);
@@ -414,6 +407,21 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
app.ui.show_configurations = open;
}
/// Restore a saved window configuration, keeping the configuration list and the appearance.
/// A configuration saved before the current panel layouts gets the default layouts.
pub fn recall_configuration(app: &mut SoundApp, saved: &Value) -> Result<(), String> {
let mut saved = saved.clone();
crate::docking::reset_old_layouts(&mut saved);
let mut st = serde_json::from_value::<crate::UiState>(saved).map_err(|error| error.to_string())?;
crate::docking::validate(&st.docking).and_then(|()| crate::docking::validate_mix(&st.mix_docking))?;
st.configurations = app.ui.configurations.clone();
st.theme = app.ui.theme;
st.show_configurations = true;
crate::docking::cancel_replaced_state(app, &st);
app.ui = st;
Ok(())
}
/// The audio systems as last enumerated (enumerating probes devices: done when the window opens
/// and on Refresh, not every frame).
fn audio_systems(ctx: &egui::Context, refresh: bool) -> Arc<Vec<AudioSystem>> {
+297
View File
@@ -0,0 +1,297 @@
//! The side lists in dock columns: the default shapes, saved layouts, drops into a column and
//! panels reopening in their dock.
use craft_ui::docking::{Column, Divider, DockArea, Group, Hit, Node, Side};
use craft_ui::layout::{SplitAxis, SplitSize};
use egui::{Rect, accesskit::Role};
use egui_kittest::kittest::Queryable;
use serde_json::json;
use soundcraft_ui_egui::docking::{self, CLIPS_WIDTH, MIDI_HEIGHT, Panel, TRACKS_WIDTH};
use soundcraft_ui_egui::{MainWindow, Services, SoundApp, UiState};
use super::docking_workflows::{drag, harness};
fn tabs(panel: Panel) -> Node<Panel> {
Node::Tabs { panels: vec![panel], active: 0 }
}
fn dock(side: Side, panel: Panel, width: f32) -> Node<Panel> {
Node::Dock { side, columns: vec![Column::new(vec![Group::new(vec![panel], 0)], width)] }
}
fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Panel>) -> Node<Panel> {
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
}
/// The columns of the dock on `side`.
fn columns(node: &Node<Panel>, side: Side) -> Option<&Vec<Column<Panel>>> {
match node {
Node::Split { first, second, .. } => columns(first, side).or_else(|| columns(second, side)),
Node::Dock { side: found, columns } if *found == side => Some(columns),
_ => None,
}
}
fn dock_columns(h: &egui_kittest::Harness<'_, SoundApp>, side: Side) -> Vec<Column<Panel>> {
h.state().ui.docking.layout.root.as_ref().and_then(|root| columns(root, side)).cloned().unwrap_or_default()
}
#[test]
fn default_layouts_put_the_side_lists_in_full_height_dock_columns() {
let edit = docking::edit_default();
let session = split(SplitAxis::Vertical, SplitSize::FixedSecond(MIDI_HEIGHT + 26.0), tabs(Panel::Canvas), tabs(Panel::Midi));
let expected = split(
SplitAxis::Horizontal,
SplitSize::FixedFirst(TRACKS_WIDTH),
dock(Side::Start, Panel::Tracks, TRACKS_WIDTH),
split(SplitAxis::Horizontal, SplitSize::FixedSecond(CLIPS_WIDTH), session, dock(Side::End, Panel::Clips, CLIPS_WIDTH)),
);
assert_eq!(edit.layout.root, Some(expected));
assert_eq!((TRACKS_WIDTH, CLIPS_WIDTH, MIDI_HEIGHT), (168.0, 230.0, 320.0));
let mix = docking::mix_default();
assert_eq!(
mix.layout.root,
Some(split(SplitAxis::Horizontal, SplitSize::FixedFirst(TRACKS_WIDTH), dock(Side::Start, Panel::Tracks, TRACKS_WIDTH), tabs(Panel::Canvas)))
);
for workspace in [&edit, &mix] {
let floating: Vec<_> = workspace.layout.floating.iter().map(|window| (window.panels().copied().collect::<Vec<_>>(), window.rect)).collect();
assert_eq!(floating, [(vec![Panel::Memory], [160.0, 120.0, 480.0, 280.0]), (vec![Panel::History], [680.0, 120.0, 260.0, 300.0])]);
assert!(workspace.hidden.is_empty());
}
docking::validate(&edit).unwrap();
docking::validate_mix(&mix).unwrap();
// Drawn: the docks run the window's height at its sides, and the MIDI editor sits under the
// session view between them.
let width = 1400.0;
let mut h = harness(Some(UiState { show_midi_editor: true, ..Default::default() }), width, 1.0);
h.run_steps(3);
let tracks = h.get_by_role_and_label(Role::Tab, "Tracks and Groups").rect();
let clips = h.get_by_role_and_label(Role::Tab, "Clips").rect();
let midi = h.ctx.data(|data| data.get_temp::<Rect>(egui::Id::unique("soundcraft-midi-body"))).unwrap();
assert!(tracks.left() < TRACKS_WIDTH && clips.right() > width - CLIPS_WIDTH, "tracks {tracks:?}, clips {clips:?}");
assert!(midi.left() >= TRACKS_WIDTH && midi.right() <= width - CLIPS_WIDTH, "midi {midi:?}");
assert!((midi.height() - MIDI_HEIGHT).abs() < 1.0 && midi.top() > clips.bottom(), "midi {midi:?}");
}
/// The Tracks group's body in the start dock: below its tab strip, down to the column's foot.
fn tracks_body(h: &egui_kittest::Harness<'_, SoundApp>) -> Rect {
let hits = DockArea::<Panel>::hit_rects(&h.ctx, docking::area_id(MainWindow::Edit));
let strip = hits
.iter()
.filter(|(hit, _)| matches!(hit, Hit::Tab(Panel::Tracks) | Hit::DragBar(Panel::Tracks)))
.fold(Rect::NOTHING, |all, (_, rect)| all.union(*rect));
// The column's inner edge runs the column's full height.
let bottom =
hits.iter().find(|(hit, _)| *hit == Hit::Divider(Divider::Column(Panel::Tracks))).map_or(f32::NEG_INFINITY, |(_, rect)| rect.bottom());
assert!(strip.is_positive() && bottom > strip.bottom() + 100.0, "strip {strip:?}, bottom {bottom}, hits {hits:?}");
Rect::from_min_max(egui::pos2(strip.left(), strip.bottom()), egui::pos2(strip.right(), bottom))
}
#[test]
fn a_tab_dropped_on_a_dock_group_joins_it_in_the_middle_and_stacks_below_in_the_lower_third() {
let mut h = harness(None, 1400.0, 1.0);
let body = tracks_body(&h);
let clips = h.get_by_role_and_label(Role::Tab, "Clips").rect().center();
drag(&mut h, clips, body.center());
assert_eq!(
dock_columns(&h, Side::Start),
[Column::new(vec![Group { active: 1, ..Group::new(vec![Panel::Tracks, Panel::Clips], 0) }], TRACKS_WIDTH)]
);
assert!(dock_columns(&h, Side::End).is_empty(), "the emptied end dock goes");
let mut h = harness(None, 1400.0, 1.0);
let body = tracks_body(&h);
let clips = h.get_by_role_and_label(Role::Tab, "Clips").rect().center();
drag(&mut h, clips, egui::pos2(body.center().x, body.bottom() - body.height() / 6.0));
let column = dock_columns(&h, Side::Start);
let groups: Vec<_> = column.iter().flat_map(|column| column.groups.iter().map(|group| group.panels.clone())).collect();
assert_eq!(groups, [vec![Panel::Tracks], vec![Panel::Clips]], "Clips stacks below Tracks in its column");
assert_eq!(column.len(), 1);
assert!(h.state().ui.show_clip_list);
docking::validate(&h.state().ui.docking).unwrap();
}
#[test]
fn a_panel_without_a_recorded_place_reopens_at_its_default_place() {
use craft_ui::docking::{Action, Permissions, Placement, Zone};
let close = |state: &mut UiState, panel: Panel| {
state.docking.apply(Action::Close { panel }, |_| Permissions::default()).unwrap();
state.docking.hidden.clear();
};
// Both docks gone: each list makes its dock again, at its default width. The MIDI editor
// opens under the session view, and Memory Locations and Undo History float where they
// start.
let mut state = UiState::default();
for panel in [Panel::Tracks, Panel::Clips, Panel::Midi, Panel::Memory, Panel::History] {
close(&mut state, panel);
}
state.show_tracks_list = false;
state.show_clip_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.state_mut().run("window.clip_list", json!({})).unwrap();
for panel in ["midi", "memory", "history"] {
h.state_mut().run("ui.dock", json!({"operation":"open","panel":panel})).unwrap();
}
h.run_steps(4);
assert_eq!(dock_columns(&h, Side::Start), [Column::new(vec![Group::new(vec![Panel::Tracks], 0)], TRACKS_WIDTH)]);
assert_eq!(dock_columns(&h, Side::End), [Column::new(vec![Group::new(vec![Panel::Clips], 0)], CLIPS_WIDTH)]);
let layout = &h.state().ui.docking.layout;
let midi = layout.location(&Panel::Midi).unwrap();
assert_eq!((midi.anchor, midi.placement), (Some(Panel::Canvas), Placement::Split(Zone::Bottom)));
let rect = |panel: Panel| layout.floating.iter().find(|window| window.contains(&panel)).map(|window| window.rect);
assert_eq!((rect(Panel::Memory), rect(Panel::History)), (Some([160.0, 120.0, 480.0, 280.0]), Some([680.0, 120.0, 260.0, 300.0])));
let ui = &h.state().ui;
assert!(ui.show_tracks_list && ui.show_clip_list && ui.show_midi_editor && ui.show_memory_locations && ui.show_undo_history);
docking::validate(&h.state().ui.docking).unwrap();
// A dock that is still shown takes the list into its first group.
let mut state = UiState::default();
state
.docking
.apply(
Action::Move {
panel: Panel::History,
anchor: Panel::Tracks,
placement: craft_ui::docking::Placement::Group { below: true, height: None },
},
|_| Permissions::default(),
)
.unwrap();
close(&mut state, Panel::Tracks);
state.show_tracks_list = false;
state.show_undo_history = true;
let mut h = harness(Some(state), 1400.0, 1.0);
h.state_mut().run("window.track_list", json!({})).unwrap();
h.run_steps(4);
let groups: Vec<_> = dock_columns(&h, Side::Start).iter().flat_map(|column| column.groups.iter().map(|group| group.panels.clone())).collect();
assert_eq!(groups, [vec![Panel::History, Panel::Tracks]]);
assert_eq!(h.state().ui.docking.layout.panel_state(&Panel::Tracks), craft_ui::docking::PanelState::Front);
}
/// A hidden list or editor whose recorded anchor has disappeared reopens at its default place
/// instead of where craft-ui opens a panel it cannot restore.
#[test]
fn a_recorded_place_whose_anchor_is_gone_reopens_at_its_default_place() {
use craft_ui::docking::{Action, Permissions, Placement, Zone};
let allow = |_: &Panel| Permissions::default();
let mut state = UiState::default();
// Stale saved locations: their recorded anchor closed without leaving a place to follow.
for panel in [Panel::Clips, Panel::Midi, Panel::Tracks] {
state.docking.apply(Action::Close { panel }, allow).unwrap();
}
state.docking.hidden.retain(|(panel, _)| *panel != Panel::Tracks);
for (_, location) in state.docking.hidden.iter_mut().filter(|(panel, _)| matches!(panel, Panel::Clips | Panel::Midi)) {
location.anchor = Some(Panel::Tracks);
location.placement = Placement::Split(Zone::Left);
location.sibling.clear();
location.floating = None;
}
docking::validate(&state.docking).unwrap();
state.show_clip_list = false;
state.show_midi_editor = false;
state.show_tracks_list = false;
let mut h = harness(Some(state), 1400.0, 1.0);
assert!(!h.state().ui.docking.layout.contains(&Panel::Tracks), "app sync keeps the stale anchor absent");
h.state_mut().run("ui.dock", json!({"operation":"open","panel":"clips"})).unwrap();
h.state_mut().run("ui.dock", json!({"operation":"open","panel":"midi"})).unwrap();
h.run_steps(4);
assert_eq!(dock_columns(&h, Side::End), [Column::new(vec![Group::new(vec![Panel::Clips], 0)], CLIPS_WIDTH)]);
let midi = h.state().ui.docking.layout.location(&Panel::Midi).unwrap();
assert_eq!((midi.anchor, midi.placement), (Some(Panel::Canvas), Placement::Split(Zone::Bottom)));
assert!(h.state().ui.show_clip_list && h.state().ui.show_midi_editor);
docking::validate(&h.state().ui.docking).unwrap();
}
/// A workspace as releases before dock columns saved it: no layout version, the lists in split
/// frames, a v1 floating window and hidden places.
fn split_frame_settings() -> serde_json::Value {
let edit = 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 mix = json!({"layout": {"root": {"Split": {
"axis": "Horizontal",
"size": {"FixedFirst": 240.0},
"first": {"Tabs": {"panels": ["tracks"], "active": 0}},
"second": {"Tabs": {"panels": ["canvas"], "active": 0}}
}}, "floating": []}});
let mut settings =
serde_json::to_value(UiState { theme: soundcraft_ui_egui::theme::ThemeMode::Light, narrow_mix: true, ..Default::default() }).unwrap();
let fields = settings.as_object_mut().unwrap();
fields.remove("docking_version");
fields.insert("docking".into(), edit);
fields.insert("mix_docking".into(), mix);
settings
}
#[test]
fn split_frame_settings_load_as_the_dock_defaults_and_dock_layouts_round_trip() {
let mut settings = split_frame_settings();
// The old layout still parses: the reset is what replaces it.
assert!(serde_json::from_value::<UiState>(settings.clone()).unwrap().docking.layout.contains(&Panel::Clips));
docking::reset_old_layouts(&mut settings);
let state: UiState = serde_json::from_value(settings).unwrap();
assert_eq!(state.docking, docking::edit_default());
assert_eq!(state.mix_docking, docking::mix_default());
assert_eq!(state.docking_version, docking::LAYOUT_VERSION);
assert_eq!(state.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
assert!(state.narrow_mix);
// A configuration saved before dock columns resets the same way when recalled.
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
app.run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[80,90,260,400]})).unwrap();
app.ui.configurations = vec![("Old".into(), split_frame_settings())];
soundcraft_ui_egui::windows::recall_configuration(&mut app, &split_frame_settings()).unwrap();
assert_eq!(app.ui.docking, docking::edit_default());
assert_eq!(app.ui.mix_docking, docking::mix_default());
assert!(app.ui.narrow_mix && app.ui.configurations.len() == 1);
// A dock layout comes back as it was saved, and drawing it does not rearrange it.
let mut h = harness(Some(UiState { show_undo_history: true, ..Default::default() }), 1400.0, 1.0);
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"clips","target":"tracks","zone":"bottom"})).unwrap();
h.state_mut().run("ui.dock", json!({"operation":"float","panel":"history","rect":[500,200,260,300]})).unwrap();
h.run_steps(4);
let saved = h.state().ui.clone();
let mut value = serde_json::to_value(&saved).unwrap();
assert_eq!(value["docking_version"], json!(docking::LAYOUT_VERSION));
docking::reset_old_layouts(&mut value);
let loaded: UiState = serde_json::from_value(value).unwrap();
assert_eq!(loaded.docking, saved.docking);
assert_eq!(loaded.mix_docking, saved.mix_docking);
let groups: Vec<_> = columns(loaded.docking.layout.root.as_ref().unwrap(), Side::Start)
.unwrap()
.iter()
.flat_map(|column| column.groups.iter().map(|group| group.panels.clone()))
.collect();
assert_eq!(groups, [vec![Panel::Tracks], vec![Panel::Clips]]);
let h = harness(Some(loaded), 1400.0, 1.0);
assert_eq!(h.state().ui.docking, saved.docking);
}
#[test]
fn a_floating_list_closed_beside_its_shown_dock_reopens_floating() {
let mut h = harness(None, 1400.0, 1.0);
// Tracks joins Clips in the end dock, so the end dock stays shown after Clips floats.
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"tracks","target":"clips","zone":"center"})).unwrap();
h.state_mut().run("ui.dock", json!({"operation":"float","panel":"clips","rect":[300,200,260,320]})).unwrap();
h.run_steps(4);
let rect = h.state().ui.docking.layout.floating.iter().find(|w| w.contains(&Panel::Clips)).unwrap().rect;
assert!(dock_columns(&h, Side::End).iter().any(|c| c.groups.iter().any(|g| g.panels.contains(&Panel::Tracks))));
h.state_mut().run("ui.dock", json!({"operation":"close","panel":"clips"})).unwrap();
h.run_steps(4);
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(|w| w.contains(&Panel::Clips)).map(|w| w.rect), Some(rect));
}
+25 -89
View File
@@ -4,7 +4,7 @@ use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_ui_egui::{MainWindow, Services, SoundApp, UiState, docking::Panel};
fn harness(state: Option<UiState>, width: f32, scale: f32) -> Harness<'static, SoundApp> {
pub(crate) fn harness(state: Option<UiState>, width: f32, scale: f32) -> Harness<'static, SoundApp> {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
app.run("track.new", json!({"name":"Voice"})).unwrap();
if let Some(state) = state {
@@ -21,7 +21,7 @@ fn harness(state: Option<UiState>, width: f32, scale: f32) -> Harness<'static, S
h
}
fn drag(h: &mut Harness<'_, SoundApp>, from: egui::Pos2, to: egui::Pos2) {
pub(crate) fn drag(h: &mut Harness<'_, SoundApp>, from: egui::Pos2, to: egui::Pos2) {
h.event(Event::PointerMoved(from));
h.run_steps(2);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
@@ -63,12 +63,14 @@ fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() {
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);
// craft-ui 650b squeezes both tabs into the narrow Tracks frame, so the inactive Tracks tab
// is visibly shown (only shown tabs get AccessKit nodes) instead of moving under a "Panels"
// overflow. Activate it through that visible tab with a real pointer click.
let tracks_tab = reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").rect();
assert!(tracks_tab.width() > 0.0 && tracks_tab.height() > 0.0, "the Tracks tab is visibly laid out: {tracks_tab:?}");
reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").click();
// A Tracks column too narrow for both tabs lists Tracks under the "Panels" overflow.
if let Some(overflow) = reloaded.query_by_label("Panels") {
overflow.click();
reloaded.run_steps(3);
reloaded.get_by_label("Tracks and Groups").click();
} else {
reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "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();
@@ -179,64 +181,41 @@ fn legacy_preferences_restore_the_theme_and_validate_default_docking() {
soundcraft_ui_egui::docking::validate_mix(&restored.mix_docking).unwrap();
}
/// The tab group holding `panel` in the docked frames.
/// The tab group holding `panel` in the docked frames or a dock column.
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()),
Node::Dock { columns, .. } => {
columns.iter().flat_map(|column| &column.groups).find(|group| group.panels.contains(&panel)).map(|group| group.panels.clone())
}
_ => None,
}
}
#[test]
fn v1_workspace_settings_load_with_their_layout_and_hidden_places() {
fn a_floating_window_saved_in_the_one_group_shape_keeps_it_when_written_back() {
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);
// A floating window as one `{panels, active, rect}` group, as releases before the shared
// docking model wrote it, beside the dock layout.
let mut settings = serde_json::to_value(UiState { show_midi_editor: true, ..Default::default() }).unwrap();
settings["docking"]["layout"]["floating"] = json!([{"panels": ["memory", "history"], "active": 1, "rect": [300.0, 120.0, 420.0, 360.0]}]);
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]);
assert_eq!(floating[0].root, Node::Tabs { panels: vec![Panel::Memory, Panel::History], active: 1 });
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);
let mut state = state;
state.show_memory_locations = true;
state.show_undo_history = true;
let 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();
let window = saved["layout"]["floating"].as_array().unwrap().iter().find(|w| w["panels"] == json!(["memory", "history"])).unwrap();
assert_eq!(window["active"], json!(1));
assert_eq!(window["rect"], json!([300.0, 120.0, 420.0, 360.0]));
}
@@ -274,49 +253,6 @@ fn malformed_dock_operations_name_the_field() {
}
}
#[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);
}
/// A hidden panel whose saved anchor has disappeared reopens beside the session view instead of failing to restore.
#[test]
fn a_recorded_place_whose_anchor_is_gone_opens_beside_the_session_view() {
use craft_ui::docking::{Action, Permissions, Placement, Zone};
let allow = |_: &Panel| Permissions::default();
let mut state = UiState::default();
// A stale saved location: its recorded anchor closed without leaving a place to follow.
state.docking.apply(Action::Close { panel: Panel::Clips }, allow).unwrap();
state.docking.apply(Action::Close { panel: Panel::Tracks }, allow).unwrap();
state.docking.hidden.retain(|(panel, _)| *panel != Panel::Tracks);
let location = &mut state.docking.hidden.iter_mut().find(|(panel, _)| *panel == Panel::Clips).unwrap().1;
location.anchor = Some(Panel::Tracks);
location.placement = Placement::Split(Zone::Left);
location.sibling.clear();
location.floating = None;
soundcraft_ui_egui::docking::validate(&state.docking).unwrap();
state.show_clip_list = false;
state.show_tracks_list = false;
assert!(!state.docking.layout.contains(&Panel::Tracks));
let mut h = harness(Some(state), 1400.0, 1.0);
assert!(!h.state().ui.docking.layout.contains(&Panel::Tracks), "app sync keeps the stale anchor absent");
h.state_mut().run("ui.dock", json!({"operation":"open","panel":"clips"})).unwrap();
h.run_steps(4);
let location = h.state().ui.docking.layout.location(&Panel::Clips).unwrap();
assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(Zone::Right)));
assert!(h.state().ui.show_clip_list);
}
/// Panels hidden one by one reopen into their original floating window, rebuilding the hidden-anchor chain in the
/// process.
#[test]
+1
View File
@@ -13,6 +13,7 @@ mod clip_looping;
mod clip_menu;
mod command_reachability;
mod commands_filter;
mod docking_columns;
mod docking_workflows;
mod edit_menu;
mod edit_modes;
+8 -2
View File
@@ -97,11 +97,13 @@ Scroll Into View in the track name menu. A window not on screen catches up when
## Panel docking
Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Commands target the active Edit or Mix view; floating panels stay inside the current viewport. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.
Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Commands target the active Edit or Mix view; floating panels stay inside the current viewport. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. The document/session view is protected.
The Edit window starts with Tracks and Groups in a 168-point column in a dock at the left edge, Clips in a 230-point column in a dock at the right edge, and the MIDI Editor under the session view between them. Both docks run the window's full height. Mix starts with Tracks and Groups docked at its left edge. Memory Locations and Undo History float. A dock column stacks tab groups top to bottom: a panel dropped on a group's middle joins it as a tab, on its upper or lower third stacks a new group above or below it, and at a column's edge makes a new column. A panel dropped at an empty window edge makes a dock there. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.
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: 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 no saved location or its saved anchor cannot be restored, 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.
`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 and bring a visible panel to the front. A panel with no saved location, or whose saved anchor cannot be restored, opens at its default place: Tracks and Groups and Clips join their dock's first group, or make that dock again at its default width when it is gone; the MIDI Editor opens under the session view; Memory Locations and Undo History float at their default rectangles. 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]}}`.
@@ -113,3 +115,7 @@ A center `move` can include `before` (panel ID or null to append) for tab order.
The equivalent `{"action": ...}` form accepts a serialized shared docking action;
this is also the form emitted by the renderer. Protected-canvas and app-specific panel
rules apply equally to both forms.
The saved UI preferences and window configurations carry `docking_version`. Settings with no
version, or an older one, hold the split-frame layouts of earlier releases: they load with the
default Edit and Mix layouts and keep every other setting.
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (Scroll Into View in the track name menu) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Tracks and Groups and Clips in dock columns) · **Target:** Avid Pro Tools Ultimate 2026.4.1
How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts,
windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command
@@ -42,7 +42,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| Window | Status | Notes |
|---|---|---|
| Edit window: rulers, track headers, view columns, Clip List, Groups, Tracks list | ✅ | Track Pin (new in 2026.4) ✅ (`track.pin`) |
| Edit window: rulers, track headers, view columns, Clip List, Groups, Tracks list | ✅ | Track Pin (new in 2026.4) ✅ (`track.pin`); Tracks and Groups and the Clip List sit in full-height dock columns at the window's sides |
| Mix window: strips, views (inserts, sends, I/O, comments, EQ curve, preamps, instruments, object) | ✅ | Narrow Mix ✅; Mix and Edit on screen at once (Window › Mix in Separate Window, its own native window) ✅; Delay Compensation view and HEAT missing |
| MIDI Editor, Score Editor, Event List | 🟡 | Dock height bug fixed (#113); notation editing shallow |
| Transport, Big Counter, Memory Locations, Automation, Video, Universe, Renderer, UI Customization | ✅ present | |
@@ -69,6 +69,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | Edit and Mix windows: Tracks and Groups and Clips in full-height dock columns. Readiness estimate unchanged |
| 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged |
| 2026-10-13 | minor | A plugin opens when it's inserted (#486) |
| 2026-10-13 | minor | Drag across checkboxes (#399) |