UI.9: preserve SoundCraft workflows with shared docking
This commit is contained in:
parent
3bc2d92b4e
commit
63b487589c
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
|||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]") };
|
||||
|
|
|
|||
|
|
@ -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<PathBuf> {
|
||||
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<OsString>) -> Option<PathBuf> {
|
||||
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<Option<soundcraft_ui_egui::UiState>, 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<PathBuf> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<std::path::PathBuf> {
|
||||
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<std::path::PathBuf> {
|
||||
|
|
@ -122,24 +114,24 @@ fn load_prefs() -> Option<UiState> {
|
|||
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<String> = 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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, String> {
|
||||
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<Panel> {
|
||||
Node::Tabs { panels: vec![panel], active: 0 }
|
||||
}
|
||||
|
||||
fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Panel>) -> Node<Panel> {
|
||||
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Workspace {
|
||||
pub layout: Layout<Panel>,
|
||||
/// Closed panels retain their most recent group, side, size or floating rectangle.
|
||||
pub hidden: Vec<(Panel, Location<Panel>)>,
|
||||
}
|
||||
|
||||
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<Panel>) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
if let Action::Close { panel } = &action {
|
||||
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
next.hidden.push((*panel, location));
|
||||
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
}
|
||||
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
|
||||
if self.layout.contains(&panel) == visible {
|
||||
return Ok(());
|
||||
}
|
||||
if !visible {
|
||||
return self.apply(Action::Close { panel });
|
||||
}
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
|
||||
next.hidden.retain(|(id, _)| *id != panel);
|
||||
if let Some(saved) = &mut location
|
||||
&& saved.floating.is_some()
|
||||
&& saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor)))
|
||||
{
|
||||
// The other members of a floating group may have been closed too.
|
||||
saved.anchor = None;
|
||||
saved.placement = Placement::Tab { before: None };
|
||||
}
|
||||
let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
|
||||
if !restored {
|
||||
next.layout
|
||||
.apply_with_permissions(
|
||||
Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
|
||||
permissions,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command(&mut self, params: &Value) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
if let Some(action) = params.get("action") {
|
||||
let action: Action<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
|
||||
return self.apply(action);
|
||||
}
|
||||
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
|
||||
if operation == "resizeSplit" {
|
||||
let path: Vec<bool> =
|
||||
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
|
||||
let size: SplitSize =
|
||||
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
|
||||
return self.apply(Action::ResizeSplit { path, size });
|
||||
}
|
||||
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
|
||||
let action = match operation {
|
||||
"open" => return self.set_visible(panel, true),
|
||||
"close" => Action::Close { panel },
|
||||
"activate" => Action::Activate { panel },
|
||||
"setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
|
||||
"resizeStack" => {
|
||||
let value = params.get("height").ok_or("height is required")?;
|
||||
let height = if value.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
value
|
||||
.as_f64()
|
||||
.filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
|
||||
.ok_or("height must be finite and bounded")? as f32,
|
||||
)
|
||||
};
|
||||
Action::ResizeStack { panel, height }
|
||||
}
|
||||
"float" | "moveFloating" => {
|
||||
let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
|
||||
let mut values = [0.0f32; 4];
|
||||
for (output, value) in values.iter_mut().zip(rect) {
|
||||
*output = value
|
||||
.as_f64()
|
||||
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
|
||||
.ok_or("rect values must be finite and bounded")? as f32;
|
||||
}
|
||||
if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
|
||||
}
|
||||
"move" => {
|
||||
let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
|
||||
let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
|
||||
"center" => Placement::Split(Zone::Center),
|
||||
"left" => Placement::Split(Zone::Left),
|
||||
"right" => Placement::Split(Zone::Right),
|
||||
"top" => Placement::Split(Zone::Top),
|
||||
"bottom" => Placement::Split(Zone::Bottom),
|
||||
_ => return Err("zone must be center, left, right, top or bottom".into()),
|
||||
};
|
||||
let placement = if let Some(before) = params.get("before") {
|
||||
if !matches!(placement, Placement::Split(Zone::Center)) {
|
||||
return Err("before only applies to a center move".into());
|
||||
}
|
||||
Placement::Tab {
|
||||
before: if before.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
|
||||
},
|
||||
}
|
||||
} else {
|
||||
placement
|
||||
};
|
||||
Action::Move { panel, anchor, placement }
|
||||
}
|
||||
_ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
|
||||
};
|
||||
self.apply(action)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Value, String> {
|
||||
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::<Panel>(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<Value, String> {
|
||||
if !edit && ["panel", "target"].iter().any(|field| matches!(params.get(*field).and_then(Value::as_str), Some("clips" | "midi"))) {
|
||||
return Err("Clips and MIDI panels belong to the Edit window".into());
|
||||
}
|
||||
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) }
|
||||
}
|
||||
|
|
@ -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::<f32>();
|
||||
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<String> = app.engine.session().edit.rulers.clone();
|
||||
|
|
|
|||
|
|
@ -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<bool>,
|
||||
/// Last panel host context, used to cancel renderer-owned gestures on explicit replacement.
|
||||
pub(crate) docking_context: Option<egui::Context>,
|
||||
/// Transport simulation when there is no player (tests, offscreen renders).
|
||||
sim: Option<(Samples, Option<Samples>, Option<Range>)>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<MenuNode> {
|
|||
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<String> {
|
|||
/// 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<Result<Value, String>> {
|
||||
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<Result<
|
|||
}
|
||||
"window.configurations" => toggle(&mut app.ui.show_configurations),
|
||||
"window.config_new" | "window.config_update" => {
|
||||
if let Err(error) = app.ui.docking.validate().and_then(|()| app.ui.mix_docking.validate_mix()) {
|
||||
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<Result<
|
|||
json!({"configurations": app.ui.configurations.len()})
|
||||
}
|
||||
"window.arrange_tile" | "window.arrange_tile_h" | "window.arrange_tile_v" | "window.arrange_cascade" => {
|
||||
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<Result<
|
|||
obj.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
match serde_json::from_value(cur) {
|
||||
match serde_json::from_value::<crate::UiState>(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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TrackId> = 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<TrackId> = 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() {
|
||||
|
|
|
|||
|
|
@ -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::<String>(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::<String>(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) {
|
||||
|
|
|
|||
|
|
@ -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<String, serde_json::Value>| {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<crate::UiState>(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);
|
||||
|
|
|
|||
|
|
@ -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<UiState>, width: f32, scale: f32) -> Harness<'static, SoundApp> {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.run("track.new", json!({"name":"Voice"})).unwrap();
|
||||
if let Some(state) = state {
|
||||
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();
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<Event>) {
|
|||
}
|
||||
|
||||
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::<Rect>(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Response>,
|
||||
}
|
||||
|
||||
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<Event>) -> 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<egui::Rect>| {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <port>` |
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue