Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 3 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +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 = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }

# 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-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }

# 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 = "a0916808f7d78bed8c7b90cdf937176015454a7f", 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).
Expand Down
4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
31 changes: 31 additions & 0 deletions apps/soundcraft/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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()));
Expand Down
150 changes: 114 additions & 36 deletions crates/ui-egui/src/docking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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>;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(());
Expand All @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// 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)
})
}

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading