Browse / github.com/storytold/soundcraft / patch
Mix window: plugin and Send windows open in the window they were asked from
Mix window: plugin and Send windows open in the window they were asked from **Depends on #1039** (separate Mix window); only the last two commits are new here. Draft until #1039 lands. With Window › Mix in Separate Window on, windows asked for from the Mix window opened in the Edit window, often behind the Mix window (e.g. a slot's Edit Parameters…). - Plugin parameter windows and Send windows asked for from the Mix window are now drawn in the Mix window; ones asked for from the Edit window stay there. Closing the Mix window (or turning the mode off) brings its windows back to the main window, and turning it on again returns them. - Samples dropped on the Mix window
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- forwardaudio (imported from pull request #1052, open)
- Base
aa74bffac1aebfeedee0a3b60729ad8ceefc2195- Size
- +922 −85 in 11 files
- Review
- pending
- Shared
- 2026-10-28
- Id
e36b52d0d41902c4954e8ce8
How it went
No other operator has reported using it yet.
Diff
raw · apps/soundcraft/src/config.rs apps/soundcraft/src/main.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/mix_window.rs crates/ui-egui/src/panels.rs crates/ui-egui/src/sampler_window.rs crates/ui-egui/src/shortcuts.rs crates/ui-egui/tests/it/mix_separate.rs crates/ui-egui/tests/it/sampler_window.rs docs/ui-parity.md
@@ -115,6 +115,7 @@ pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Resu ui.mix_docking.validate_mix()?; let mut saved = ui.clone(); saved.plugin_windows.clear();+ saved.mix_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 {@@ -142,6 +143,21 @@ pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Resu mod config_tests { use super::*; + #[test]+ fn open_plugin_windows_are_not_saved_with_the_preferences() {+ // They name the session's tracks, which the next session doesn't have.+ let directory = std::env::temp_dir().join(format!("soundcraft-plugin-window-prefs-{}", std::process::id()));+ let path = directory.join("ui.json");+ let _ = std::fs::remove_dir_all(&directory);+ let ui: soundcraft_ui_egui::UiState =+ serde_json::from_value(serde_json::json!({"plugin_windows": [[7, 0]], "mix_plugin_windows": [[7, 0]]})).unwrap();+ assert_eq!(ui.mix_plugin_windows.len(), 1);+ save_ui(&path, &ui).unwrap();+ let saved = load_ui(&path).unwrap().unwrap();+ assert!(saved.plugin_windows.is_empty() && saved.mix_plugin_windows.is_empty());+ let _ = std::fs::remove_dir_all(&directory);+ }+ #[test] fn native_file_reload_preserves_edit_mix_and_hidden_floating_panels() { use soundcraft_ui_egui::{MainWindow, Services, SoundApp, docking::Panel};@@ -79,7 +79,7 @@ impl eframe::App for App { "{}{} — SoundCraft — {}", self.0.engine.session().name, if self.0.engine.is_dirty() { " *" } else { "" },- if self.0.ui.window == soundcraft_ui_egui::MainWindow::Edit { "Edit" } else { "Mix" }+ if self.0.main_window_shows() == soundcraft_ui_egui::MainWindow::Edit { "Edit" } else { "Mix" } ) } else { format!("{} — SoundCraft", self.0.engine.session().name)@@ -53,6 +53,15 @@ pub enum MainWindow { Mix, } +/// The native window a floating window (a plugin or Send window) lives in.+#[derive(Debug, Clone, Copy, PartialEq, Eq)]+pub(crate) enum Host {+ /// The main window, which also draws a Mix window embedded in it.+ Main,+ /// The separate Mix window (Window › Mix in Separate Window).+ Mix,+}+ /// UI state (serde, so agents can read and set it). #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(default)]@@ -80,12 +89,20 @@ pub struct UiState { /// Edit window track-header columns: io, inserts_ae, sends_ae, comments, color. pub edit_views: Vec<String>, /// How strongly Edit window tracks take on their colour, 0..1 (0: only the colour strip).+ #[serde(deserialize_with = "saved::track_tint")] pub track_tint: f32, /// Open plugin windows (track, slot). pub plugin_windows: Vec<(TrackId, usize)>,+ /// The plugin windows (of `plugin_windows`) opened from the separate Mix window: they are+ /// drawn there while it is open, in the main window otherwise. Like `plugin_windows`, it can+ /// be read and set (`ui.set`) but is not saved with the preferences.+ pub mix_plugin_windows: Vec<(TrackId, usize)>, /// The send in the Send window (track, slot). #[serde(skip)] pub send_window: Option<(TrackId, usize)>,+ /// The Send window was opened from the separate Mix window (see `mix_plugin_windows`).+ #[serde(skip)]+ pub send_window_in_mix: bool, pub audiosuite: Option<String>, pub status: String, pub show_automation: bool,@@ -104,6 +121,12 @@ pub struct UiState { /// The Mix window lives in its own native window (Window › Mix in Separate Window); the main /// window then always shows Edit. pub mix_separate: bool,+ /// The separate Mix window's outer position and inner size (logical pixels) as last seen; it+ /// reopens there. No position: the system places it.+ #[serde(deserialize_with = "saved::mix_window_pos")]+ pub mix_window_pos: Option<[f32; 2]>,+ #[serde(deserialize_with = "saved::mix_window_size")]+ pub mix_window_size: [f32; 2], pub show_io_setup: bool, pub show_shortcuts: bool, pub show_clip_effects: bool,@@ -124,11 +147,51 @@ pub struct UiState { pub theme: theme::ThemeMode, /// The main window's size (logical points) and maximized state, and the interface scale, as /// the desktop app last saw them; it restores them at launch.+ #[serde(deserialize_with = "saved::window_size")] pub window_size: [f32; 2], pub window_maximized: bool,+ #[serde(deserialize_with = "saved::ui_scale")] pub ui_scale: f32, } +/// Reading saved sizes and scales. JSON reads a number too big for an `f32` (1e39) as infinity,+/// which serde_json writes back as `null`, and the whole preferences file would no longer load:+/// such values, and ones out of range, are replaced as they are read (preferences and `ui.set`).+mod saved {+ use serde::{Deserialize, Deserializer};++ /// `v` as an `f32`, if it is a finite one.+ fn finite(v: f64) -> Option<f32> {+ (v.is_finite() && v.abs() <= f64::from(f32::MAX)).then_some(v as f32)+ }++ fn pair([x, y]: [f64; 2]) -> Option<[f32; 2]> {+ Some([finite(x)?, finite(y)?])+ }++ pub fn track_tint<'de, D: Deserializer<'de>>(d: D) -> Result<f32, D::Error> {+ Ok(finite(f64::deserialize(d)?).map_or(0.5, |v| v.clamp(0.0, 1.0)))+ }++ pub fn ui_scale<'de, D: Deserializer<'de>>(d: D) -> Result<f32, D::Error> {+ Ok(finite(f64::deserialize(d)?).map_or(1.0, |v| v.clamp(0.75, 1.5)))+ }++ pub fn window_size<'de, D: Deserializer<'de>>(d: D) -> Result<[f32; 2], D::Error> {+ Ok(pair(<[f64; 2]>::deserialize(d)?).unwrap_or([1600.0, 1000.0]))+ }++ pub fn mix_window_pos<'de, D: Deserializer<'de>>(d: D) -> Result<Option<[f32; 2]>, D::Error> {+ let pos = Option::<[f64; 2]>::deserialize(d)?.and_then(pair);+ Ok(crate::mix_window::sane_placement(pos, crate::mix_window::DEFAULT_SIZE).0)+ }++ pub fn mix_window_size<'de, D: Deserializer<'de>>(d: D) -> Result<[f32; 2], D::Error> {+ let size = pair(<[f64; 2]>::deserialize(d)?).unwrap_or(crate::mix_window::DEFAULT_SIZE);+ Ok(crate::mix_window::sane_placement(None, size).1)+ }+}+ impl Default for UiState { fn default() -> Self { UiState {@@ -152,7 +215,9 @@ impl Default for UiState { edit_views: vec!["color".into()], track_tint: 0.5, plugin_windows: Vec::new(),+ mix_plugin_windows: Vec::new(), send_window: None,+ send_window_in_mix: false, audiosuite: None, status: String::new(), show_automation: false,@@ -168,6 +233,8 @@ impl Default for UiState { show_playback_engine: false, audio: soundcraft_playback::AudioConfig::default(), mix_separate: false,+ mix_window_pos: None,+ mix_window_size: mix_window::DEFAULT_SIZE, show_io_setup: false, show_shortcuts: false, show_clip_effects: false,@@ -297,19 +364,32 @@ pub struct SoundApp { pub palette: palette::PaletteState, /// Bring the separate Mix window to the front on the next frame. pub(crate) focus_mix: bool,+ /// Bring the main (Edit) window to the front on the next frame.+ pub(crate) focus_edit: bool,+ /// The separate Mix window has keyboard focus (as of its last frame).+ pub(crate) mix_focused: bool,+ /// The separate Mix window is a native window of its own (as of its last pass), not drawn+ /// inside the main one.+ pub(crate) mix_native: bool,+ /// Files dropped on the separate Mix window that none of its samplers took, handed to the+ /// shell with the main window's (`SoundApp::take_dropped_files`).+ pub(crate) mix_drops: Vec<egui::DroppedFileHandle>,+ /// Where the separate Mix window opened, while it is open (see `mix_window::show_separate`).+ pub(crate) mix_placement: Option<mix_window::Placement>, /// Scroll Into View: the track each window brings to its top (Edit) or left (Mix) when it /// next draws, so a window not on screen catches up when it is shown. pub(crate) edit_scroll_into_view: Option<TrackId>, pub(crate) mix_scroll_into_view: Option<TrackId>, pub synthetic: Vec<egui::Event>,- /// Where a dropped audio file loads into a track's sampler (last frame's rects).- pub sample_drop_targets: Vec<(egui::Rect, TrackId)>,+ /// Where a dropped audio file loads into a track's sampler: the window (viewport) and rect,+ /// as each window last drew them.+ pub sample_drop_targets: Vec<(egui::ViewportId, egui::Rect, TrackId)>, sample_reads: Vec<sampler_window::SampleRead>, synthetic_grace: u32, control_rx: Option<Receiver<ControlRequest>>, pending_shots: Vec<control::PendingShot>, /// An insert just picked from its menu, opened once the audio engine has its instance.- pub(crate) pending_open: Option<(TrackId, usize)>,+ pub(crate) pending_open: Option<(TrackId, usize, Host)>, last_rev: u64, fonts_ready: bool, fonts_installed: bool,@@ -414,6 +494,11 @@ impl SoundApp { extra: extra_windows::ExtraState::default(), palette: palette::PaletteState::default(), focus_mix: false,+ focus_edit: false,+ mix_focused: false,+ mix_native: false,+ mix_drops: Vec::new(),+ mix_placement: None, edit_scroll_into_view: None, mix_scroll_into_view: None, synthetic: Vec::new(),@@ -683,7 +768,7 @@ impl SoundApp { /// Opens the insert just picked from its menu: a hosted plugin's own editor, else (a built-in /// plugin, no audio engine, or an editor that can't open here) its plugin window. fn open_inserted(&mut self) {- let Some((track, slot)) = self.pending_open.take() else { return };+ let Some((track, slot, host)) = self.pending_open.take() else { return }; let Some(plugin) = self.engine.session().track(track).and_then(|t| t.mixer.inserts.get(slot).cloned().flatten()).map(|i| i.plugin) else { return; };@@ -696,11 +781,43 @@ impl SoundApp { Err(e) => self.ui.status = format!("{}: {e}", mix_window::plugin_info(&plugin).map_or(plugin.as_str(), |i| i.name)), } }- if !self.ui.plugin_windows.contains(&(track, slot)) {- self.ui.plugin_windows.push((track, slot));+ self.open_plugin_window(track, slot, host);+ }++ /// Opens the plugin window of `track`'s `slot` in `host`; one already open moves there.+ pub(crate) fn open_plugin_window(&mut self, track: TrackId, slot: usize, host: Host) {+ let w = (track, slot);+ if !self.ui.plugin_windows.contains(&w) {+ self.ui.plugin_windows.push(w);+ }+ self.ui.mix_plugin_windows.retain(|x| *x != w);+ if host == Host::Mix {+ self.ui.mix_plugin_windows.push(w); } } + /// The window `ctx` is drawing, where a floating window asked for from it opens: the separate+ /// Mix window, or the main window (also for a Mix window embedded in it).+ pub(crate) fn host_of(ctx: &egui::Context) -> Host {+ if ctx.viewport_id() == mix_window::viewport_id() { Host::Mix } else { Host::Main }+ }++ /// Where the floating windows asked for from the separate Mix window are drawn: there while it+ /// is a native window of its own, else (it is off, or embedded) in the main window.+ pub(crate) fn mix_hosts_floating(&self) -> bool {+ self.ui.mix_separate && self.mix_native+ }++ /// The window the plugin window of `w` (track, slot) is drawn in.+ pub(crate) fn plugin_window_host(&self, w: (TrackId, usize)) -> Host {+ if self.mix_hosts_floating() && self.ui.mix_plugin_windows.contains(&w) { Host::Mix } else { Host::Main }+ }++ /// The window the Send window is drawn in.+ pub(crate) fn send_window_host(&self) -> Host {+ if self.mix_hosts_floating() && self.ui.send_window_in_mix { Host::Mix } else { Host::Main }+ }+ /// Parameter edits made in plugin editors go into the session (one undo step per gesture). /// Hosted instruments keep their parameters in their own state, which the session takes /// instead, as it does for plugins that said their state changed: either way the session is@@ -1322,18 +1439,26 @@ impl SoundApp { self.arrange_request = false; ctx.memory_mut(|m| m.reset_areas()); }+ // The main window has the keys, so the Mix window no longer does (it learns that only on its next pass).+ if ctx.input(|i| i.focused) {+ self.mix_focused = false;+ } shortcuts::handle(self, &ctx);- self.sample_drop_targets.clear();+ self.sample_drop_targets.retain(|(v, ..)| *v != egui::ViewportId::ROOT); if !self.native_menu_bar { menus::menu_bar(self, ui); }- // With the Mix window in its own native window, the main window keeps Edit.- match if self.ui.mix_separate { MainWindow::Edit } else { self.ui.window } {+ match self.main_window_shows() { MainWindow::Edit => edit_window::show(self, ui), MainWindow::Mix => mix_window::show(self, ui), } if self.ui.mix_separate { mix_window::show_separate(self, &ctx);+ } else {+ mix_window::separate_hidden(self);+ }+ if std::mem::take(&mut self.focus_edit) {+ ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus); } panels::floating(self, &ctx); windows::show(self, &ctx);@@ -1355,6 +1480,11 @@ impl SoundApp { } } + /// What the main window shows: with the Mix window in its own native window, it keeps Edit.+ pub fn main_window_shows(&self) -> MainWindow {+ if self.ui.mix_separate { MainWindow::Edit } else { self.ui.window }+ }+ /// UI state as JSON (control channel `ui.inspect`). pub fn inspect(&self, ctx: &egui::Context) -> Value { let size = ctx.content_rect().size();@@ -12,7 +12,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ ("window.mix", "Mix", "Window > Mix", Some("Cmd+=")), ("window.edit", "Edit", "Window > Edit", Some("Cmd+=")), ("window.toggle_mix_edit", "Toggle Mix/Edit", "", Some("Cmd+=")),- ("window.mix_separate", "Mix in Separate Window", "", None),+ ("window.mix_separate", "Mix in Separate Window", "Window > Mix in Separate Window", None), ("window.transport", "Transport", "Window > Transport", Some("Cmd+1")), ("window.big_counter", "Big Counter", "Window > Big Counter", Some("Cmd+3")), ("window.memory_locations", "Memory Locations", "Window > Memory Locations", Some("Cmd+5")),@@ -203,6 +203,15 @@ pub fn tree() -> Vec<MenuNode> { insert_path(&mut roots, line); } insert_path(&mut roots, "Window > Reset Panel Layout");+ // Not in the observed menu catalog; it belongs with Mix and Edit.+ insert_path(&mut roots, "Window > Mix in Separate Window");+ if let Some(window) = roots.iter_mut().find(|n| n.label == "Window")+ && let Some(from) = window.children.iter().position(|n| n.path == "Window > Mix in Separate Window")+ && let Some(edit) = window.children.iter().position(|n| n.path == "Window > Edit")+ {+ let item = window.children.remove(from);+ window.children.insert((edit + 1).min(window.children.len()), item);+ } // Not in the observed catalog either; Pro Tools lists it right under Identify Sync Point. if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") { let at = clip.children.iter().position(|n| n.label == "Identify Sync Point").map_or(clip.children.len(), |i| i + 1);@@ -257,18 +266,6 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) { ui.separator(); ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim)); }- if root.label == "Window" {- ui.separator();- let mut separate = app.ui.mix_separate;- if ui- .checkbox(&mut separate, "Mix in Separate Window")- .on_hover_text("Show Edit and Mix at once, e.g. one per monitor")- .changed()- {- let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));- ui.close();- }- } }); } let title = format!(@@ -317,7 +314,11 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str let label = if checked { format!("✔ {label}") } else { label }; let shortcut = id.as_deref().and_then(shortcut_for).unwrap_or(""); let btn = egui::Button::new(label).shortcut_text(crate::shortcuts::shortcut_label(shortcut, ui.ctx().os().is_mac()));- if ui.add_enabled(enabled, btn).clicked()+ let mut response = ui.add_enabled(enabled, btn);+ if id.as_deref() == Some("window.mix_separate") {+ response = response.on_hover_text("Show Edit and Mix at once, e.g. one per monitor");+ }+ if response.clicked() && let Some(id) = id { invoke_menu(app, &id, &n.path);@@ -364,6 +365,7 @@ fn checked_state(app: &SoundApp, path: &str, id: Option<&str>) -> bool { Some("view.main_counter") => path.rsplit(" > ").next().map(ruler_id).is_some_and(|r| e.main_counter.id() == r), Some("window.mix") => app.ui.window == MainWindow::Mix, Some("window.edit") => app.ui.window == MainWindow::Edit,+ Some("window.mix_separate") => app.ui.mix_separate, Some("window.track_list") => app.ui.show_tracks_list, Some("window.clip_list_view") | Some("window.clip_list") => app.ui.show_clip_list, Some("window.narrow_mix") => app.ui.narrow_mix,@@ -529,16 +531,19 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result< } "window.edit" => { app.ui.window = MainWindow::Edit;+ app.focus_edit = app.ui.mix_separate; json!({"window": "edit"}) } "window.toggle_mix_edit" => { if app.ui.mix_separate {- // Both are on screen: bring the Mix window forward instead.- app.focus_mix = true;+ // Both are on screen: bring the other one forward instead (Edit from the Mix window).+ app.focus_edit = app.mix_focused;+ app.focus_mix = !app.mix_focused;+ json!({"window": if app.mix_focused { MainWindow::Edit } else { MainWindow::Mix }}) } else { app.ui.window = if app.ui.window == MainWindow::Edit { MainWindow::Mix } else { MainWindow::Edit };+ json!({"window": app.ui.window}) }- json!({"window": app.ui.window}) } "window.transport" => toggle(&mut app.ui.show_transport), "window.big_counter" => toggle(&mut app.ui.show_big_counter),@@ -626,6 +631,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result< app.ui.show_audio_health = false; app.ui.show_automation = false; app.ui.plugin_windows.clear();+ app.ui.mix_plugin_windows.clear(); app.ui.audiosuite = None; json!({}) }@@ -19,28 +19,138 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { crate::docking::show(app, ui, crate::MainWindow::Mix); } +/// The separate Mix window's size when nothing sensible was saved (logical pixels).+pub const DEFAULT_SIZE: [f32; 2] = [1100.0, 720.0];+const MIN_SIZE: [f32; 2] = [480.0, 320.0];++/// The separate Mix window's viewport.+pub fn viewport_id() -> egui::ViewportId {+ egui::ViewportId::from_hash_of("soundcraft.mix")+}++/// Where the separate Mix window opened (points). It is handed to egui unchanged on every frame:+/// a builder that changed would move the window back to it.+#[derive(Debug, Clone, Copy)]+pub(crate) struct Placement {+ pos: Option<egui::Pos2>,+ size: egui::Vec2,+ min_size: egui::Vec2,+ /// Frames drawn in the native window since it opened.+ passes: u32,+}++/// A saved Mix window position and size, or the defaults where the preferences hold nonsense+/// (NaN, negative or tiny sizes, positions far off any desktop such as Windows' -32000 for a+/// minimized window). Logical pixels.+pub fn sane_placement(pos: Option<[f32; 2]>, [w, h]: [f32; 2]) -> (Option<[f32; 2]>, [f32; 2]) {+ let size = if w.is_finite() && h.is_finite() && w >= MIN_SIZE[0] && h >= MIN_SIZE[1] { [w.min(16_384.0), h.min(16_384.0)] } else { DEFAULT_SIZE };+ (pos.filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 16_384.0)), size)+}+ /// The Mix window in its own native window (Window › Mix in Separate Window), so Edit and Mix can /// be seen at once, e.g. one per monitor. Closing that window brings Mix back into the main one.-/// Backends without multiple windows show it as a floating egui window instead.+/// It reopens where it was last left. Keyboard shortcuts typed into it are handled here, with the+/// Mix window as their context. Backends without multiple windows show it as a floating egui+/// window instead, whose keys the main window already handles. pub fn show_separate(app: &mut SoundApp, ctx: &egui::Context) { let title = format!("{} — Mix", app.engine.session().name);- let builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([1100.0, 720.0]).with_min_inner_size([480.0, 320.0]);- ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("soundcraft.mix"), builder, |ui, _class| {+ let zoom = ctx.zoom_factor();+ let placement = *app.mix_placement.get_or_insert_with(|| {+ let (pos, [w, h]) = sane_placement(app.ui.mix_window_pos, app.ui.mix_window_size);+ let min_size = egui::vec2(MIN_SIZE[0], MIN_SIZE[1]) / zoom;+ Placement { pos: pos.map(|[x, y]| egui::pos2(x, y) / zoom), size: egui::vec2(w, h) / zoom, min_size, passes: 0 }+ });+ let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size(placement.size).with_min_inner_size(placement.min_size);+ if let Some(pos) = placement.pos {+ builder = builder.with_position(pos);+ }+ let main_rect = ctx.input(|i| i.viewport().outer_rect);+ ctx.show_viewport_immediate(viewport_id(), builder, |ui, class| { ui.set_style(ui.ctx().global_style());+ let ctx = ui.ctx().clone();+ app.mix_native = class == egui::ViewportClass::Immediate;+ if app.mix_native {+ app.mix_focused = ctx.input(|i| i.focused);+ track_placement(app, &ctx, main_rect);+ // Files dropped here go to this window's samplers (as it last drew them), the rest to+ // the shell with the main window's.+ let rest = app.claim_dropped_samples(&ctx);+ if !rest.is_empty() {+ app.mix_drops.extend(rest);+ ctx.request_repaint_of(egui::ViewportId::ROOT);+ }+ crate::shortcuts::handle_in(app, &ctx, crate::MainWindow::Mix);+ } else {+ app.mix_focused = false;+ }+ app.sample_drop_targets.retain(|(v, ..)| *v != viewport_id()); if std::mem::take(&mut app.focus_mix) {- ui.ctx().send_viewport_cmd(egui::ViewportCommand::Focus);+ ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } show(app, ui);- if ui.ctx().input(|i| i.viewport().close_requested()) {+ if app.mix_native {+ // The plugin and Send windows opened from here. Drawn inside the main window, Mix+ // leaves them to the main window's pass.+ crate::panels::floating_in_mix(app, &ctx);+ }+ if ctx.input(|i| i.viewport().close_requested()) { separate_closed(app); } }); } +/// Remembers the native Mix window's position and size for the preferences, and on its second+/// frame (when the system has placed it) fixes a placement that no longer fits: a window on no+/// monitor (a saved position on a display that is gone) moves next to the main window, and one+/// bigger than its monitor is maximized.+fn track_placement(app: &mut SoundApp, ctx: &egui::Context, main_rect: Option<egui::Rect>) {+ let Some(placement) = app.mix_placement.as_mut() else { return };+ placement.passes = placement.passes.saturating_add(1);+ let (outer, inner, monitor, minimized, maximized, fullscreen) = ctx.input(|i| {+ let v = i.viewport();+ (v.outer_rect, v.inner_rect, v.monitor_size, v.minimized.unwrap_or(false), v.maximized.unwrap_or(false), v.fullscreen.unwrap_or(false))+ });+ if placement.passes == 2 {+ if outer.is_some() && monitor.is_none() {+ let to = main_rect.map_or(egui::pos2(48.0, 48.0), |r| r.min + egui::vec2(48.0, 48.0));+ ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(to));+ return;+ }+ if inner.zip(monitor).is_some_and(|(r, m)| (r.size() - m).max_elem() > 0.0) {+ ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));+ return;+ }+ }+ // A minimized, maximized or full-screen window keeps the placement it had before.+ if minimized || maximized || fullscreen {+ return;+ }+ // Logical pixels (points × zoom). Restored, they are read at the scale the new window starts+ // with: with monitors at different scales (Windows, X11) a window saved on the other monitor+ // reopens somewhat offset.+ let zoom = ctx.zoom_factor();+ if let Some(r) = outer {+ app.ui.mix_window_pos = Some((r.min.to_vec2() * zoom).round().into());+ }+ if let Some(r) = inner {+ app.ui.mix_window_size = (r.size() * zoom).round().into();+ }+}+ /// The separate Mix window was closed: Mix goes back into the main window. pub fn separate_closed(app: &mut SoundApp) { app.ui.mix_separate = false; app.ui.window = crate::MainWindow::Mix;+ separate_hidden(app);+}++/// The separate Mix window is not on screen: it no longer has the keys, and opens afresh from the+/// saved placement next time.+pub(crate) fn separate_hidden(app: &mut SoundApp) {+ app.mix_focused = false;+ app.mix_native = false;+ app.mix_placement = None;+ app.sample_drop_targets.retain(|(v, ..)| *v != viewport_id()); } pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) {@@ -512,8 +622,8 @@ pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: None => empty_slot(ui.painter(), r, resp.hovered(), Mark::Dot), } let hosted = ins.is_some_and(|i| soundcraft_mix::is_third_party(&i.plugin));- if resp.clicked() && hosted && !app.ui.plugin_windows.contains(&(id, slot)) {- app.ui.plugin_windows.push((id, slot));+ if resp.clicked() && hosted {+ app.open_plugin_window(id, slot, SoundApp::host_of(ui.ctx())); } let menu_resp = if hosted { resp.clone().on_hover_text("Click: open instrument · right-click: change") } else { resp.clone() }; let popup = if hosted { egui::Popup::context_menu(&menu_resp) } else { egui::Popup::menu(&menu_resp) };@@ -611,8 +721,8 @@ pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: if resp.clicked() && ins.is_some() { if ui.input(|i| i.modifiers.command) { let _ = app.run("mix.insert_bypass", json!({"track": id.0, "slot": slot}));- } else if !app.ui.plugin_windows.contains(&(id, slot)) {- app.ui.plugin_windows.push((id, slot));+ } else {+ app.open_plugin_window(id, slot, SoundApp::host_of(ui.ctx())); } } let cmd = if ui.ctx().os().is_mac() { "⌘" } else { "Ctrl+" };@@ -678,9 +788,7 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { if let Some(info) = current.as_ref().and_then(|i| plugin_info(&i.plugin)) { ui.label(egui::RichText::new(info.name).strong()); if current.as_ref().is_some_and(|i| !soundcraft_mix::is_third_party(&i.plugin)) && ui.button("Edit Parameters…").clicked() {- if !app.ui.plugin_windows.contains(&(id, slot)) {- app.ui.plugin_windows.push((id, slot));- }+ app.open_plugin_window(id, slot, SoundApp::host_of(ui.ctx())); ui.close(); } if let Some(p) = &app.player@@ -720,7 +828,7 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { /// it (`SoundApp::open_inserted`). fn pick_insert(app: &mut SoundApp, ui: &Ui, id: TrackId, slot: usize, plugin: &str) { match app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin})) {- Ok(_) => app.pending_open = Some((id, slot)),+ Ok(_) => app.pending_open = Some((id, slot, SoundApp::host_of(ui.ctx()))), Err(e) => app.ui.status = e, } ui.close();@@ -945,6 +1053,7 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us used_slot(ui.painter(), r, t.slot_face, mark, &name, color, Some(level)); let which = (b'A' + slot as u8) as char; let shown = app.ui.send_window == Some((id, slot));+ let in_mix = SoundApp::host_of(ui.ctx()) == crate::Host::Mix; let level_resp = ui.interact(level_r, ui.id().with(("send-value", id.0, slot)), Sense::click_and_drag()); level_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {which} level"))); if shown {@@ -954,7 +1063,10 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us if ui.input(|i| i.modifiers.alt) { let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0})); } else {- app.ui.send_window = (!shown).then_some((id, slot));+ // Open where it was clicked; open in the other window, it moves here.+ let here = shown && app.ui.send_window_in_mix == in_mix;+ app.ui.send_window = (!here).then_some((id, slot));+ app.ui.send_window_in_mix = in_mix; } } if level_resp.dragged() {@@ -1001,7 +1113,11 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us /// The Send window, as in Pro Tools: one send's fader, level, pan, Mute and Pre-fader. Clicking a /// send's level opens it, moves it to that send, or closes it, and so does Escape.-pub(crate) fn send_window(app: &mut SoundApp, ctx: &egui::Context) {+/// It is drawn by the window it was opened from (`host`: the pass drawing it).+pub(crate) fn send_window(app: &mut SoundApp, ctx: &egui::Context, host: crate::Host) {+ if app.send_window_host() != host {+ return;+ } // egui closes menus and drops text focus on Escape before this runs, so whether this Escape // was theirs comes from the last frame. A dialog or the search on top takes it too. let busy_id = egui::Id::new("send_window_escape");@@ -1,7 +1,7 @@ //! Side panels (Tracks, Groups, Clips) and floating windows. -use crate::SoundApp; use crate::theme::{Tokens, bold, mono, regular, rgb};+use crate::{Host, SoundApp}; use egui::{Align2, Color32, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{ClipId, SourceId};@@ -309,12 +309,18 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { pub fn floating(app: &mut SoundApp, ctx: &egui::Context) { transport_window(app, ctx); big_counter(app, ctx);- plugin_windows(app, ctx);- crate::mix_window::send_window(app, ctx);+ plugin_windows(app, ctx, Host::Main);+ crate::mix_window::send_window(app, ctx, Host::Main); about(app, ctx); session_info(app, ctx); } +/// The floating windows asked for from the separate Mix window, drawn in it (see `SoundApp::host_of`).+pub(crate) fn floating_in_mix(app: &mut SoundApp, ctx: &egui::Context) {+ plugin_windows(app, ctx, Host::Mix);+ crate::mix_window::send_window(app, ctx, Host::Mix);+}+ fn transport_window(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_transport; if !open {@@ -498,9 +504,10 @@ pub(crate) fn undo_history(app: &mut SoundApp, ui: &mut Ui) { }); } -fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {- let wins = app.ui.plugin_windows.clone();- let mut keep = Vec::new();+/// The plugin windows that live in `host`, the window this pass draws.+fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context, host: Host) {+ let wins: Vec<_> = app.ui.plugin_windows.iter().copied().filter(|w| app.plugin_window_host(*w) == host).collect();+ let mut closed = Vec::new(); for (tid, slot) in wins { // The instrument slot (an instrument track's instrument) or an insert slot. let instrument = slot == soundcraft_mix::INSTRUMENT_SLOT;@@ -508,9 +515,13 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { let ins = if instrument { t.instrument.clone() } else { t.mixer.inserts.get(slot).cloned().flatten() }; ins.map(|i| (t.name.clone(), i)) }) else {+ closed.push((tid, slot));+ continue;+ };+ let Some(info) = crate::mix_window::plugin_info(&ins.plugin) else {+ closed.push((tid, slot)); continue; };- let Some(info) = crate::mix_window::plugin_info(&ins.plugin) else { continue }; let place = if instrument { "instrument".to_string() } else { char::from(b'a'.saturating_add(slot as u8)).to_string() }; let mut open = true; let shown = egui::Window::new(format!("{tname} · {place} · {}", info.name))@@ -621,11 +632,12 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { { crate::sampler_window::drop_target(app, ctx, w.response.rect, tid); }- if open {- keep.push((tid, slot));+ if !open {+ closed.push((tid, slot)); } }- app.ui.plugin_windows = keep;+ app.ui.plugin_windows.retain(|w| !closed.contains(w));+ app.ui.mix_plugin_windows.retain(|w| !closed.contains(w) && app.ui.plugin_windows.contains(w)); } fn eq_curve(ui: &mut Ui, id: &str, ins: &soundcraft_model::Insert) {@@ -3,8 +3,8 @@ //! an instrument track. Markers and every other control go through `mix.instrument_param`, so //! they undo and script like any edit. -use crate::SoundApp; use crate::theme::{Tokens, mono, regular};+use crate::{Host, SoundApp}; use egui::{Align2, Color32, CursorIcon, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{Insert, TrackId};@@ -50,10 +50,10 @@ fn param(ins: &Insert, id: &str) -> f32 { ins.params.get(id).copied().unwrap_or(default) } -/// Remembers `r` as a place where a dropped audio file loads into track `t`'s sampler, and-/// outlines it while files are dragged over it.+/// Remembers `r` (in the window `ctx` draws) as a place where a dropped audio file loads into+/// track `t`'s sampler, and outlines it while files are dragged over it. pub fn drop_target(app: &mut SoundApp, ctx: &egui::Context, r: Rect, t: TrackId) {- app.sample_drop_targets.push((r, t));+ app.sample_drop_targets.push((ctx.viewport_id(), r, t)); let over = ctx.input(|i| !i.raw.hovered_files.is_empty() && i.pointer.latest_pos().is_some_and(|p| r.contains(p))); if over { ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new(("sample_drop", t.0)))).rect_stroke(@@ -72,6 +72,8 @@ pub fn drop_target(app: &mut SoundApp, ctx: &egui::Context, r: Rect, t: TrackId) pub struct SampleRead { pub track: TrackId, pub file: egui::DroppedFileHandle,+ /// The window it was dropped on, where the Sampler window opens.+ host: Host, } impl SampleRead {@@ -82,16 +84,26 @@ impl SampleRead { } impl SoundApp {- /// Takes this frame's dropped files: audio dropped on an instrument track or a Sampler window+ /// Takes this frame's files dropped on the main window (`ctx`), and those the separate Mix+ /// window was handed since: audio dropped on an instrument track or a Sampler window /// loads into that track's sampler (the sampler becomes its instrument). A file with a full /// path loads straight away; one without (the browser) waits in [`SoundApp::take_sample_reads`] /// for the shell to read. Everything else is returned for the shell to open or import. pub fn take_dropped_files(&mut self, ctx: &egui::Context) -> Vec<egui::DroppedFileHandle> {+ let mut rest = self.claim_dropped_samples(ctx);+ rest.append(&mut self.mix_drops);+ rest+ }++ /// Loads audio dropped on a sampler target of the window `ctx` draws (only its own targets:+ /// each window's drops come with its own pointer) and returns the other files.+ pub(crate) fn claim_dropped_samples(&mut self, ctx: &egui::Context) -> Vec<egui::DroppedFileHandle> { let (files, pos) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.latest_pos())); if files.is_empty() { return files; }- let Some(track) = self.drop_track(pos) else { return files };+ let host = SoundApp::host_of(ctx);+ let Some(track) = self.drop_track(ctx.viewport_id(), host, pos) else { return files }; let mut rest = Vec::new(); let mut claimed = false; for f in files {@@ -103,23 +115,25 @@ impl SoundApp { if f.path().is_absolute() { let path = f.path().to_string_lossy().into_owned(); let r = self.run("mix.sampler_load", json!({"track": track.0, "path": path}));- self.sample_loaded(track, &path, r);+ self.sample_loaded(track, host, &path, r); } else {- self.sample_reads.push(SampleRead { track, file: f });+ self.sample_reads.push(SampleRead { track, file: f, host }); } } rest } - /// The track a drop at `pos` loads into. Without a position (some platforms report none- /// during a drag), a single open Sampler window takes it.- fn drop_track(&self, pos: Option<egui::Pos2>) -> Option<TrackId> {+ /// The track a drop at `pos` in `viewport` (the window `host`) loads into. Without a position+ /// (some platforms report none during a drag), a single Sampler window open in it takes it.+ fn drop_track(&self, viewport: egui::ViewportId, host: Host, pos: Option<egui::Pos2>) -> Option<TrackId> { if let Some(p) = pos {- return self.sample_drop_targets.iter().rev().find(|(r, _)| r.contains(p)).map(|(_, t)| *t);+ return self.sample_drop_targets.iter().rev().find(|(v, r, _)| *v == viewport && r.contains(p)).map(|(.., t)| *t); } let s = self.engine.session();- let mut open = self.ui.plugin_windows.iter().filter(|(t, slot)| {- *slot == soundcraft_mix::INSTRUMENT_SLOT && s.track(*t).and_then(|x| x.instrument.as_ref()).is_some_and(|i| i.plugin == SAMPLER)+ let mut open = self.ui.plugin_windows.iter().filter(|&&(t, slot)| {+ slot == soundcraft_mix::INSTRUMENT_SLOT+ && self.plugin_window_host((t, slot)) == host+ && s.track(t).and_then(|x| x.instrument.as_ref()).is_some_and(|i| i.plugin == SAMPLER) }); match (open.next(), open.next()) { (Some((t, _)), None) => Some(*t),@@ -137,15 +151,15 @@ impl SoundApp { pub fn load_sample_bytes(&mut self, read: &SampleRead, bytes: Result<Vec<u8>, String>) { let name = read.name(); let r = bytes.and_then(|b| soundcraft_engine::io::load_sampler_bytes(&mut self.engine, read.track, &name, &b).map_err(|e| e.to_string()));- self.sample_loaded(read.track, &name, r);+ self.sample_loaded(read.track, read.host, &name, r); } - fn sample_loaded(&mut self, track: TrackId, what: &str, r: Result<serde_json::Value, String>) {+ /// Shows the outcome of a sample load; loaded, the Sampler window opens in `host`, the window+ /// the file was dropped on (one already open elsewhere moves there).+ fn sample_loaded(&mut self, track: TrackId, host: Host, what: &str, r: Result<serde_json::Value, String>) { match r { Ok(v) => {- if !self.ui.plugin_windows.contains(&(track, soundcraft_mix::INSTRUMENT_SLOT)) {- self.ui.plugin_windows.push((track, soundcraft_mix::INSTRUMENT_SLOT));- }+ self.open_plugin_window(track, soundcraft_mix::INSTRUMENT_SLOT, host); let name = v.get("name").and_then(|n| n.as_str()).unwrap_or(what); self.ui.status = format!("{name} loaded into the sampler"); }@@ -54,7 +54,17 @@ fn search_chord(m: Modifiers, mac: bool) -> bool { mac && m.command && m.ctrl } +/// The main window's keys (see [`handle_in`]). pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {+ let shown = app.main_window_shows();+ handle_in(app, ctx, shown);+}++/// Shortcuts typed into the window whose pass `ctx` is in, which shows `window`. Each native+/// window gets only its own key presses, so a press is handled once, by the window that has the+/// keyboard focus; Edit-only keys (nudge, Grid/Nudge value, Commands Keyboard Focus) act only+/// there when that window shows Edit.+pub fn handle_in(app: &mut SoundApp, ctx: &egui::Context, window: MainWindow) { // Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom. ctx.options_mut(|o| o.zoom_with_keyboard = false); if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() {@@ -72,7 +82,7 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { // Cmd and Ctrl are separate keys only on macOS (asked at runtime so the web build in a Mac browser agrees). let mac = ctx.os() == egui::os::OperatingSystem::Mac; for (key, mods) in events {- if fixed(app, key, mods, mac) {+ if fixed(app, key, mods, mac, window) { continue; } if let Some(id) = command_for(key, mods, mac) {@@ -98,7 +108,7 @@ fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> { } /// Keys with context-dependent meaning. Returns true when handled.-fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {+fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool, window: MainWindow) -> bool { let plain = !m.command && !m.alt && !m.ctrl && !m.shift; match key { Key::Space if !m.command && !m.alt => {@@ -123,7 +133,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { true } // Shift+=/- step the Grid value and Alt+Shift+=/- the Nudge value; macOS sends Alt+Shift+= as =, not +.- Key::Plus | Key::Equals | Key::Minus if m.shift && !m.command && !m.ctrl && app.ui.window == MainWindow::Edit => {+ Key::Plus | Key::Equals | Key::Minus if m.shift && !m.command && !m.ctrl && window == MainWindow::Edit => { let step = if key == Key::Minus { -1 } else { 1 }; let _ = app.run(if m.alt { "view.nudge" } else { "view.grid" }, json!({"step": step})); true@@ -165,7 +175,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { // `=` is what a bare press of the unlabeled +/- key reports (it has no shift held, so it // can never be `Key::Plus`); `Key::Plus` is kept for a keyboard that does send it (e.g. a // numpad +). `!m.alt` leaves Alt+= to `window.event_list`'s registered shortcut.- Key::Plus | Key::Equals | Key::Minus if !m.command && !m.alt && app.ui.window == MainWindow::Edit => {+ Key::Plus | Key::Equals | Key::Minus if !m.command && !m.alt && window == MainWindow::Edit => { let dir = if key == Key::Minus { -1 } else { 1 }; let _ = app.run("edit.nudge", json!({"direction": dir})); true@@ -213,7 +223,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { k if !m.command && !m.alt && !m.ctrl- && app.ui.window == MainWindow::Edit+ && window == MainWindow::Edit && app.engine.session().edit.keyboard_focus == "commands" && commands_focus(app, k, m.shift) => {@@ -1,9 +1,12 @@ //! Window › Mix in Separate Window: Edit stays in the main window, and closing the Mix window-//! brings Mix back into it.-use egui::vec2;+//! brings Mix back into it. Keys typed into either window act there, and the Mix window reopens+//! where it was left.+use egui::{Key, Modifiers, vec2}; use egui_kittest::Harness; use serde_json::json; use soundcraft_ui_egui::{MainWindow, Services, SoundApp, mix_window};+use std::cell::{Cell, RefCell};+use std::rc::Rc; #[test] fn mix_in_a_separate_window_toggles_and_closing_it_returns_to_the_main_window() {@@ -35,3 +38,531 @@ fn mix_in_a_separate_window_toggles_and_closing_it_returns_to_the_main_window() app.run("window.mix_separate", json!({"value": false})).unwrap(); assert!(!app.ui.mix_separate); }++#[test]+fn mix_in_separate_window_is_a_checked_item_of_the_window_menu() {+ use soundcraft_ui_egui::menus;+ let path = "Window > Mix in Separate Window";+ let tree = menus::tree();+ let window = tree.iter().find(|n| n.label == "Window").unwrap();+ let at = window.children.iter().position(|n| n.path == path).expect("in the Window menu (native and in-window)");+ assert_eq!(window.children.get(at.wrapping_sub(1)).map(|n| n.path.as_str()), Some("Window > Edit"));+ assert_eq!(menus::command_for_path(path, &menus::ui_aliases()).as_deref(), Some("window.mix_separate"));+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ assert_eq!(menus::item_state(&app, path, Some("window.mix_separate")), (true, false));+ menus::invoke_menu(&mut app, "window.mix_separate", path);+ assert!(app.ui.mix_separate);+ assert_eq!(menus::item_state(&app, path, Some("window.mix_separate")), (true, true));+ menus::invoke_menu(&mut app, "window.mix_separate", path);+ assert!(!app.ui.mix_separate);+}++/// A main window and the native Mix window, as a multi-window backend drives them: each window's+/// pass gets only its own input (`set_immediate_viewport_renderer` is how eframe does it).+struct TwoWindows {+ ctx: egui::Context,+ /// Input for the Mix window's next pass, and what it reported.+ mix_events: Rc<RefCell<Vec<egui::Event>>>,+ mix_info: Rc<RefCell<egui::ViewportInfo>>,+ mix_focused: Rc<Cell<bool>>,+ mix_commands: Rc<RefCell<Vec<egui::ViewportCommand>>>,+ mix_builder: Rc<RefCell<Option<egui::ViewportBuilder>>>,+ /// A widget in the Mix window holds the keyboard focus (a text field being typed into).+ mix_field_focused: Rc<Cell<bool>>,+ /// The labelled widgets and windows each window drew last (with `ctx.enable_accesskit()`).+ mix_nodes: Rc<RefCell<Vec<(String, egui::Rect)>>>,+ main_nodes: RefCell<Vec<(String, egui::Rect)>>,+ /// Files dropped on each window in its next pass, and those the app left to the shell.+ mix_dropped: Rc<RefCell<Vec<egui::DroppedFileHandle>>>,+ main_dropped: RefCell<Vec<egui::DroppedFileHandle>>,+ shell_drops: RefCell<Vec<egui::DroppedFileHandle>>,+}++/// The labels (and values) of the accessibility nodes a pass output, with their bounds.+fn labelled(update: Option<egui::accesskit::TreeUpdate>) -> Vec<(String, egui::Rect)> {+ let nodes = update.map(|u| u.nodes).unwrap_or_default();+ nodes+ .iter()+ .filter_map(|(_, n)| {+ let text = n.label().or(n.value())?.to_string();+ let b = n.bounds()?;+ Some((text, egui::Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32))))+ })+ .collect()+}++impl TwoWindows {+ fn new() -> Self {+ let ctx = egui::Context::default();+ ctx.set_os(egui::os::OperatingSystem::Mac);+ ctx.set_embed_viewports(false);+ let w = TwoWindows {+ ctx,+ mix_events: Rc::default(),+ mix_info: Rc::default(),+ mix_focused: Rc::new(Cell::new(true)),+ mix_commands: Rc::default(),+ mix_builder: Rc::default(),+ mix_field_focused: Rc::default(),+ mix_nodes: Rc::default(),+ main_nodes: RefCell::default(),+ mix_dropped: Rc::default(),+ main_dropped: RefCell::default(),+ shell_drops: RefCell::default(),+ };+ let (events, info, focused, builder) = (w.mix_events.clone(), w.mix_info.clone(), w.mix_focused.clone(), w.mix_builder.clone());+ let (field, nodes, dropped) = (w.mix_field_focused.clone(), w.mix_nodes.clone(), w.mix_dropped.clone());+ egui::Context::set_immediate_viewport_renderer(move |ctx, mut viewport| {+ let id = viewport.ids.this;+ *builder.borrow_mut() = Some(viewport.builder.clone());+ let input = egui::RawInput {+ viewport_id: id,+ viewports: std::iter::once((id, info.borrow().clone())).collect(),+ events: events.take(),+ dropped_files: dropped.take(),+ focused: focused.get(),+ ..Default::default()+ };+ let mut out = ctx.run_ui(input, |ui| {+ if field.get() {+ ui.memory_mut(|m| m.request_focus(egui::Id::new("mix text field")));+ }+ (viewport.viewport_ui_cb)(ui)+ });+ out.textures_delta.clear();+ *nodes.borrow_mut() = labelled(out.platform_output.accesskit_update);+ });+ w+ }++ /// One frame of the main window, with `events` typed into it; returns the main window's commands.+ fn frame(&self, app: &mut SoundApp, events: Vec<egui::Event>) -> Vec<egui::ViewportCommand> {+ let root =+ egui::ViewportInfo { outer_rect: Some(egui::Rect::from_min_size(egui::pos2(100.0, 80.0), vec2(1600.0, 1000.0))), ..Default::default() };+ let input = egui::RawInput {+ events,+ viewports: std::iter::once((egui::ViewportId::ROOT, root)).collect(),+ dropped_files: self.main_dropped.take(),+ focused: !self.mix_focused.get(),+ ..Default::default()+ };+ let mut out = self.ctx.run_ui(input, |ui| {+ app.logic(ui.ctx());+ // As the shell does: the files no sampler took open or import.+ self.shell_drops.borrow_mut().extend(app.take_dropped_files(ui.ctx()));+ app.ui(ui);+ });+ out.textures_delta.clear();+ *self.main_nodes.borrow_mut() = labelled(out.platform_output.accesskit_update.take());+ // The main window's pass carries the commands of the windows drawn inside it.+ if let Some(mix) = out.viewport_output.remove(&mix_window::viewport_id()) {+ self.mix_commands.borrow_mut().extend(mix.commands);+ }+ out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()+ }++ fn type_in_mix(&self, app: &mut SoundApp, key: Key, modifiers: Modifiers) -> Vec<egui::ViewportCommand> {+ self.mix_focused.set(true);+ *self.mix_events.borrow_mut() = key_press(key, modifiers);+ self.frame(app, Vec::new())+ }++ /// A click at `at` in the Mix window (`in_mix`) or the main window, over two frames.+ fn click(&self, app: &mut SoundApp, in_mix: bool, at: egui::Pos2) {+ let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };+ self.mix_focused.set(in_mix);+ for events in [vec![egui::Event::PointerMoved(at), button(true)], vec![button(false)]] {+ if in_mix {+ *self.mix_events.borrow_mut() = events;+ self.frame(app, Vec::new());+ } else {+ self.frame(app, events);+ }+ }+ self.frame(app, Vec::new());+ }++ /// Where the leftmost (then topmost) widget or window labelled `label` is in the Mix window or the+ /// main one: the first track's, in the Mix strips or the Edit window's rows.+ fn find(&self, in_mix: bool, label: impl Fn(&str) -> bool) -> Option<egui::Pos2> {+ let nodes = if in_mix { self.mix_nodes.borrow().clone() } else { self.main_nodes.borrow().clone() };+ nodes.iter().filter(|(l, _)| label(l)).map(|(_, r)| r.center()).min_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)))+ }++ /// Drops `file` at `at` in the Mix window (`in_mix`) or the main window.+ fn drop_file(&self, app: &mut SoundApp, in_mix: bool, at: egui::Pos2, file: egui::DroppedFileHandle) {+ if in_mix {+ *self.mix_events.borrow_mut() = vec![egui::Event::PointerMoved(at)];+ self.frame(app, Vec::new());+ self.mix_dropped.borrow_mut().push(file);+ self.frame(app, Vec::new());+ } else {+ self.frame(app, vec![egui::Event::PointerMoved(at)]);+ self.main_dropped.borrow_mut().push(file);+ self.frame(app, Vec::new());+ }+ self.frame(app, Vec::new());+ }++ fn type_in_main(&self, app: &mut SoundApp, key: Key, modifiers: Modifiers) -> Vec<egui::ViewportCommand> {+ self.mix_focused.set(false);+ self.frame(app, key_press(key, modifiers))+ }+}++fn key_press(key: Key, modifiers: Modifiers) -> Vec<egui::Event> {+ [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec()+}++fn separate_app() -> SoundApp {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ app.run("window.mix_separate", json!({"value": true})).unwrap();+ app+}++#[test]+fn shortcuts_work_in_the_window_that_has_the_keys_and_run_once() {+ let w = TwoWindows::new();+ let mut app = separate_app();+ for _ in 0..3 {+ w.frame(&mut app, Vec::new());+ }+ let cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };+ // Cmd+1 (Transport) typed into the Mix window toggles it exactly once; so does the main window.+ assert!(!app.ui.show_transport);+ w.type_in_mix(&mut app, Key::Num1, cmd);+ assert!(app.ui.show_transport, "a shortcut typed into the separate Mix window runs");+ w.type_in_main(&mut app, Key::Num1, cmd);+ assert!(!app.ui.show_transport, "and one typed into the main window runs once, not twice");++ // Nudge (bare =) is an Edit window key: not from the Mix window, but from the main window,+ // which shows Edit even after Window › Mix brought the Mix window forward.+ let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id;+ app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();+ let start = |app: &SoundApp| app.engine.session().find_clip(kick).unwrap().1.start;+ let original = start(&app);+ app.run("window.mix", json!({})).unwrap();+ w.type_in_mix(&mut app, Key::Equals, Modifiers::NONE);+ assert_eq!(start(&app), original, "Edit-only keys don't act from the Mix window");+ w.type_in_main(&mut app, Key::Equals, Modifiers::NONE);+ assert!(start(&app) > original, "the main window shows Edit, so its nudge key works");++ // Cmd+= goes to the other window: Edit from the Mix window, Mix from the main one.+ let focus = |c: &[egui::ViewportCommand]| c.contains(&egui::ViewportCommand::Focus);+ w.mix_commands.borrow_mut().clear();+ assert!(focus(&w.type_in_mix(&mut app, Key::Equals, cmd)), "Cmd+= in the Mix window brings Edit forward");+ w.type_in_main(&mut app, Key::Equals, cmd);+ w.frame(&mut app, Vec::new());+ assert!(focus(&w.mix_commands.borrow()), "Cmd+= in the main window brings Mix forward");+ assert!(app.ui.mix_separate);+}++#[test]+fn a_text_field_in_the_mix_window_keeps_its_keys() {+ let w = TwoWindows::new();+ let mut app = separate_app();+ for _ in 0..3 {+ w.frame(&mut app, Vec::new());+ }+ let cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };+ w.mix_field_focused.set(true);+ w.type_in_mix(&mut app, Key::Num1, cmd);+ assert!(!app.ui.show_transport, "typing into a Mix window field runs no shortcut");+ // The field goes away (egui drops the focus of a widget no longer drawn).+ w.mix_field_focused.set(false);+ for _ in 0..2 {+ w.frame(&mut app, Vec::new());+ }+ w.type_in_mix(&mut app, Key::Num1, cmd);+ assert!(app.ui.show_transport);+}++#[test]+fn a_mix_window_drawn_inside_the_main_one_handles_its_keys_once() {+ // Backends without native windows draw it as a window inside the main one, which shares the+ // main window's input: the main window's pass handles the keys, the Mix window's must not.+ let mut app = separate_app();+ let ctx = egui::Context::default();+ ctx.set_os(egui::os::OperatingSystem::Mac);+ let frame = |app: &mut SoundApp, events: Vec<egui::Event>| {+ ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {+ app.logic(ui.ctx());+ app.ui(ui);+ })+ .textures_delta+ .clear();+ };+ for _ in 0..3 {+ frame(&mut app, Vec::new());+ }+ frame(&mut app, key_press(Key::Num1, Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }));+ assert!(app.ui.show_transport, "Cmd+1 toggled Transport once, not on and off again");+}++#[test]+fn the_mix_window_reopens_where_it_was_left() {+ reopens_where_it_was_left(1.0);+ reopens_where_it_was_left(1.5);+}++fn reopens_where_it_was_left(zoom: f32) {+ let w = TwoWindows::new();+ w.ctx.set_zoom_factor(zoom);+ let mut app = separate_app();+ assert_eq!(app.ui.mix_window_pos, None);+ *w.mix_info.borrow_mut() = egui::ViewportInfo {+ outer_rect: Some(egui::Rect::from_min_size(egui::pos2(1800.0, 120.0), vec2(900.0, 640.0))),+ inner_rect: Some(egui::Rect::from_min_size(egui::pos2(1800.0, 148.0), vec2(900.0, 612.0))),+ monitor_size: Some(vec2(1920.0, 1080.0)),+ ..Default::default()+ };+ for _ in 0..3 {+ w.frame(&mut app, Vec::new());+ }+ // Saved in logical pixels: points × zoom.+ assert_eq!(app.ui.mix_window_pos, Some([1800.0 * zoom, 120.0 * zoom]));+ assert_eq!(app.ui.mix_window_size, [900.0 * zoom, 612.0 * zoom]);+ assert!(!w.mix_commands.borrow().iter().any(|c| matches!(c, egui::ViewportCommand::OuterPosition(_))), "an on-screen window stays put");++ // Saved with the preferences, restored at the next launch.+ let ui: soundcraft_ui_egui::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap();+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ app.ui = ui;+ for _ in 0..3 {+ w.frame(&mut app, Vec::new());+ }+ let builder = w.mix_builder.borrow().clone().unwrap();+ assert_eq!(builder.position, Some(egui::pos2(1800.0, 120.0)));+ assert_eq!(builder.inner_size, Some(vec2(900.0, 612.0)));+}++#[test]+fn a_mix_window_on_a_display_that_is_gone_moves_next_to_the_main_window() {+ let w = TwoWindows::new();+ let mut app = separate_app();+ app.ui.mix_window_pos = Some([5000.0, 300.0]);+ // The system puts the window where it was, but on no monitor.+ *w.mix_info.borrow_mut() = egui::ViewportInfo {+ outer_rect: Some(egui::Rect::from_min_size(egui::pos2(5000.0, 300.0), vec2(900.0, 640.0))),+ inner_rect: Some(egui::Rect::from_min_size(egui::pos2(5000.0, 328.0), vec2(900.0, 612.0))),+ monitor_size: None,+ ..Default::default()+ };+ for _ in 0..3 {+ w.frame(&mut app, Vec::new());+ }+ let moves: Vec<_> = w.mix_commands.borrow().iter().filter(|c| matches!(c, egui::ViewportCommand::OuterPosition(_))).cloned().collect();+ assert_eq!(moves, [egui::ViewportCommand::OuterPosition(egui::pos2(148.0, 128.0))]);+}++#[test]+fn nonsense_mix_window_placements_fall_back_to_the_defaults() {+ assert_eq!(mix_window::sane_placement(Some([10.0, 20.0]), [900.0, 600.0]), (Some([10.0, 20.0]), [900.0, 600.0]));+ assert_eq!(mix_window::sane_placement(Some([f32::NAN, 0.0]), [f32::INFINITY, 600.0]), (None, mix_window::DEFAULT_SIZE));+ assert_eq!(mix_window::sane_placement(Some([1e9, 0.0]), [10.0, -5.0]), (None, mix_window::DEFAULT_SIZE));+ assert_eq!(mix_window::sane_placement(None, [1e9, 1e9]), (None, [16_384.0, 16_384.0]));+ // Preferences from before the Mix window was remembered.+ let ui: soundcraft_ui_egui::UiState = serde_json::from_value(json!({"mix_separate": true})).unwrap();+ assert_eq!((ui.mix_window_pos, ui.mix_window_size), (None, mix_window::DEFAULT_SIZE));+}++#[test]+fn saved_numbers_too_big_for_the_preferences_get_their_defaults() {+ // JSON reads 1e39 into an f32 as infinity, which would be written back as `null` and make+ // the whole preferences file unreadable.+ let saved =+ json!({"mix_window_pos": [1e39, 0], "mix_window_size": [1e39, 600], "track_tint": 1e39, "window_size": [1e39, 1e39], "ui_scale": -1e39});+ let ui: soundcraft_ui_egui::UiState = serde_json::from_value(saved.clone()).unwrap();+ assert_eq!((ui.mix_window_pos, ui.mix_window_size), (None, mix_window::DEFAULT_SIZE));+ assert_eq!((ui.track_tint, ui.window_size, ui.ui_scale), (0.5, [1600.0, 1000.0], 1.0));+ let again: soundcraft_ui_egui::UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap();+ assert_eq!(again.mix_window_size, mix_window::DEFAULT_SIZE, "the preferences round-trip");++ // The control channel's ui.set goes through the same reading.+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ app.run("ui.set", saved).unwrap();+ assert_eq!((app.ui.mix_window_pos, app.ui.mix_window_size, app.ui.track_tint), (None, mix_window::DEFAULT_SIZE, 0.5));+ app.run("ui.set", json!({"track_tint": 2.0, "mix_window_pos": [-32000, -32000]})).unwrap();+ assert_eq!((app.ui.track_tint, app.ui.mix_window_pos), (1.0, None), "out of range: clamped, or the minimized-window sentinel dropped");+ app.run("ui.set", json!({"show_transport": true})).expect("later ui.set calls still round-trip");+}++#[test]+fn plugin_and_send_windows_open_in_the_window_they_were_asked_from() {+ let w = TwoWindows::new();+ w.ctx.enable_accesskit();+ let mut app = separate_app();+ app.run("view.edit_inserts_ae", json!({})).unwrap();+ let first = app.engine.session().tracks.first().map(|t| (t.id, t.name.clone())).unwrap();+ app.run("mix.insert", json!({"track": first.0.0, "slot": 0, "plugin": "eq_7band"})).unwrap();+ for _ in 0..3 {+ w.frame(&mut app, Vec::new());+ }+ let title = format!("{} · a · ", first.1);+ let window = |l: &str| l.starts_with(&title);++ // Clicking an insert in the Mix window opens its plugin window there, not in the main window.+ let insert = w.find(true, |l| l == "Insert A").expect("the Mix window shows inserts");+ w.click(&mut app, true, insert);+ assert_eq!(app.ui.plugin_windows, [(first.0, 0)]);+ assert!(w.find(true, window).is_some(), "the plugin window opened in the Mix window");+ assert!(w.find(false, window).is_none(), "and not in the main window");++ // With the Mix window back inside the main one, so is the plugin window; turned on again,+ // the plugin window goes back with it.+ app.run("window.mix_separate", json!({"value": false})).unwrap();+ w.frame(&mut app, Vec::new());+ assert!(w.find(false, window).is_some(), "Mix in the main window: the plugin window is there");+ app.run("window.mix_separate", json!({"value": true})).unwrap();+ // A window reopened in a new native window takes a few frames to find its size.+ for _ in 0..4 {+ w.frame(&mut app, Vec::new());+ }+ assert!(w.find(true, window).is_some() && w.find(false, window).is_none(), "back in the separate Mix window");++ // Its close button works there.+ let close = w.find(true, |l| l == "Close window").expect("a close button");+ w.click(&mut app, true, close);+ assert!(app.ui.plugin_windows.is_empty() && app.ui.mix_plugin_windows.is_empty(), "closed from the Mix window");++ // The same insert clicked in the Edit window opens it in the main window; clicked in the Mix+ // window again, the open window moves there.+ let insert_in_edit = w.find(false, |l| l == "Insert A").expect("the Edit window shows inserts");+ w.click(&mut app, false, insert_in_edit);+ assert!(w.find(false, window).is_some() && w.find(true, window).is_none(), "opened from Edit: in the main window");+ w.click(&mut app, true, insert);+ assert!(w.find(true, window).is_some() && w.find(false, window).is_none(), "asked for from Mix: moved there");+ let close = w.find(true, |l| l == "Close window").unwrap();+ w.click(&mut app, true, close);++ // A send's level opens the Send window in the window it was clicked in, too.+ let send = w.find(true, |l| l == "Send A level").expect("a send");+ w.click(&mut app, true, send);+ let send_window = |l: &str| l.contains(" · Send A");+ assert!(app.ui.send_window.is_some());+ assert!(w.find(true, send_window).is_some() && w.find(false, send_window).is_none(), "the Send window opened in the Mix window");+ w.click(&mut app, true, send);+ assert!(app.ui.send_window.is_none(), "clicking the send again closes it");+}++#[test]+fn plugin_windows_of_a_mix_window_inside_the_main_one_are_drawn_there() {+ // Backends without native windows: the Mix window and its plugin windows are in the main one.+ // So without a renderer for native windows, even where egui is not told to embed them.+ for embed in [true, false] {+ plugin_windows_drawn_in_the_main_window(embed);+ }+}++fn plugin_windows_drawn_in_the_main_window(embed: bool) {+ let mut app = separate_app();+ let kick = app.engine.session().track_by_name("Kick").unwrap().id;+ app.ui.plugin_windows = vec![(kick, 0)];+ app.ui.mix_plugin_windows = vec![(kick, 0)];+ let ctx = egui::Context::default();+ ctx.set_embed_viewports(embed);+ ctx.enable_accesskit();+ let mut nodes = Vec::new();+ for _ in 0..3 {+ let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {+ app.logic(ui.ctx());+ app.ui(ui);+ });+ out.textures_delta.clear();+ nodes = labelled(out.platform_output.accesskit_update);+ }+ assert!(nodes.iter().any(|(l, _)| l.starts_with("Kick · a · ")), "the plugin window is drawn (embed: {embed})");+ assert_eq!(app.ui.plugin_windows, [(kick, 0)], "and stays open");+}++#[test]+fn plugin_and_send_windows_come_back_to_the_main_window_when_the_mix_window_is_closed() {+ let w = TwoWindows::new();+ w.ctx.enable_accesskit();+ let mut app = separate_app();+ let kick = app.engine.session().track_by_name("Kick").unwrap().id;+ app.ui.plugin_windows = vec![(kick, 0)];+ app.ui.mix_plugin_windows = vec![(kick, 0)];+ let sends = app.engine.session().tracks.iter().find(|t| t.mixer.sends.first().is_some_and(Option::is_some)).map(|t| t.id).unwrap();+ app.ui.send_window = Some((sends, 0));+ app.ui.send_window_in_mix = true;+ for _ in 0..3 {+ w.frame(&mut app, Vec::new());+ }+ let plugin = |l: &str| l.starts_with("Kick · a · ");+ let send = |l: &str| l.contains(" · Send A");+ assert!(w.find(true, plugin).is_some() && w.find(true, send).is_some(), "both in the Mix window");+ assert!(w.find(false, plugin).is_none() && w.find(false, send).is_none());++ // The system's close button: Mix comes back into the main window, and so do they.+ w.mix_info.borrow_mut().events = vec![egui::ViewportEvent::Close];+ w.frame(&mut app, Vec::new());+ w.mix_info.borrow_mut().events.clear();+ assert!(!app.ui.mix_separate && app.ui.window == MainWindow::Mix, "closing the Mix window shows Mix in the main one");+ for _ in 0..2 {+ w.frame(&mut app, Vec::new());+ }+ assert!(w.find(false, plugin).is_some() && w.find(false, send).is_some(), "both in the main window");+ assert_eq!((app.ui.plugin_windows.as_slice(), app.ui.send_window), ([(kick, 0)].as_slice(), Some((sends, 0))), "and still open");++ // Turned on again, they go back with it.+ app.run("window.mix_separate", json!({"value": true})).unwrap();+ for _ in 0..4 {+ w.frame(&mut app, Vec::new());+ }+ assert!(w.find(true, plugin).is_some() && w.find(true, send).is_some(), "back in the separate Mix window");+ assert!(w.find(false, plugin).is_none() && w.find(false, send).is_none());+}++#[test]+fn a_sample_loads_where_it_was_dropped_and_its_window_opens_there() {+ use crate::sampler_window::{Dropped, wav};+ use std::sync::Arc;+ let w = TwoWindows::new();+ let mut app = separate_app();+ let keys = app.engine.session().track_by_name("Keys").unwrap().id;+ app.run("mix.instrument", json!({"track": keys.0, "plugin": "sampler"})).unwrap();+ let sampler = (keys, soundcraft_mix::INSTRUMENT_SLOT);+ app.ui.plugin_windows = vec![sampler];+ app.ui.mix_plugin_windows = vec![sampler];+ for _ in 0..3 {+ w.frame(&mut app, Vec::new());+ }+ let target = |app: &SoundApp, v: egui::ViewportId| app.sample_drop_targets.iter().find(|(at, _, t)| *at == v && *t == keys).map(|(_, r, _)| *r);+ let at = target(&app, mix_window::viewport_id()).expect("the Sampler window in the Mix window is a drop target").center();+ let file = |name: &str| -> egui::DroppedFileHandle { Arc::new(Dropped { path: name.into(), bytes: wav(0.1) }) };++ // Dropped in the main window where the Mix window's Sampler is: not for that Sampler.+ let edit_track = app.sample_drop_targets.iter().rev().find(|(v, r, _)| *v == egui::ViewportId::ROOT && r.contains(at)).map(|(.., t)| *t);+ w.drop_file(&mut app, false, at, file("main.wav"));+ let reads = app.take_sample_reads();+ assert_eq!(reads.iter().map(|r| r.track).collect::<Vec<_>>(), Vec::from_iter(edit_track), "only what the main window shows there takes it");+ assert_eq!(w.shell_drops.take().len(), usize::from(edit_track.is_none()), "else it is the shell's");++ // Dropped on the Sampler window in the Mix window: it loads there.+ w.drop_file(&mut app, true, at, file("mix.wav"));+ let reads = app.take_sample_reads();+ assert_eq!(reads.iter().map(|r| r.track).collect::<Vec<_>>(), [keys]);+ for r in &reads {+ app.load_sample_bytes(r, r.file.bytes());+ }+ assert!(app.ui.mix_plugin_windows.contains(&sampler), "its window stays in the Mix window");++ // Dropped on Keys' lane in the Edit window while the Mix window last had the keys: the+ // Sampler window comes to the main window, where the file was dropped.+ let lane = target(&app, egui::ViewportId::ROOT).expect("Keys' lane in the Edit window is a drop target").center();+ w.mix_focused.set(true);+ w.drop_file(&mut app, false, lane, file("lane.wav"));+ let reads = app.take_sample_reads();+ assert_eq!(reads.iter().map(|r| r.track).collect::<Vec<_>>(), [keys]);+ for r in &reads {+ app.load_sample_bytes(r, r.file.bytes());+ }+ assert_eq!(app.ui.plugin_windows, [sampler]);+ assert!(app.ui.mix_plugin_windows.is_empty(), "the Sampler window moved to the main window");++ // Other files dropped on the Mix window go to the shell, as from the main window.+ w.drop_file(&mut app, true, at, file("song.scraft"));+ assert_eq!(w.shell_drops.take().iter().map(|f| f.path().to_string_lossy().into_owned()).collect::<Vec<_>>(), ["song.scraft"]);+ assert!(app.take_sample_reads().is_empty());+}@@ -11,9 +11,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; #[derive(Debug)]-struct Dropped {- path: PathBuf,- bytes: Vec<u8>,+pub(crate) struct Dropped {+ pub(crate) path: PathBuf,+ pub(crate) bytes: Vec<u8>, } impl DroppedFile for Dropped {@@ -25,7 +25,7 @@ impl DroppedFile for Dropped { } } -fn wav(secs: f32) -> Vec<u8> {+pub(crate) fn wav(secs: f32) -> Vec<u8> { let n = (48_000.0 * secs) as usize; let one: Vec<f32> = (0..n).map(|i| 0.4 * (std::f32::consts::TAU * 220.0 * i as f32 / 48_000.0).sin()).collect(); let buf = AudioBuffer { sample_rate: 48_000, channels: vec![one; 2] };@@ -66,7 +66,7 @@ fn instrument(h: &Harness<'_, SoundApp>, t: TrackId) -> soundcraft_model::Insert /// Drops `file` with the pointer at the centre of the Keys sampler window. fn drop_on_window(h: &mut Harness<'_, SoundApp>, keys: TrackId, file: Dropped) {- let r = h.state().sample_drop_targets.iter().rev().find(|(_, t)| *t == keys).map(|(r, _)| *r).expect("the sampler window is a drop target");+ let r = h.state().sample_drop_targets.iter().rev().find(|(.., t)| *t == keys).map(|(_, r, _)| *r).expect("the sampler window is a drop target"); h.state_mut().synthetic.push(Event::PointerMoved(r.center())); for _ in 0..3 { step(h);@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (Scroll Into View in the track name menu) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (separate Mix window: plugin and Send windows open in it) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command@@ -43,7 +43,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Window | Status | Notes | |---|---|---| | Edit window: rulers, track headers, view columns, Clip List, Groups, Tracks list | ✅ | Track Pin (new in 2026.4) ✅ (`track.pin`) |-| Mix window: strips, views (inserts, sends, I/O, comments, EQ curve, preamps, instruments, object) | ✅ | Narrow Mix ✅; Mix and Edit on screen at once (Window › Mix in Separate Window, its own native window) ✅; Delay Compensation view and HEAT missing |+| Mix window: strips, views (inserts, sends, I/O, comments, EQ curve, preamps, instruments, object) | ✅ | Narrow Mix ✅; Mix and Edit on screen at once (Window › Mix in Separate Window, its own native window, a checked item of the Window menu, also the macOS menu bar) ✅: shortcuts work in whichever window has the keys (Edit-only keys such as nudge only in the Edit window; Cmd+= brings the other window forward), and it reopens where it was left (moved next to Edit if its display is gone, as far as the system reports that: macOS yes, Windows no; with monitors at different scales on Windows/X11 it can reopen somewhat offset); plugin and Send windows open in the window they were asked from (the Sampler window, when a sample loads: the window the file was dropped on; `ui.set`: the main window, or the Mix window for those also in `mix_plugin_windows`) and go back into the main window with Mix; Delay Compensation view and HEAT missing | | MIDI Editor, Score Editor, Event List | 🟡 | Dock height bug fixed (#113); notation editing shallow | | Transport, Big Counter, Memory Locations, Automation, Video, Universe, Renderer, UI Customization | ✅ present | | | Workspace / sound browser with previews | ❌ | G-16 |@@ -69,6 +69,8 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Separate Mix window: plugin and Send windows opened from it open in it, not behind it in the main window. Readiness estimate unchanged |+| 2026-10-24 | minor | Separate Mix window: shortcuts work while it has the keys, its position and size are saved, and it is in the Window menu on the macOS menu bar. Readiness estimate unchanged | | 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged | | 2026-10-13 | minor | A plugin opens when it's inserted (#486) | | 2026-10-13 | minor | Drag across checkboxes (#399) |