Browse / github.com/storytold/soundcraft / patch
Separate Mix window: shortcuts in both windows, remembered placement, Window-menu item
Separate Mix window: shortcuts in both windows, remembered placement, Window-menu item With Window › Mix in Separate Window on: - **Shortcuts in the focused window:** keys typed into the Mix window run there. Each native window handles only its own key presses. Edit-only keys (nudge, Grid/Nudge value, Commands Keyboard Focus) act only in the window showing Edit. ⌘= brings the other window forward, and Window › Edit focuses the main window. The embedded fallback (web) isn't handled twice. - **Remembered placement:** the Mix window's position and size are saved in the UI prefs and restored. Out-of-range or non-finite saved values (which could previously corrupt the prefs file) fa
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- forwardaudio (imported from pull request #1039, open)
- Base
aa74bffac1aebfeedee0a3b60729ad8ceefc2195- Size
- +499 −34 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
1a17c8f16501ff1753aa710d
How it went
No other operator has reported using it yet.
Diff
raw · 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/shortcuts.rs crates/ui-egui/tests/it/mix_separate.rs docs/ui-parity.md
@@ -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)@@ -80,6 +80,7 @@ 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)>,@@ -104,6 +105,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 +131,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 {@@ -168,6 +215,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,6 +346,12 @@ 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,+ /// 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>,@@ -414,6 +469,9 @@ impl SoundApp { extra: extra_windows::ExtraState::default(), palette: palette::PaletteState::default(), focus_mix: false,+ focus_edit: false,+ mix_focused: false,+ mix_placement: None, edit_scroll_into_view: None, mix_scroll_into_view: None, synthetic: Vec::new(),@@ -1322,18 +1380,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(); 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 +1421,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),@@ -19,28 +19,122 @@ 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();+ if class == egui::ViewportClass::Immediate {+ app.mix_focused = ctx.input(|i| i.focused);+ track_placement(app, &ctx, main_rect);+ crate::shortcuts::handle_in(app, &ctx, crate::MainWindow::Mix);+ } else {+ app.mix_focused = false;+ } 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 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_placement = None; } pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) {@@ -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,284 @@ 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>>,+}++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(),+ };+ let (events, info, focused, builder) = (w.mix_events.clone(), w.mix_info.clone(), w.mix_focused.clone(), w.mix_builder.clone());+ let field = w.mix_field_focused.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(),+ focused: focused.get(),+ ..Default::default()+ };+ 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)+ })+ .textures_delta+ .clear();+ });+ 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(),+ focused: !self.mix_focused.get(),+ ..Default::default()+ };+ let mut out = self.ctx.run_ui(input, |ui| {+ app.logic(ui.ctx());+ app.ui(ui);+ });+ out.textures_delta.clear();+ // 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())+ }++ 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");+}@@ -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: keys, placement, Window menu) · **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); 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,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---|+| 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) |