From 63b487589c7b931ac5e8a5e312d383d76a1af3f0 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 10 Oct 2026 15:37:36 -0600 Subject: [PATCH] UI.9: preserve SoundCraft workflows with shared docking --- Cargo.lock | 10 + Cargo.toml | 1 + README.md | 5 +- ROADMAP.md | 6 + apps/soundcraft-cli/src/main.rs | 2 +- apps/soundcraft/src/config.rs | 192 +++++++++ apps/soundcraft/src/main.rs | 40 +- crates/mix/src/lib.rs | 2 +- crates/ui-egui/Cargo.toml | 3 +- crates/ui-egui/src/docking.rs | 475 ++++++++++++++++++++++ crates/ui-egui/src/edit_window.rs | 33 +- crates/ui-egui/src/lib.rs | 9 + crates/ui-egui/src/menus.rs | 24 +- crates/ui-egui/src/mix_window.rs | 54 +-- crates/ui-egui/src/panels.rs | 140 +++---- crates/ui-egui/src/widgets.rs | 260 ++++++++++-- crates/ui-egui/src/windows.rs | 13 +- crates/ui-egui/tests/docking_workflows.rs | 159 ++++++++ crates/ui-egui/tests/midi_panel.rs | 79 +++- crates/ui-egui/tests/shared_controls.rs | 217 ++++++++++ crates/ui-egui/tests/theme_docking.rs | 83 ++++ docs/architecture.md | 7 + docs/control-protocol.md | 31 ++ xtask/src/main.rs | 2 +- 24 files changed, 1659 insertions(+), 188 deletions(-) create mode 100644 apps/soundcraft/src/config.rs create mode 100644 crates/ui-egui/src/docking.rs create mode 100644 crates/ui-egui/tests/docking_workflows.rs create mode 100644 crates/ui-egui/tests/shared_controls.rs create mode 100644 crates/ui-egui/tests/theme_docking.rs diff --git a/Cargo.lock b/Cargo.lock index 8b26758..cb38648 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -782,6 +782,15 @@ dependencies = [ "windows 0.54.0", ] +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +dependencies = [ + "egui", + "serde", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -3570,6 +3579,7 @@ dependencies = [ name = "soundcraft-ui-egui" version = "0.4.0" dependencies = [ + "craft-ui", "egui", "egui_kittest", "image", diff --git a/Cargo.toml b/Cargo.toml index 95b9a8e..083ceba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ rustfft = "6" cpal = "0.16" # ui +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" diff --git a/README.md b/README.md index 89ec40e..26101e1 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,8 @@ where things are. ## Getting started Download a build from the [releases page](https://github.com/storytold/soundcraft/releases), or build -from source with a recent stable Rust: +from source with Rust 1.95 or later for the desktop/web UI (the core workspace's declared +minimum remains Rust 1.90): ```sh git clone https://github.com/storytold/soundcraft @@ -366,3 +367,5 @@ Forks and modified versions must remove them. ArtCraft
Made by the ArtCraft team and community.

+ +Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window. diff --git a/ROADMAP.md b/ROADMAP.md index a89da3f..14ef908 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,6 +61,10 @@ mutated and truncated movies. ## Current focus +Common toolbar icon buttons and track record-enable buttons use shared `craft-ui` interaction, +accessible labels and keyboard focus, retaining SoundCraft's blue tool-selection and red record-arm +states. The specialized transport buttons, faders, meters and saved session model remain local. + 1. The alpha list above. 2. Surround-pan automation; AU editor views. 3. Built-in plugin depth (more processors, better UIs). @@ -76,3 +80,5 @@ mutated and truncated movies. - **M9 (in progress):** AudioSuite breadth (done), Elastic Audio, Beat Detective. - **M10 (next):** alpha release on all platforms. - **M11 (mostly done):** third-party plugin hosting (CLAP, VST3, AU), video, surround. + +- **2026-10-10 (UI docking):** 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. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. No milestone percentage change. diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 2ba9112..b127f19 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -299,7 +299,7 @@ fn app(args: &[String]) -> ExitCode { let positional: Vec<&String> = args .iter() .enumerate() - .filter(|(i, a)| !a.starts_with("--") && !(*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port"))) + .filter(|(i, a)| !(a.starts_with("--") || (*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port")))) .map(|(_, a)| a) .collect(); let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") }; diff --git a/apps/soundcraft/src/config.rs b/apps/soundcraft/src/config.rs new file mode 100644 index 0000000..71abd34 --- /dev/null +++ b/apps/soundcraft/src/config.rs @@ -0,0 +1,192 @@ +//! Per-user settings and automation profile directories. + +use std::ffi::OsString; +use std::path::PathBuf; + +#[derive(Clone, Copy)] +enum Platform { + Mac, + Windows, + Other, +} + +/// SoundCraft's settings folder. A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default. +/// The override is the complete directory; no application-name suffix is appended. An explicitly +/// empty value disables the settings directory instead of falling back to a personal profile. +pub fn config_dir() -> Option { + let platform = if cfg!(target_os = "macos") { + Platform::Mac + } else if cfg!(windows) { + Platform::Windows + } else { + Platform::Other + }; + config_dir_with(platform, |name| std::env::var_os(name)) +} + +fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option) -> Option { + if let Some(dir) = env("SOUNDCRAFT_CONFIG_DIR") { + return (!dir.is_empty()).then(|| PathBuf::from(dir)); + } + match platform { + Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/SoundCraft")), + Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("SoundCraft")), + Platform::Other => { + env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config"))).map(|c| c.join("soundcraft")) + } + } +} + +/// Read a bounded native settings file and reject invalid docking trees before using them. +pub fn load_ui(path: &std::path::Path) -> Result, String> { + use std::io::Read as _; + let file = match std::fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("Settings could not be read: {error}")), + }; + let mut bytes = Vec::new(); + file.take(MAX_UI_BYTES + 1).read_to_end(&mut bytes).map_err(|error| format!("Settings could not be read: {error}"))?; + if bytes.len() as u64 > MAX_UI_BYTES { + return Err("Settings file exceeds the 4 MiB limit".into()); + } + let state: soundcraft_ui_egui::UiState = serde_json::from_slice(&bytes).map_err(|error| format!("Settings could not be restored: {error}"))?; + state.docking.validate()?; + state.mix_docking.validate_mix()?; + Ok(Some(state)) +} + +const MAX_UI_BYTES: u64 = 4 * 1024 * 1024; + +/// Write settings atomically. Preserve an unreadable or malformed existing file for recovery. +pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Result<(), String> { + use std::io::Write as _; + load_ui(path)?; + ui.docking.validate()?; + ui.mix_docking.validate_mix()?; + let mut saved = ui.clone(); + saved.plugin_windows.clear(); + saved.status.clear(); + 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()); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|error| error.to_string())?.as_nanos(); + let temporary = path.with_extension(format!("json.{}.{nonce}.tmp", std::process::id())); + let mut file = + std::fs::OpenOptions::new().write(true).create_new(true).open(&temporary).map_err(|error| format!("Settings could not be saved: {error}"))?; + let written = (|| -> std::io::Result<()> { + file.write_all(&bytes)?; + file.sync_all()?; + std::fs::rename(&temporary, path) + })(); + if written.is_err() { + let _ = std::fs::remove_file(&temporary); + } + written.map_err(|error| format!("Settings could not be saved: {error}")) +} + +#[cfg(test)] +mod config_tests { + use super::*; + + #[test] + fn native_file_reload_preserves_edit_mix_and_hidden_floating_panels() { + use soundcraft_ui_egui::{MainWindow, Services, SoundApp, docking::Panel}; + let directory = std::env::temp_dir().join(format!("soundcraft-dock-prefs-{}", std::process::id())); + let path = directory.join("ui.json"); + let _ = std::fs::remove_dir_all(&directory); + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[80,90,260,400]})).unwrap(); + app.run("ui.dock", serde_json::json!({"operation":"close","panel":"tracks"})).unwrap(); + app.ui.theme = soundcraft_ui_egui::theme::ThemeMode::Light; + let edit = serde_json::to_value(&app.ui.docking).unwrap(); + app.ui.window = MainWindow::Mix; + app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap(); + app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[140,150,280,420]})).unwrap(); + save_ui(&path, &app.ui).unwrap(); + 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]); + 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]); + let mut invalid_mix = app.ui.clone(); + invalid_mix.mix_docking = soundcraft_ui_egui::docking::Workspace::default(); + let invalid_bytes = serde_json::to_vec(&invalid_mix).unwrap(); + std::fs::write(&path, &invalid_bytes).unwrap(); + assert!(load_ui(&path).is_err()); + 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(); + assert!(load_ui(&path).is_err()); + assert!(save_ui(&path, &app.ui).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"damaged settings"); + 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())); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("ui.json"); + let bytes = vec![b' '; MAX_UI_BYTES as usize + 1]; + std::fs::write(&path, &bytes).unwrap(); + assert!(load_ui(&path).is_err()); + assert!(save_ui(&path, &soundcraft_ui_egui::UiState::default()).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + std::fs::remove_dir_all(directory).unwrap(); + } + + fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option { + config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value)))) + } + + #[test] + fn config_override_wins_without_reading_user_profile() { + for platform in [Platform::Mac, Platform::Windows, Platform::Other] { + let actual = config_dir_with(platform, |name| { + assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR"); + Some(OsString::from("isolated/settings")) + }); + assert_eq!(actual, Some(PathBuf::from("isolated/settings"))); + } + } + + #[test] + fn config_empty_override_disables_profile_without_reading_defaults() { + for platform in [Platform::Mac, Platform::Windows, Platform::Other] { + assert_eq!( + config_dir_with(platform, |name| { + assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR"); + Some(OsString::new()) + }), + None + ); + } + } + + #[test] + fn config_unset_override_keeps_platform_defaults() { + let vars = [("HOME", "/user"), ("APPDATA", "/roaming"), ("XDG_CONFIG_HOME", "/xdg")]; + assert_eq!(resolve(Platform::Mac, &vars), Some(PathBuf::from("/user/Library/Application Support/SoundCraft"))); + assert_eq!(resolve(Platform::Windows, &vars), Some(PathBuf::from("/roaming/SoundCraft"))); + assert_eq!(resolve(Platform::Other, &vars), Some(PathBuf::from("/xdg/soundcraft"))); + assert_eq!(resolve(Platform::Other, &[("HOME", "/user")]), Some(PathBuf::from("/user/.config/soundcraft"))); + for platform in [Platform::Mac, Platform::Windows, Platform::Other] { + assert_eq!(resolve(platform, &[]), None); + } + } + + #[cfg(unix)] + #[test] + fn config_override_preserves_non_unicode_paths() { + use std::os::unix::ffi::OsStringExt as _; + let value = OsString::from_vec(b"isolated/\xff".to_vec()); + assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value))); + } +} diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 87da4f0..a54e7e1 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -11,6 +11,7 @@ #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod config; mod control_server; #[cfg(any(target_os = "windows", test))] mod graphics; @@ -97,16 +98,7 @@ fn open_path(app: &mut SoundApp, p: &str) { /// The per-user settings directory: `ui.json`, `Autosave/`, `Presets/` and `logs/` live here. fn config_dir() -> Option { - if cfg!(target_os = "macos") { - std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SoundCraft")) - } else if cfg!(windows) { - std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SoundCraft")) - } else { - std::env::var_os("XDG_CONFIG_HOME") - .map(std::path::PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) - .map(|c| c.join("soundcraft")) - } + config::config_dir() } fn prefs_path() -> Option { @@ -122,24 +114,24 @@ fn load_prefs() -> Option { if !prefs_enabled() { return None; } - let bytes = std::fs::read(prefs_path()?).ok()?; - serde_json::from_slice(&bytes).ok() + let path = prefs_path()?; + match config::load_ui(&path) { + Ok(state) => state, + Err(error) => { + log::warn!("{error}"); + None + } + } } fn save_prefs(ui: &UiState) { if !prefs_enabled() { return; } - if let Some(p) = prefs_path() { - if let Some(dir) = p.parent() { - let _ = std::fs::create_dir_all(dir); - } - let mut ui = ui.clone(); - ui.plugin_windows.clear(); - ui.status.clear(); - if let Ok(bytes) = serde_json::to_vec_pretty(&ui) { - let _ = std::fs::write(&p, bytes); - } + if let Some(path) = prefs_path() + && let Err(error) = config::save_ui(&path, ui) + { + log::warn!("{error}"); } } @@ -198,7 +190,7 @@ fn main() -> eframe::Result { let files: Vec = args .iter() .enumerate() - .filter(|(i, a)| !a.starts_with("--") && !(i > &0 && args.get(i - 1).is_some_and(|p| p == "--control"))) + .filter(|(i, a)| !(a.starts_with("--") || (i > &0 && args.get(i - 1).is_some_and(|p| p == "--control")))) .map(|(_, a)| a.clone()) .collect(); log::info!("SoundCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH); @@ -215,7 +207,7 @@ fn main() -> eframe::Result { }, None => { logger.no_file(); - log::warn!("no log file: no settings directory (HOME, XDG_CONFIG_HOME or APPDATA is not set)"); + log::warn!("no log file: no settings directory (profile override is empty or platform configuration is unavailable)"); } } } diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 70cdd1c..ef6b8ef 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -663,7 +663,7 @@ impl MixEngine { // through any track, send or insert. Only the playback engine sets `metronome`. // Not while monitoring only (transport stopped): the position does not advance there, so the // start of the same beat would repeat every block as a buzz. - if self.metronome && !self.monitor_only && s.edit.click && !(s.edit.flag("click.only_during_record") && !self.recording) { + if self.metronome && !self.monitor_only && s.edit.click && (!s.edit.flag("click.only_during_record") || self.recording) { click::render(s, pos, frames, &mut self.main); } // Output: min(out.len(), main channels) channels; any further output channels are silent. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c91b324..66158bb 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -4,10 +4,11 @@ description = "SoundCraft's egui user interface: Edit and Mix windows, transport version.workspace = true edition.workspace = true license.workspace = true -rust-version.workspace = true +rust-version = "1.95" repository.workspace = true [dependencies] +craft-ui.workspace = true soundcraft-time.workspace = true soundcraft-audio-io.workspace = true soundcraft-midi.workspace = true diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs new file mode 100644 index 0000000..f2668fa --- /dev/null +++ b/crates/ui-egui/src/docking.rs @@ -0,0 +1,475 @@ +//! 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::layout::{SplitAxis, SplitSize}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Panel { + Canvas, + Tracks, + Clips, + Midi, + Memory, + History, +} + +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", + Self::Tracks => "Tracks and Groups", + Self::Clips => "Clips", + Self::Midi => "MIDI Editor", + Self::Memory => "Memory Locations", + 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, + } + } + fn parse(id: &str) -> Result { + Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}")) + } +} + +const MIDI_TAB_HEIGHT: f32 = 26.0; + +fn permissions(panel: &Panel) -> Permissions { + if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() } +} + +fn tabs(panel: Panel) -> Node { + Node::Tabs { panels: vec![panel], active: 0 } +} + +fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node { + 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, + /// Closed panels retain their most recent group, side, size or floating rectangle. + pub hidden: Vec<(Panel, Location)>, +} + +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(), + } + } +} + +impl Workspace { + pub fn validate(&self) -> Result<(), String> { + self.layout.validate().map_err(|error| error.to_string())?; + if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() { + return Err("The panel layout must contain its document view and bounded hidden panels".into()); + } + // Saved settings cannot turn the protected document view into a floating or closable tab. + if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) { + return Err("The document view cannot float".into()); + } + let mut nodes: Vec<_> = self.layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => { + nodes.push(first); + nodes.push(second); + } + Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { + return Err("The document view cannot share a tab group".into()); + } + Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { + return Err("The document view cannot be collapsed".into()); + } + _ => {} + } + } + let mut seen = std::collections::HashSet::new(); + for (panel, location) in &self.hidden { + if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) { + return Err("A hidden panel has an invalid or duplicate identity".into()); + } + if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) { + return Err("A hidden panel cannot restore over the document view".into()); + } + let mut probe = self.layout.clone(); + if let Err(error) = probe.restore(*panel, location) + && error != craft_ui::docking::DockError::MissingTarget + { + return Err(error.to_string()); + } + } + Ok(()) + } + + pub fn validate_mix(&self) -> Result<(), String> { + self.validate()?; + if self.layout.contains(&Panel::Clips) || self.layout.contains(&Panel::Midi) { + return Err("Clips and MIDI panels belong to the Edit window".into()); + } + Ok(()) + } + + pub fn apply(&mut self, action: Action) -> Result<(), String> { + self.validate()?; + let mut next = self.clone(); + if let Action::Close { panel } = &action { + let location = next.layout.location(panel).map_err(|error| error.to_string())?; + next.hidden.retain(|(id, _)| id != panel); + next.hidden.push((*panel, location)); + } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action { + next.hidden.retain(|(id, _)| id != panel); + } + next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?; + next.validate()?; + *self = next; + Ok(()) + } + + pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> { + if self.layout.contains(&panel) == visible { + return Ok(()); + } + if !visible { + return self.apply(Action::Close { panel }); + } + self.validate()?; + let mut next = self.clone(); + let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); + next.hidden.retain(|(id, _)| *id != panel); + if let Some(saved) = &mut location + && saved.floating.is_some() + && saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor))) + { + // The other members of a floating group may have been closed too. + saved.anchor = None; + saved.placement = Placement::Tab { before: None }; + } + let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok()); + if !restored { + next.layout + .apply_with_permissions( + Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, + permissions, + ) + .map_err(|error| error.to_string())?; + } + next.validate()?; + *self = next; + Ok(()) + } + + fn command(&mut self, params: &Value) -> Result<(), String> { + self.validate()?; + if let Some(action) = params.get("action") { + let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?; + return self.apply(action); + } + let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?; + if operation == "resizeSplit" { + let path: Vec = + serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?; + let size: SplitSize = + serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?; + return self.apply(Action::ResizeSplit { path, size }); + } + let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?; + let action = match operation { + "open" => return self.set_visible(panel, true), + "close" => Action::Close { panel }, + "activate" => Action::Activate { panel }, + "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? }, + "resizeStack" => { + let value = params.get("height").ok_or("height is required")?; + let height = if value.is_null() { + None + } else { + Some( + value + .as_f64() + .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height)) + .ok_or("height must be finite and bounded")? as f32, + ) + }; + Action::ResizeStack { panel, height } + } + "float" | "moveFloating" => { + let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?; + let mut values = [0.0f32; 4]; + for (output, value) in values.iter_mut().zip(rect) { + *output = value + .as_f64() + .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) + .ok_or("rect values must be finite and bounded")? as f32; + } + if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } } + } + "move" => { + let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?; + let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") { + "center" => Placement::Split(Zone::Center), + "left" => Placement::Split(Zone::Left), + "right" => Placement::Split(Zone::Right), + "top" => Placement::Split(Zone::Top), + "bottom" => Placement::Split(Zone::Bottom), + _ => return Err("zone must be center, left, right, top or bottom".into()), + }; + let placement = if let Some(before) = params.get("before") { + if !matches!(placement, Placement::Split(Zone::Center)) { + return Err("before only applies to a center move".into()); + } + Placement::Tab { + before: if before.is_null() { + None + } else { + Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?) + }, + } + } else { + placement + }; + Action::Move { panel, anchor, placement } + } + _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()), + }; + self.apply(action) + } +} + +/// 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); + workspace +} + +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)?; + if edit { + workspace.set_visible(Panel::Clips, app.ui.show_clip_list)?; + workspace.set_visible(Panel::Midi, app.ui.show_midi_editor)?; + } + Ok(()) +} + +fn publish_visibility(app: &mut crate::SoundApp, workspace: &Workspace, edit: bool) { + app.ui.show_tracks_list = workspace.layout.contains(&Panel::Tracks); + app.ui.show_memory_locations = workspace.layout.contains(&Panel::Memory); + app.ui.show_undo_history = workspace.layout.contains(&Panel::History); + if edit { + app.ui.show_clip_list = workspace.layout.contains(&Panel::Clips); + app.ui.show_midi_editor = workspace.layout.contains(&Panel::Midi); + } +} + +fn store(app: &mut crate::SoundApp, workspace: Workspace, edit: bool) { + if edit { + app.ui.docking = workspace; + } else { + app.ui.mix_docking = workspace; + } +} + +pub fn command(app: &mut crate::SoundApp, reset: bool, params: &Value) -> Result { + let edit = app.ui.window == crate::MainWindow::Edit; + let previous = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() }; + let result = command_for(app, reset, params, 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) + && let Some(ctx) = &app.docking_context + { + craft_ui::docking::cancel_drag::(ctx, egui::Id::new(("soundcraft-docking", edit))); + } +} + +/// Restoring complete preferences/configurations uses the same ownership boundary as commands. +pub(crate) fn cancel_replaced_state(app: &crate::SoundApp, next: &crate::UiState) { + cancel_replaced_workspace(app, &app.ui.docking, &next.docking, true); + cancel_replaced_workspace(app, &app.ui.mix_docking, &next.mix_docking, false); +} + +fn command_for(app: &mut crate::SoundApp, reset: bool, params: &Value, edit: bool) -> Result { + if !edit && ["panel", "target"].iter().any(|field| matches!(params.get(*field).and_then(Value::as_str), Some("clips" | "midi"))) { + return Err("Clips and MIDI panels belong to the Edit window".into()); + } + 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() }; + sync(&mut workspace, app, edit)?; + } else { + sync(&mut workspace, app, edit)?; + workspace.command(params)?; + if !edit { + workspace.validate_mix()?; + } + } + publish_visibility(app, &workspace, edit); + let result = json!({"docking": workspace}); + store(app, workspace, edit); + Ok(result) +} + +/// Existing Arrange commands reposition shared floating panels along with the legacy windows. +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()?; + } else { + workspace.validate_mix()?; + } + let previous = workspace.clone(); + let floating: Vec<_> = workspace.layout.floating.iter().filter_map(|group| group.panels.first().map(|panel| (*panel, group.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 })?; + } + cancel_replaced_workspace(app, &previous, &workspace, edit); + store(app, workspace, edit); + Ok(()) +} + +pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWindow) { + 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 before_sync = workspace.clone(); + if let Err(error) = (if edit { workspace.validate() } else { workspace.validate_mix() }).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() }; + let _ = sync(&mut workspace, app, edit); + } + cancel_replaced_workspace(app, &before_sync, &workspace, edit); + let mut style = DockStyle::from_ui(ui); + style.min_pane = 96.0; + style.tab_height = MIDI_TAB_HEIGHT; + style.background = t.panel_bg; + style.tab_background = t.panel_bg2; + style.active_background = t.row_selected; + style.text = t.text; + style.inactive_text = t.text_dim; + 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( + ui, + &workspace.layout, + &style, + |panel| panel.label().into(), + permissions, + panel_limits, + |ui, panel| match panel { + Panel::Canvas => { + ui.painter().rect_filled(ui.max_rect(), 0.0, t.window_bg); + if edit { + crate::edit_window::main_area(app, ui); + } else { + crate::mix_window::content(app, ui); + } + } + Panel::Tracks => crate::panels::tracks_and_groups(app, ui), + Panel::Clips => crate::panels::clip_list(app, ui), + Panel::Midi => { + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("soundcraft-midi-body"), ui.max_rect())); + minimum_body(ui, egui::vec2(420.0, 260.0), |ui| crate::midi_editor::show(app, ui)); + } + Panel::Memory => crate::panels::memory_locations(app, ui), + Panel::History => crate::panels::undo_history(app, ui), + }, + ); + let _ = sync(&mut workspace, app, edit); + 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)) + { + app.ui.status = format!("Panel move was not applied: {error}"); + } + } +} + +/// Docking may make a specialist view smaller than its fixed tool/ruler geometry. Keep that +/// geometry scrollable inside its clipped panel instead of producing inverted rectangles. +pub(crate) fn minimum_body(ui: &mut egui::Ui, minimum: egui::Vec2, body: impl FnOnce(&mut egui::Ui)) { + let available = ui.available_size(); + if available.x >= minimum.x && available.y >= minimum.y { + body(ui); + return; + } + let extent = available.max(minimum); + egui::ScrollArea::both().id_salt("minimum-body").auto_shrink([false, false]).show(ui, |ui| { + let (rect, _) = ui.allocate_exact_size(extent, egui::Sense::hover()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect)); + body(&mut child); + }); +} + +fn panel_limits(panel: &Panel) -> PanelLimits { + let (min, max_width) = match panel { + Panel::Canvas => (egui::vec2(300.0, 240.0), 1_000_000.0), + Panel::Tracks => (egui::vec2(140.0, 120.0), 520.0), + Panel::Clips => (egui::vec2(180.0, 120.0), 720.0), + Panel::Midi => (egui::vec2(420.0, 260.0), 1_000_000.0), + Panel::Memory => (egui::vec2(260.0, 160.0), 1_000_000.0), + Panel::History => (egui::vec2(200.0, 160.0), 720.0), + }; + PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) } +} diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 0ea03a2..906ae1e 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -2,7 +2,7 @@ use crate::theme::{Tokens, bold, clip_colors, regular, rgb}; use crate::widgets::{rec_toggle, selector_box, text_toggle}; -use crate::{Gesture, SoundApp, icons, panels, toolbar}; +use crate::{Gesture, SoundApp, icons, toolbar}; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, Session, Tool, Track, TrackHeight, TrackId, TrackKind}; @@ -66,27 +66,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { egui::Panel::top("edit_toolbar").exact_size(toolbar::HEIGHT).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| toolbar::show(app, ui)); egui::Panel::bottom("edit_status").exact_size(22.0).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| status_bar(app, ui)); egui::Panel::bottom("lower_dock_tabs").exact_size(22.0).frame(egui::Frame::NONE.fill(t.panel_bg2)).show(ui, |ui| dock_tabs(app, ui)); - if app.ui.show_midi_editor { - egui::Panel::bottom("midi_editor") - .resizable(true) - .default_size(320.0) - .min_size(260.0) - .frame(egui::Frame::NONE.fill(t.panel_bg)) - .show(ui, |ui| crate::midi_editor::show(app, ui)); - } if app.ui.show_universe { egui::Panel::top("universe").exact_size(64.0).frame(egui::Frame::NONE.fill(Tokens::current().universe_bg)).show(ui, |ui| universe(app, ui)); } - if app.ui.show_tracks_list { - egui::Panel::left("tracks_list") - .exact_size(168.0) - .frame(egui::Frame::NONE.fill(t.panel_bg)) - .show(ui, |ui| panels::tracks_and_groups(app, ui)); - } - if app.ui.show_clip_list { - egui::Panel::right("clip_list").exact_size(230.0).frame(egui::Frame::NONE.fill(t.panel_bg)).show(ui, |ui| panels::clip_list(app, ui)); - } - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| main_area(app, ui)); + crate::docking::show(app, ui, crate::MainWindow::Edit); } /// Universe: the whole session in miniature; the box is the visible area (drag or click to move). @@ -177,7 +160,17 @@ fn status_bar(app: &mut SoundApp, ui: &mut Ui) { ); } -fn main_area(app: &mut SoundApp, ui: &mut Ui) { +pub(crate) fn main_area(app: &mut SoundApp, ui: &mut Ui) { + let session = app.engine.session(); + let mut ruler_height_total = session.edit.rulers.iter().map(|ruler| ruler_height(ruler)).sum::(); + if session.edit.flag("view.ruler.tempo_editor") && session.edit.rulers.iter().any(|ruler| ruler == "tempo") { + ruler_height_total += ruler_height("tempo_editor"); + } + let minimum = vec2(header_width(session) + 64.0, ruler_height_total + 100.0); + crate::docking::minimum_body(ui, minimum, |ui| main_area_content(app, ui)); +} + +fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let full = ui.max_rect(); let rulers: Vec = app.engine.session().edit.rulers.clone(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6da9b92..955bd5a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -7,6 +7,7 @@ pub mod control; pub mod credits; pub mod dialogs; +pub mod docking; pub mod edit_window; pub mod extra_windows; pub mod fonts; @@ -47,6 +48,9 @@ pub enum MainWindow { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(default)] pub struct UiState { + pub docking: docking::Workspace, + #[serde(default = "docking::mix_default")] + pub mix_docking: docking::Workspace, pub window: MainWindow, pub show_tracks_list: bool, pub show_clip_list: bool, @@ -102,6 +106,8 @@ pub struct UiState { impl Default for UiState { fn default() -> Self { UiState { + docking: docking::Workspace::default(), + mix_docking: docking::mix_default(), window: MainWindow::Edit, show_tracks_list: true, show_clip_list: true, @@ -229,6 +235,8 @@ pub struct SoundApp { fonts_installed: bool, /// The palette last applied to the context (light?), so visuals are set only on a change. theme_applied: Option, + /// Last panel host context, used to cancel renderer-owned gestures on explicit replacement. + pub(crate) docking_context: Option, /// Transport simulation when there is no player (tests, offscreen renders). sim: Option<(Samples, Option, Option)>, pub quit_requested: bool, @@ -286,6 +294,7 @@ impl SoundApp { fonts_ready: false, fonts_installed: false, theme_applied: None, + docking_context: None, sim: None, quit_requested: false, arrange_request: false, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 89aca57..8ae3899 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,6 +7,8 @@ use soundcraft_engine::catalog; /// UI-layer commands: (id, label, catalog path or "", shortcut). pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ + ("ui.dock", "Arrange Panel", "", None), + ("ui.dock.reset", "Reset Panel Layout", "Window > Reset Panel Layout", None), ("window.mix", "Mix", "Window > Mix", Some("Cmd+=")), ("window.edit", "Edit", "Window > Edit", Some("Cmd+=")), ("window.toggle_mix_edit", "Toggle Mix/Edit", "", Some("Cmd+=")), @@ -194,6 +196,7 @@ pub fn tree() -> Vec { for line in catalog::catalog() { insert_path(&mut roots, line); } + insert_path(&mut roots, "Window > Reset Panel Layout"); for (path, _) in MENU_WINDOWS { insert_path(&mut roots, path); } @@ -277,7 +280,11 @@ pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option { /// Enabled and checked state shared by all menu presentations. pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) { let enabled = match id { - Some(i) if i.starts_with("window.") || i.starts_with("view.mix_section") || i == "audiosuite.process" => true, + Some(i) + if i.starts_with("window.") || i.starts_with("view.mix_section") || matches!(i, "audiosuite.process" | "ui.dock" | "ui.dock.reset") => + { + true + } Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()), None => false, }; @@ -427,6 +434,9 @@ fn wants_dialog(id: &str) -> bool { /// Handle UI-layer commands. Returns None when `id` is not a UI command. pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option> { + if matches!(id, "ui.dock" | "ui.dock.reset") { + return Some(crate::docking::command(app, id == "ui.dock.reset", p)); + } if let Some(v) = crate::extra_windows::run(app, id, p) { return Some(Ok(v)); } @@ -494,6 +504,9 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option toggle(&mut app.ui.show_configurations), "window.config_new" | "window.config_update" => { + if let Err(error) = app.ui.docking.validate().and_then(|()| app.ui.mix_docking.validate_mix()) { + return Some(Err(error)); + } let mut snap = app.ui.clone(); snap.configurations.clear(); let v = serde_json::to_value(&snap).unwrap_or_default(); @@ -508,6 +521,9 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option { + if let Err(error) = crate::docking::arrange(app) { + app.ui.status = format!("Panel windows could not be arranged: {error}"); + } app.arrange_request = true; json!({}) } @@ -567,8 +583,12 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option(cur) { Ok(ns) => { + if let Err(error) = ns.docking.validate().and_then(|()| ns.mix_docking.validate_mix()) { + return Some(Err(error)); + } + crate::docking::cancel_replaced_state(app, &ns); app.ui = ns; json!({"ui": app.ui}) } diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 20591fe..0a6b84e 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -1,10 +1,11 @@ //! The Mix window: one channel strip per track. +use crate::SoundApp; use crate::theme::{Tokens, bold, regular, rgb}; use crate::widgets::{ - PannerEdit, PannerSpeaker, db_text, fader, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, surround_panner, text_toggle, + PannerEdit, PannerSpeaker, db_text, fader, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, selector_box_with_tooltip, + surround_panner, text_toggle, }; -use crate::{SoundApp, panels}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{ChannelFormat, Route, SurroundPan, Track, TrackId, TrackKind}; @@ -15,28 +16,23 @@ pub fn strip_width(narrow: bool) -> f32 { } pub fn show(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::current(); - if app.ui.show_tracks_list { - egui::Panel::left("mix_tracks_list") - .exact_size(168.0) - .frame(egui::Frame::NONE.fill(t.panel_bg)) - .show(ui, |ui| panels::tracks_and_groups(app, ui)); - } - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| { - let ids: Vec = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect(); - // Per-channel peaks for multichannel meters (read once per frame). - let snap = app - .player - .as_ref() - .filter(|_| app.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor)) - .map(|p| p.meters()); - egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| { - ui.horizontal_top(|ui| { - ui.spacing_mut().item_spacing.x = 1.0; - for id in ids { - strip(app, ui, id, snap.as_ref()); - } - }); + crate::docking::show(app, ui, crate::MainWindow::Mix); +} + +pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) { + let ids: Vec = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect(); + // Per-channel peaks for multichannel meters (read once per frame). + let snap = app + .player + .as_ref() + .filter(|_| app.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor)) + .map(|p| p.meters()); + egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| { + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + for id in ids { + strip(app, ui, id, snap.as_ref()); + } }); }); } @@ -247,7 +243,15 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } else { t.auto_write }; - let resp = selector_box(&mut c, am_r.width(), am_r.height(), &format!("auto {}", mode.label()), col); + let full_label = format!("auto {}", mode.label()); + let label = if mode == soundcraft_model::AutomationMode::TouchLatch { + if narrow { "t/l" } else { mode.label() } + } else if narrow { + mode.label() + } else { + &full_label + }; + let resp = selector_box_with_tooltip(&mut c, am_r.width(), am_r.height(), label, col, &full_label); egui::Popup::menu(&resp).show(|ui| { for m in soundcraft_model::AutomationMode::ALL { if ui.selectable_label(m == mode, m.label()).clicked() { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 33c4324..9ee4ba2 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -168,9 +168,7 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { /// Floating windows. pub fn floating(app: &mut SoundApp, ctx: &egui::Context) { transport_window(app, ctx); - memory_locations(app, ctx); big_counter(app, ctx); - undo_history(app, ctx); plugin_windows(app, ctx); about(app, ctx); session_info(app, ctx); @@ -277,92 +275,78 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_big_counter = open; } -fn memory_locations(app: &mut SoundApp, ctx: &egui::Context) { - let mut open = app.ui.show_memory_locations; - if !open { - return; - } - egui::Window::new("Memory Locations").open(&mut open).default_size(vec2(480.0, 280.0)).show(ctx, |ui| { - ui.horizontal(|ui| { - if ui.button("+ Marker").clicked() { - let _ = app.run("markers.add", json!({})); +pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) { + ui.horizontal(|ui| { + if ui.button("+ Marker").clicked() { + let _ = app.run("markers.add", json!({})); + } + if ui.button("+ Selection").clicked() { + let _ = app.run("markers.add", json!({"kind": "selection"})); + } + }); + let rows: Vec<(u32, String, String, String, String)> = { + let s = app.engine.session(); + let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + s.markers + .iter() + .map(|m| { + let kind = match m.kind { + soundcraft_model::MarkerKind::Marker => "marker", + soundcraft_model::MarkerKind::Selection => "selection", + soundcraft_model::MarkerKind::None => "none", + }; + let len = if m.kind == soundcraft_model::MarkerKind::Selection { f(m.end - m.start) } else { String::new() }; + (m.number, m.name.clone(), f(m.start), len, kind.to_string()) + }) + .collect() + }; + egui::ScrollArea::vertical().show(ui, |ui| { + egui::Grid::new("mem_grid").striped(true).num_columns(6).show(ui, |ui| { + for h in ["#", "Name", "Location", "Length", "Type", ""] { + ui.label(egui::RichText::new(h).strong()); } - if ui.button("+ Selection").clicked() { - let _ = app.run("markers.add", json!({"kind": "selection"})); - } - }); - let rows: Vec<(u32, String, String, String, String)> = { - let s = app.engine.session(); - let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); - s.markers - .iter() - .map(|m| { - let kind = match m.kind { - soundcraft_model::MarkerKind::Marker => "marker", - soundcraft_model::MarkerKind::Selection => "selection", - soundcraft_model::MarkerKind::None => "none", - }; - let len = if m.kind == soundcraft_model::MarkerKind::Selection { f(m.end - m.start) } else { String::new() }; - (m.number, m.name.clone(), f(m.start), len, kind.to_string()) - }) - .collect() - }; - egui::ScrollArea::vertical().show(ui, |ui| { - egui::Grid::new("mem_grid").striped(true).num_columns(6).show(ui, |ui| { - for h in ["#", "Name", "Location", "Length", "Type", ""] { - ui.label(egui::RichText::new(h).strong()); + ui.end_row(); + for (n, name, loc, len, kind) in rows { + if ui.button(n.to_string()).on_hover_text("Recall").clicked() { + let _ = app.run("markers.recall", json!({"number": n})); + } + let key = egui::Id::new(("mem_name", n)); + let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0)); + if r.changed() { + ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone())); + } + if r.lost_focus() && edit != name { + let _ = app.run("markers.edit", json!({"number": n, "rename": edit})); + ui.ctx().memory_mut(|m| m.data.remove::(key)); + } + ui.label(egui::RichText::new(loc).font(mono(11.0))); + ui.label(egui::RichText::new(len).font(mono(11.0))); + ui.label(kind); + if ui.small_button("✕").on_hover_text("Delete").clicked() { + let _ = app.run("markers.delete", json!({"number": n})); } ui.end_row(); - for (n, name, loc, len, kind) in rows { - if ui.button(n.to_string()).on_hover_text("Recall").clicked() { - let _ = app.run("markers.recall", json!({"number": n})); - } - let key = egui::Id::new(("mem_name", n)); - let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0)); - if r.changed() { - ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone())); - } - if r.lost_focus() && edit != name { - let _ = app.run("markers.edit", json!({"number": n, "rename": edit})); - ui.ctx().memory_mut(|m| m.data.remove::(key)); - } - ui.label(egui::RichText::new(loc).font(mono(11.0))); - ui.label(egui::RichText::new(len).font(mono(11.0))); - ui.label(kind); - if ui.small_button("✕").on_hover_text("Delete").clicked() { - let _ = app.run("markers.delete", json!({"number": n})); - } - ui.end_row(); - } - }); + } }); }); - app.ui.show_memory_locations = open; } -fn undo_history(app: &mut SoundApp, ctx: &egui::Context) { - let mut open = app.ui.show_undo_history; - if !open { - return; - } - egui::Window::new("Undo History").open(&mut open).default_size(vec2(260.0, 300.0)).show(ctx, |ui| { - let hist = app.engine.undo_history(); - let n = hist.len(); - egui::ScrollArea::vertical().show(ui, |ui| { - for (i, h) in hist.iter().enumerate() { - if ui.selectable_label(i + 1 == n, format!("{} {h}", i + 1)).clicked() { - for _ in 0..(n - i - 1) { - let _ = app.run("edit.undo", json!({})); - } +pub(crate) fn undo_history(app: &mut SoundApp, ui: &mut Ui) { + let hist = app.engine.undo_history(); + let n = hist.len(); + egui::ScrollArea::vertical().show(ui, |ui| { + for (i, h) in hist.iter().enumerate() { + if ui.selectable_label(i + 1 == n, format!("{} {h}", i + 1)).clicked() { + for _ in 0..(n - i - 1) { + let _ = app.run("edit.undo", json!({})); } } - if let Some(r) = app.engine.redo_label() { - ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::current().text_dim)); - } - }); + } + if let Some(r) = app.engine.redo_label() { + ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::current().text_dim)); + } }); - app.ui.show_undo_history = open; } fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index da913a8..d4eab05 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1,24 +1,33 @@ //! Small custom widgets in the studio style. +use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle}; + use crate::icons; use crate::theme::{Tokens, bold, regular}; use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +/// Local colors for shared icon-button interaction; transport-specific states stay in the toolbar. +fn button_style(selected_fill: Color32, normal_icon: Color32, radius: u8) -> IconButtonStyle { + let t = Tokens::current(); + let visuals = |fill, icon| ButtonVisuals { fill, stroke: Stroke::new(1.0, t.button_border), icon }; + IconButtonStyle { + normal: visuals(t.button, normal_icon), + hovered: visuals(t.button_hi, normal_icon), + pressed: None, + selected: visuals(selected_fill, Color32::WHITE), + corner_radius: CornerRadius::same(radius), + focus_stroke: Stroke::new(1.0, t.accent), + } +} + /// A toolbar button with an icon; `on` draws the blue selected state. pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &str) -> Response { let t = Tokens::current(); - let (r, resp) = ui.allocate_exact_size(size, Sense::click()); - let fill = if on { - t.accent - } else if resp.hovered() { - t.button_hi - } else { - t.button - }; - ui.painter().rect(r, CornerRadius::same(3), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside); - let col = if on { Color32::WHITE } else { t.text }; - let s = r.height().min(r.width()) * 0.78; - icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, col); + let style = button_style(t.accent, t.text, 3); + let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| { + let s = r.height().min(r.width()) * 0.78; + icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, tint); + }); resp.on_hover_text(tip) } @@ -39,24 +48,22 @@ pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color resp.on_hover_text(tip) } -/// Record-enable button (red dot). +/// Record-enable button (red dot); the caller owns track arming and command dispatch. pub fn rec_toggle(ui: &mut Ui, size: egui::Vec2, on: bool, tip: &str) -> Response { - let t = Tokens::current(); - let (r, resp) = ui.allocate_exact_size(size, Sense::click()); - let fill = if on { - t.rec - } else if resp.hovered() { - t.button_hi - } else { - t.button - }; - ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside); - ui.painter().circle_filled(r.center(), size.y * 0.24, if on { Color32::WHITE } else { Color32::from_rgb(190, 190, 190) }); + let style = button_style(Tokens::current().rec, Color32::from_rgb(190, 190, 190), 2); + let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| { + ui.painter().circle_filled(r.center(), size.y * 0.24, tint); + }); resp.on_hover_text(tip) } /// A dropdown-looking selector box. Returns the response (caller opens a menu on click). pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32) -> Response { + selector_box_with_tooltip(ui, width, height, text, color, text) +} + +/// A selector with a compact visible label and the complete meaning on hover. +pub(crate) fn selector_box_with_tooltip(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32, tooltip: &str) -> Response { let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); ui.painter().rect( @@ -68,10 +75,15 @@ pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Col ); let tr = Rect::from_min_max(pos2(r.max.x - height * 0.8, r.min.y), r.max); icons::draw(ui.painter(), tr.shrink(height * 0.15), "triangle_down", t.text_dim); - let galley = ui.painter().layout(text.to_string(), regular((height * 0.62).clamp(8.0, 12.0)), color, (width - height).max(10.0)); - let pos = pos2(r.min.x + (r.width() - height * 0.6 - galley.size().x) * 0.5, r.center().y - galley.size().y * 0.5); + let text_rect = Rect::from_min_max(r.min + vec2(3.0, 1.0), pos2(tr.left() - 2.0, r.bottom() - 1.0)); + let mut job = egui::text::LayoutJob::simple(text.to_owned(), regular((height * 0.62).clamp(8.0, 12.0)), color, text_rect.width().max(1.0)); + job.wrap.max_rows = 1; + job.wrap.break_anywhere = true; + job.wrap.overflow_character = Some('…'); + let galley = ui.painter().layout_job(job); + let pos = text_rect.center() - galley.size() * 0.5; ui.painter().with_clip_rect(r.shrink(1.0)).galley(pos, galley, color); - resp + resp.on_hover_text(tooltip) } /// Rotary knob for pan / send pan (-1..1). Drag vertically; double-click resets. @@ -371,3 +383,197 @@ mod tests { } } } + +#[cfg(test)] +mod selector_visual_regressions { + use super::*; + + fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput { + let mut input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }; + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale); + input + } + + #[test] + fn capture_selector_visual_fixtures() { + let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for scale in [1.0, 1.5, 2.0] { + let mut ready = false; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(320.0, 120.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, regions: &mut serde_json::Map| { + if !ready { + crate::fonts::install(ui.ctx()); + crate::theme::apply(ui.ctx(), false); + ready = true; + return; + } + regions.clear(); + for (index, label) in ["auto read", "no group", "A very long mixer group name"].into_iter().enumerate() { + ui.horizontal(|ui| { + for width in [46.0, 74.0, 110.0] { + let r = selector_box(ui, width, 16.0, label, Tokens::current().text).rect; + regions.insert(format!("selector-{index}-{width}"), serde_json::json!([r.left(), r.top(), r.width(), r.height()])); + } + }); + } + }, + serde_json::Map::new(), + ); + harness.input_mut().max_texture_side = Some(8192); + harness.run_steps(4); + let stem = format!("soundcraft-selectors-dark-{scale}x"); + harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); + std::fs::write( + directory.join(format!("{stem}.json")), + serde_json::to_vec_pretty(&serde_json::json!({ + "screen": [320.0, 120.0], "scale": scale, "regions": harness.state() + })) + .unwrap(), + ) + .unwrap(); + } + } + + fn mixer_app(narrow: bool, mode: soundcraft_model::AutomationMode) -> crate::SoundApp { + let mut app = crate::SoundApp::new(soundcraft_engine::Engine::default(), None, Default::default()); + app.run("track.new", serde_json::json!({"name": "Voice"})).unwrap(); + app.run("track.new", serde_json::json!({"name": "Music"})).unwrap(); + let ids: Vec<_> = app.engine.session().tracks.iter().map(|track| track.id).collect(); + for id in ids { + app.run("mix.automation_mode", serde_json::json!({"track": id.0, "mode": mode.label()})).unwrap(); + } + app.ui.narrow_mix = narrow; + app.ui.show_tracks_list = false; + app.ui.mix_views = vec!["io".into()]; + app + } + + #[test] + fn actual_mixer_keeps_every_active_automation_mode_legible() { + for narrow in [false, true] { + for mode in soundcraft_model::AutomationMode::ALL { + for scale in [1.0, 1.5, 2.0] { + let ctx = egui::Context::default(); + crate::fonts::install(&ctx); + crate::theme::apply(&ctx, false); + let mut app = mixer_app(narrow, mode); + let mut output = ctx.run_ui(scaled_input(vec2(320.0, 640.0), scale), |ui| crate::mix_window::show(&mut app, ui)); + output.textures_delta.clear(); + assert_eq!(ctx.content_rect().size(), vec2(320.0, 640.0)); + assert_eq!(ctx.pixels_per_point(), scale); + let long = format!("auto {}", mode.label()); + let short = if mode == soundcraft_model::AutomationMode::TouchLatch { "t/l" } else { mode.label() }; + let texts: Vec<_> = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) + if text.galley.job.text == long || text.galley.job.text == mode.label() || text.galley.job.text == short => + { + Some(text) + } + _ => None, + }) + .collect(); + assert_eq!(texts.len(), 2, "both tracks paint their automation mode: {mode:?}"); + for text in texts { + assert_eq!(text.galley.rows.len(), 1, "{mode:?} narrow={narrow} at {scale}x: automation text wraps"); + assert!( + !text.galley.elided, + "{mode:?} narrow={narrow} at {scale}x: the active mode must remain distinguishable without hovering" + ); + } + } + } + } + } + + #[test] + fn capture_actual_mixer_visual_fixtures() { + let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for mode in soundcraft_model::AutomationMode::ALL { + for narrow in [false, true] { + for scale in [1.0, 1.5, 2.0] { + let mut ready = false; + let mut harness = + egui_kittest::Harness::builder().with_size(vec2(320.0, 640.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, app: &mut crate::SoundApp| { + if !ready { + crate::fonts::install(ui.ctx()); + crate::theme::apply(ui.ctx(), false); + ready = true; + return; + } + crate::mix_window::show(app, ui); + }, + mixer_app(narrow, mode), + ); + harness.input_mut().max_texture_side = Some(8192); + harness.run_steps(4); + let stem = format!("soundcraft-mixer-narrow-{narrow}-{}-{scale}x", mode.label().replace('/', "-")); + harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); + let strips = harness + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Rect(rect) + if (rect.rect.width() - crate::mix_window::strip_width(narrow)).abs() < 0.1 && rect.rect.height() >= 560.0 => + { + Some(rect.rect) + } + _ => None, + }) + .fold(Rect::NOTHING, Rect::union); + assert!(strips.is_finite()); + std::fs::write( + directory.join(format!("{stem}.json")), + serde_json::to_vec_pretty(&serde_json::json!({ + "screen": [320.0, 640.0], "scale": scale, "mode": mode.label(), "narrow": narrow, + "regions": {"mixer-selectors": [strips.left(), strips.top(), strips.width(), 120.0]} + })) + .unwrap(), + ) + .unwrap(); + } + } + } + } + + #[test] + fn compact_mixer_selectors_keep_labels_on_one_visible_line() { + for width in [46.0, 74.0, 110.0] { + for label in ["auto read", "no group", "A very long mixer group name"] { + for scale in [1.0, 1.5, 2.0] { + let ctx = egui::Context::default(); + crate::fonts::install(&ctx); + crate::theme::apply(&ctx, false); + let mut rect = Rect::NOTHING; + let mut output = ctx.run_ui(scaled_input(vec2(320.0, 120.0), scale), |ui| { + rect = selector_box(ui, width, 16.0, label, Tokens::current().text).rect; + }); + output.textures_delta.clear(); + assert_eq!(ctx.content_rect().size(), vec2(320.0, 120.0)); + assert_eq!(ctx.pixels_per_point(), scale); + let text = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.job.text == label => Some(text), + _ => None, + }) + .expect("selector label is painted"); + assert_eq!(text.galley.rows.len(), 1, "{label:?} at {width} must not wrap into clipped rows"); + let bounds = text.visual_bounding_rect(); + assert!(bounds.top() >= rect.top() && bounds.bottom() <= rect.bottom(), "{label:?}: {bounds:?} outside {rect:?}"); + assert!( + bounds.left() >= rect.left() && bounds.right() <= rect.right() - 16.0 * 0.8, + "text must leave room for the dropdown arrow" + ); + } + } + } + } +} diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index b7b34e5..1e51a2d 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -383,10 +383,15 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) { if ui.button(format!("{} {name}", i + 1)).clicked() && let Ok(mut st) = serde_json::from_value::(v.clone()) { - st.configurations = app.ui.configurations.clone(); - st.theme = app.ui.theme; - st.show_configurations = true; - app.ui = st; + if let Err(error) = st.docking.validate().and_then(|()| st.mix_docking.validate_mix()) { + 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; + } } if ui.small_button("✕").clicked() { app.ui.configurations.remove(i); diff --git a/crates/ui-egui/tests/docking_workflows.rs b/crates/ui-egui/tests/docking_workflows.rs new file mode 100644 index 0000000..c2cdf33 --- /dev/null +++ b/crates/ui-egui/tests/docking_workflows.rs @@ -0,0 +1,159 @@ +//! Real Edit/Mix panel workflows, persistence, and command ownership without OS windows. +use egui::{Event, Modifiers, PointerButton, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp, UiState, docking::Panel}; + +fn harness(state: Option, 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 { + app.ui = state; + } + let mut h = Harness::builder().with_size(vec2(width, 900.0)).with_pixels_per_point(scale).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h +} + +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 }); + h.run_steps(2); + for step in 1..=8 { + h.event(Event::PointerMoved(from.lerp(to, step as f32 / 8.0))); + h.run_steps(1); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(4); +} + +#[test] +fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() { + let mut h = harness(None, 1400.0, 1.0); + let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").rect().center(); + let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").rect().center(); + drag(&mut h, source, target); + 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.run_steps(5); + let rect = h.state().ui.docking.layout.floating.iter().find(|g| g.panels.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.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); + 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(); + reloaded.run_steps(3); + reloaded.get_by_label("Trim (F6)").click(); + reloaded.run_steps(3); + assert_eq!(reloaded.state().engine.session().edit.tool, soundcraft_model::Tool::Trim); + assert!(!reloaded.state().engine.transport.playing); + let edit = serde_json::to_value(&reloaded.state().ui.docking).unwrap(); + reloaded.state_mut().ui.window = MainWindow::Mix; + reloaded.run_steps(5); + reloaded.state_mut().run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[900,70,220,420]})).unwrap(); + reloaded.run_steps(4); + assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), edit); + assert!(!reloaded.state().ui.mix_docking.layout.contains(&Panel::Clips)); + let record = reloaded.get_by_label("Record enable"); + // A real pointer click must remain outside the floating panel's occluding rectangle. + assert!(!egui::Rect::from_min_size(egui::pos2(900.0, 70.0), vec2(220.0, 420.0)).contains(record.rect().center())); + record.click(); + reloaded.run_steps(3); + assert!(reloaded.state().engine.session().tracks[0].mixer.record_arm); + assert!(!reloaded.state().engine.transport.recording); +} + +#[test] +fn invalid_docking_requests_are_atomic_in_edit_and_mix() { + let mut h = harness(None, 1400.0, 1.0); + for window in [MainWindow::Edit, MainWindow::Mix] { + h.state_mut().ui.window = window; + h.run_steps(4); + let before = serde_json::to_value(&h.state().ui).unwrap(); + for params in [ + json!({"operation":"close","panel":"canvas"}), + json!({"operation":"move","panel":"tracks","target":"canvas","zone":"center"}), + json!({"operation":"float","panel":"tracks","rect":[0,0,-1,100]}), + ] { + assert!(h.state_mut().run("ui.dock", params).is_err()); + assert_eq!(serde_json::to_value(&h.state().ui).unwrap(), before); + } + if window == MainWindow::Mix { + assert!(h.state_mut().run("ui.dock", json!({"operation":"open","panel":"midi"})).is_err()); + } + assert_eq!(h.state().engine.session().tracks.len(), 1); + } +} + +#[test] +fn docking_renders_edit_and_mix_default_floating_and_redocked() { + for width in [1000.0, 1400.0] { + for scale in [1.0, 2.0] { + for window in [MainWindow::Edit, MainWindow::Mix] { + let mut h = harness(Some(UiState { window, ..Default::default() }), width, scale); + h.state_mut().engine = soundcraft_engine::demo::demo_engine(); + for state in ["default", "floating", "redocked"] { + if state == "floating" { + h.state_mut().run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[100,80,260,500]})).unwrap(); + } else if state == "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(); + 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(); + } + } + } + } + } +} + +#[test] +fn reset_is_enabled_and_resolves_in_native_and_in_window_menu_trees() { + use soundcraft_ui_egui::menus; + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let path = "Window > Reset Panel Layout"; + let extra: Vec<_> = menus::UI_COMMANDS.iter().map(|(id, _, path, _)| (*path, *id)).collect(); + assert_eq!(menus::command_for_path(path, &extra).as_deref(), Some("ui.dock.reset")); + let tree = menus::tree(); + let window = tree.iter().find(|node| node.label == "Window").unwrap(); + assert!(window.children.iter().any(|node| node.path == path)); + 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))); +} + +#[test] +fn legacy_preferences_restore_the_theme_and_validate_default_docking() { + let mut value = serde_json::to_value(UiState { theme: soundcraft_ui_egui::theme::ThemeMode::Light, ..Default::default() }).unwrap(); + let object = value.as_object_mut().unwrap(); + object.remove("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(); +} diff --git a/crates/ui-egui/tests/midi_panel.rs b/crates/ui-egui/tests/midi_panel.rs index 45ec0ec..56cb12d 100644 --- a/crates/ui-egui/tests/midi_panel.rs +++ b/crates/ui-egui/tests/midi_panel.rs @@ -13,7 +13,7 @@ fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec) { } fn panel(ctx: &egui::Context) -> Rect { - egui::containers::panel::PanelState::load(ctx, Id::new("midi_editor")).unwrap().outer_rect + ctx.data(|data| data.get_temp::(Id::new("soundcraft-midi-body"))).unwrap() } fn button(pos: Pos2, pressed: bool) -> Event { @@ -39,8 +39,8 @@ fn check_resize(with_clip: bool) { // to catch a panel that looks resized but snaps back after release. for height in [440.0, 280.0, 200.0] { let before = panel(&ctx); - let start = pos2(before.center().x, before.top() + 1.0); - let end = pos2(start.x, before.bottom() - height); + let start = pos2(before.center().x, before.top() - 26.0 - 2.0); + let end = pos2(start.x, before.bottom() - height - 26.0 - 2.0); frame(&ctx, &mut app, vec![Event::PointerMoved(start)]); frame(&ctx, &mut app, vec![button(start, true)]); for step in 1..=6 { @@ -65,6 +65,12 @@ fn check_resize(with_clip: bool) { frame(&ctx, &mut app, vec![]); } assert_eq!(panel(&ctx), resized); + let saved = serde_json::to_string(&app.ui).unwrap(); + app.ui = serde_json::from_str(&saved).unwrap(); + for _ in 0..4 { + frame(&ctx, &mut app, vec![]); + } + assert_eq!(panel(&ctx), resized); assert_eq!(*app.engine.session(), notes); } @@ -77,3 +83,70 @@ fn midi_panel_resizes_with_a_selected_clip() { fn midi_panel_resizes_without_a_selected_clip() { check_resize(false); } + +#[test] +fn layout_reset_cancels_a_held_midi_divider_without_editing_the_session() { + check_held_replacement("reset"); +} + +#[test] +fn arrange_cancels_a_held_midi_divider_without_editing_the_session() { + check_held_replacement("arrange"); +} + +#[test] +fn legacy_visibility_sync_cancels_a_held_midi_divider_without_editing_the_session() { + check_held_replacement("visibility"); +} + +fn check_held_replacement(operation: &str) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.show_midi_editor = true; + app.run("edit.select", json!({"tracks":["Keys"]})).unwrap(); + app.ui.show_memory_locations = operation == "arrange"; + let session = app.engine.session().clone(); + let ctx = egui::Context::default(); + for _ in 0..6 { + frame(&ctx, &mut app, vec![]); + } + let initial = panel(&ctx); + let start = pos2(initial.center().x, initial.top() - 28.0); + frame(&ctx, &mut app, vec![Event::PointerMoved(start)]); + frame(&ctx, &mut app, vec![button(start, true)]); + let first = start - vec2(0.0, 80.0); + frame(&ctx, &mut app, vec![Event::PointerMoved(first)]); + frame(&ctx, &mut app, vec![]); + assert!((panel(&ctx).height() - 400.0).abs() < 1.0, "first drag must resize"); + let second = start - vec2(0.0, 120.0); + frame(&ctx, &mut app, vec![Event::PointerMoved(second)]); + frame(&ctx, &mut app, vec![]); + assert!((panel(&ctx).height() - 440.0).abs() < 1.0, "a normal second held move must continue resizing"); + match operation { + "reset" => { + app.run("ui.dock.reset", json!({})).unwrap(); + } + "arrange" => { + app.run("window.arrange_cascade", json!({})).unwrap(); + } + "visibility" => { + app.run("window.midi_editor", json!({})).unwrap(); + frame(&ctx, &mut app, vec![]); + app.run("window.midi_editor", json!({})).unwrap(); + frame(&ctx, &mut app, vec![]); + } + _ => panic!("unknown synthetic operation"), + } + let reset = serde_json::to_value(&app.ui.docking).unwrap(); + let after_reset = start - vec2(0.0, 160.0); + frame(&ctx, &mut app, vec![Event::PointerMoved(after_reset)]); + frame(&ctx, &mut app, vec![]); + assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset, "old held divider must not resize the replacement layout"); + let expected = if operation == "reset" { 320.0 } else { 440.0 }; + assert!((panel(&ctx).height() - expected).abs() < 1.0); + frame(&ctx, &mut app, vec![button(after_reset, false)]); + for _ in 0..4 { + frame(&ctx, &mut app, vec![]); + } + assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset); + assert_eq!(*app.engine.session(), session); +} diff --git a/crates/ui-egui/tests/shared_controls.rs b/crates/ui-egui/tests/shared_controls.rs new file mode 100644 index 0000000..71ae5de --- /dev/null +++ b/crates/ui-egui/tests/shared_controls.rs @@ -0,0 +1,217 @@ +//! Interaction and optional offscreen evidence for the application's painted controls. + +use egui::{Event, Key, Modifiers, PointerButton, Response, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use soundcraft_ui_egui::{Services, SoundApp, theme, widgets}; + +#[derive(Default)] +struct ButtonState { + record: bool, + selected: bool, + disabled: bool, + clicks: usize, + response: Option, +} + +impl ButtonState { + fn label(&self) -> &'static str { + if self.record { "Record enable" } else { "Loop playback" } + } +} + +fn button_frame(ctx: &egui::Context, state: &mut ButtonState, events: Vec) -> egui::FullOutput { + let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + let response = ui + .add_enabled_ui(!state.disabled, |ui| { + if state.record { + widgets::rec_toggle(ui, vec2(38.0, 28.0), state.selected, state.label()) + } else { + widgets::icon_button(ui, vec2(38.0, 28.0), "loop", state.selected, state.label()) + } + }) + .inner; + state.clicks += usize::from(response.clicked()); + state.response = Some(response); + }); + output.textures_delta.clear(); + output +} + +fn key(key: Key, pressed: bool) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE } +} + +fn pointer(pos: egui::Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +#[test] +fn tool_and_record_buttons_preserve_distinct_selected_colors_and_caller_state() { + for record in [false, true] { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let mut state = ButtonState { record, selected: true, ..Default::default() }; + button_frame(&ctx, &mut state, vec![]); + let center = state.response.as_ref().unwrap().rect.center(); + let out = button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true)]); + let expected = if record { theme::Tokens::current().rec } else { theme::Tokens::current().accent }; + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.fill == expected))); + if record { + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Circle(c) if c.fill == egui::Color32::WHITE))); + } + let update = out.platform_output.accesskit_update.unwrap(); + let node = &update.nodes.iter().find(|(_, n)| n.label() == Some(state.label())).unwrap().1; + assert_eq!(node.role(), egui::accesskit::Role::Button); + assert_eq!(node.toggled(), Some(egui::accesskit::Toggled::True)); + button_frame(&ctx, &mut state, vec![pointer(center, false)]); + assert_eq!(state.clicks, 1); + assert!(state.selected, "selection and record arming remain caller-owned"); + } +} + +#[test] +fn tool_and_record_buttons_handle_keyboard_disabled_state_and_cancelled_clicks() { + for record in [false, true] { + for key_code in [Key::Enter, Key::Space] { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let mut state = ButtonState { record, ..Default::default() }; + button_frame(&ctx, &mut state, vec![]); + button_frame(&ctx, &mut state, vec![]); + let response = state.response.clone().unwrap(); + ctx.memory_mut(|m| m.request_focus(response.id)); + let out = button_frame(&ctx, &mut state, vec![]); + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) + if r.rect == response.rect.shrink(2.0) && r.stroke.color == theme::Tokens::current().accent))); + button_frame(&ctx, &mut state, vec![key(key_code, true)]); + button_frame(&ctx, &mut state, vec![key(key_code, false)]); + assert_eq!(state.clicks, 1); + let center = response.rect.center(); + button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true)]); + button_frame(&ctx, &mut state, vec![Event::PointerMoved(pos2(300.0, 150.0))]); + button_frame(&ctx, &mut state, vec![pointer(pos2(300.0, 150.0), false)]); + assert_eq!(state.clicks, 1); + state.disabled = true; + button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true), key(key_code, true)]); + let out = button_frame(&ctx, &mut state, vec![pointer(center, false), key(key_code, false)]); + assert_eq!(state.clicks, 1); + assert!(!state.response.as_ref().unwrap().enabled()); + assert!(out.platform_output.accesskit_update.unwrap().nodes.iter().any(|(_, n)| n.label() == Some(state.label()) && n.is_disabled())); + } + } +} + +#[test] +fn toolbar_keyboard_activation_changes_tools_without_transport_or_marker_actions() { + let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h.get_by_label("Trim (F6)").click(); + h.run_steps(3); + assert_eq!(h.state().engine.session().edit.tool, soundcraft_model::Tool::Trim); + let markers = h.state().engine.session().markers.len(); + for (label, tool, key) in + [("Pencil (F10)", soundcraft_model::Tool::Pencil, Key::Enter), ("Grabber (F8)", soundcraft_model::Tool::Grabber, Key::Space)] + { + h.get_by_label(label).focus(); + h.run_steps(2); + assert!(h.get_by_label(label).is_focused()); + h.key_press(key); + h.run_steps(3); + assert_eq!(h.state().engine.session().edit.tool, tool); + assert!(!h.state().engine.transport.playing); + assert!(!h.state().engine.transport.recording); + assert_eq!(h.state().engine.session().markers.len(), markers); + } +} + +#[test] +fn track_record_button_arms_the_track_without_starting_transport() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("track.new", serde_json::json!({"name": "Voice"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h.get_by_label("Record enable").click(); + h.run_steps(3); + assert!(h.state().engine.session().tracks[0].mixer.record_arm); + for (key, armed) in [(Key::Enter, false), (Key::Space, true)] { + h.get_by_label("Record enable").focus(); + h.run_steps(2); + h.key_press(key); + h.run_steps(3); + assert_eq!(h.state().engine.session().tracks[0].mixer.record_arm, armed); + assert!(!h.state().engine.transport.playing); + assert!(!h.state().engine.transport.recording); + assert!(h.state().engine.session().markers.is_empty()); + } +} + +#[test] +fn shared_controls_visuals() { + let Some(directory) = std::env::var_os("CRAFT_UI_SUITE_SHOTS").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for scale in [1.0, 2.0] { + let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + for (name, size) in [("normal", vec2(1600.0, 1000.0)), ("narrow", vec2(1000.0, 700.0))] { + h.set_size(size); + h.run_steps(6); + h.render().unwrap().save(directory.join(format!("soundcraft-{name}-dark-{scale}x.png"))).unwrap(); + } + let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + |ui, rects: &mut Vec| { + theme::apply(ui.ctx(), false); + rects.clear(); + for enabled in [true, false] { + ui.add_enabled_ui(enabled, |ui| { + ui.horizontal(|ui| { + for selected in [false, true] { + rects.push(widgets::icon_button(ui, vec2(38.0, 28.0), "loop", selected, "Loop Playback").rect); + rects.push(widgets::icon_button(ui, vec2(36.0, 26.0), "trim", selected, "Trim").rect); + rects.push(widgets::rec_toggle(ui, vec2(30.0, 18.0), selected, "Record enable").rect); + } + }); + }); + } + }, + Vec::new(), + ); + controls.run_steps(4); + controls.render().unwrap().save(directory.join(format!("soundcraft-controls-dark-{scale}x.png"))).unwrap(); + for (name, index, pressed) in [("hover", 0, false), ("selected-hover", 3, false), ("disabled-hover", 6, false), ("pressed", 0, true)] { + let center = controls.state()[index].center(); + controls.event(Event::PointerMoved(center)); + if pressed { + controls.event(pointer(center, true)); + } + controls.run_steps(2); + controls.render().unwrap().save(directory.join(format!("soundcraft-controls-{name}-dark-{scale}x.png"))).unwrap(); + } + controls.event(pointer(controls.state()[0].center(), false)); + controls.event(Event::PointerGone); + controls.run_steps(2); + controls.key_press(Key::Tab); + controls.run_steps(2); + controls.render().unwrap().save(directory.join(format!("soundcraft-controls-focus-dark-{scale}x.png"))).unwrap(); + } +} diff --git a/crates/ui-egui/tests/theme_docking.rs b/crates/ui-egui/tests/theme_docking.rs new file mode 100644 index 0000000..ed32415 --- /dev/null +++ b/crates/ui-egui/tests/theme_docking.rs @@ -0,0 +1,83 @@ +//! Synthetic appearance checks for populated shared panels and custom controls. +use egui::{Color32, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, theme, widgets}; + +#[test] +fn shared_panels_and_controls_follow_manual_and_live_system_appearance() { + // Keep palette changes in one owning test: upstream custom painters use process-global tokens. + for scale in [1.0, 2.0] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("edit.select", json!({"tracks":["Keys"]})).unwrap(); + for id in ["window.midi_editor", "window.memory_locations", "window.undo_history"] { + app.run(id, json!({})).unwrap(); + } + let session = app.engine.session().clone(); + let mut h = Harness::builder().with_size(vec2(1400.0, 1000.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + for (name, mode, system, light) in [ + ("dark", theme::ThemeMode::Dark, egui::Theme::Light, false), + ("light", theme::ThemeMode::Light, egui::Theme::Dark, true), + ("system-light", theme::ThemeMode::System, egui::Theme::Light, true), + ("system-dark", theme::ThemeMode::System, egui::Theme::Dark, false), + ] { + h.state_mut().ui.theme = mode; + h.input_mut().system_theme = Some(system); + for (width_name, size) in [("normal", vec2(1400.0, 1000.0)), ("narrow", vec2(1000.0, 800.0))] { + h.set_size(size); + h.run_steps(5); + 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(); + 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(); + } + } + let ctx = egui::Context::default(); + theme::apply(&ctx, light); + let tokens = theme::Tokens::current(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + widgets::icon_button(ui, vec2(38.0, 28.0), "loop", true, "Loop playback"); + widgets::rec_toggle(ui, vec2(30.0, 18.0), true, "Record enable"); + ui.add_enabled_ui(false, |ui| widgets::icon_button(ui, vec2(38.0, 28.0), "loop", false, "Disabled loop")); + }); + output.textures_delta.clear(); + for expected in [tokens.accent, tokens.rec] { + assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Rect(rect) if rect.fill == expected))); + } + assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Circle(circle) if circle.fill == Color32::WHITE))); + if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) { + let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, _: &mut ()| { + theme::apply(ui.ctx(), light); + for enabled in [true, false] { + ui.add_enabled_ui(enabled, |ui| { + ui.horizontal(|ui| { + for selected in [false, true] { + widgets::icon_button(ui, vec2(38.0, 28.0), "loop", selected, "Loop playback"); + widgets::rec_toggle(ui, vec2(30.0, 18.0), selected, "Record enable"); + } + }); + }); + } + }, + (), + ); + controls.run_steps(4); + controls.render().unwrap().save(directory.join(format!("sound-controls-{name}-{scale}x.png"))).unwrap(); + controls.key_press(egui::Key::Tab); + controls.run_steps(2); + controls.render().unwrap().save(directory.join(format!("sound-controls-focus-{name}-{scale}x.png"))).unwrap(); + } + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 3432699..7145bd2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,6 +3,13 @@ SoundCraft is a Cargo workspace of small crates with enforced layering (`cargo xtask layers`). Nothing below the UI knows about egui, so the interface can be replaced. +The UI package pins `craft-ui` for common icon-button and track record-enable interactions. +SoundCraft supplies each state's colors, border, radius and icon painter; selection and command +dispatch remain in the toolbar and track controls. The single-shape frame option preserves the +existing fill/border blending when controls are disabled. Specialized transport Play/Stop/Record styling, +nonlinear meters, faders, pan gestures, fonts and the live theme resolver remain local. Shared adapters read the current palette. The UI +requires Rust 1.95 or later without raising the core workspace's Rust 1.90 declaration. + ``` L0 time audio-io midi (standalone: no workspace deps) L1 dsp clap-host (plugins; clap-host is the only unsafe crate) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a64c4c8..32a4932 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -12,6 +12,18 @@ input), and every command is reachable from: | MCP (headless) | `soundcraft-cli mcp [--demo]` | | MCP (bridged to the app) | `soundcraft-cli mcp --connect ` | +## Isolated automation profiles + +Set `SOUNDCRAFT_CONFIG_DIR` to a fresh directory to isolate UI preferences, `Autosave`, `Presets`, and `logs`. +The nonempty value is used as the complete settings directory. Unset retains the normal platform paths; +an explicit empty value disables the settings directory instead of falling back to a personal profile. Save synthetic sessions to explicit scratch paths. Do not use `SOUNDCRAFT_NO_PREFS` +when testing preference persistence across restarts. + +Run UI acceptance with `--no-audio`. This disables the output player; it does not disable recording +input opened by arm/monitor commands. An automated UI smoke run must never arm a track, enable input +monitoring, or start recording. Control input injects egui events; it does not establish operating-system +input delivery or screen-reader acceptance. Native screenshots use the presented viewport frame. + ## Wire format Newline-delimited JSON over TCP on `127.0.0.1`. One request per line, one reply per line: @@ -72,3 +84,22 @@ the native or web integration at launch and on later frames, while the saved cho When the integration cannot report an appearance, System uses Dark. Manual Light and Dark stay fixed when the OS appearance changes. An offscreen render without `--system-theme` supplies no OS appearance and therefore resolves System to Dark. + +## 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. + +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. + +Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tracks","rect":[100,100,320,440]}}`. + +Advanced `ui.dock` operations use the same validated transaction as pointer gestures: +`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and +`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`), +`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null. +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. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 9ccb8fe..f15b16c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -35,7 +35,7 @@ const LAYERS: &[(&str, u8)] = &[ ]; /// Standalone format crates: no workspace dependencies at all (publishable on their own). const STANDALONE: &[&str] = &["time", "audio-io", "midi", "video", "dsp"]; -const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "egui-wgpu", "egui_glow"]; +const UI_CRATES: &[&str] = &["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "egui-wgpu", "egui_glow"]; const EXEMPT: &[&str] = &["soundcraft", "soundcraft-cli", "soundcraft-web", "xtask"]; fn layers() -> Result<(), String> {