collective

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

diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs--- a/apps/soundcraft/src/main.rs+++ b/apps/soundcraft/src/main.rs@@ -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)diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -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();diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -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),diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs--- a/crates/ui-egui/src/mix_window.rs+++ b/crates/ui-egui/src/mix_window.rs@@ -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) {diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs--- a/crates/ui-egui/src/shortcuts.rs+++ b/crates/ui-egui/src/shortcuts.rs@@ -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) =>         {diff --git a/crates/ui-egui/tests/it/mix_separate.rs b/crates/ui-egui/tests/it/mix_separate.rs--- a/crates/ui-egui/tests/it/mix_separate.rs+++ b/crates/ui-egui/tests/it/mix_separate.rs@@ -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");+}diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -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) |