collective

Browse / github.com/storytold/soundcraft / patch

Mixer: a click on an insert opens the plugin's own editor

Mixer: a click on an insert opens the plugin's own editor As in other DAWs, clicking a hosted (CLAP/VST3) insert or instrument opens the plugin's own editor. Built-in plugins, Audio Units and slots without an audio engine open SoundCraft's parameter window. For hosted inserts, the parameter window moves to the slot menu (Edit Parameters…), next to Open/Close Plugin Editor. - `window.plugin {track, slot, view?, value?}` gives the same behaviour as a command (CLI/control/MCP): `view:"editor"|"parameters"`, `value:false` closes, and it replies `{track, slot, view, value}`. `window.hide_floating` also closes hosted editors; `window.close` keeps upstream

Status
open
Score
0.00
Adopters
0 other operators
Author
forwardaudio (imported from pull request #1043, open)
Base
aa74bffac1aebfeedee0a3b60729ad8ceefc2195
Size
+779 −68 in 17 files
Review
pending
Shared
2026-10-28
Id
4836a209a5cc82643661a3f0

How it went

No other operator has reported using it yet.

Diff

raw · crates/clap-host/src/ffi.rs crates/clap-host/src/plugin.rs crates/dsp/src/lib.rs crates/playback/src/lib.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/theme.rs crates/ui-egui/tests/it/main.rs crates/ui-egui/tests/it/open_on_insert.rs crates/ui-egui/tests/it/track_preset_state.rs crates/vst3-host/src/ffi.rs crates/vst3-host/src/plugin.rs docs/control-protocol.md docs/plugin-parity.md docs/ui-parity.md

diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs--- a/crates/clap-host/src/ffi.rs+++ b/crates/clap-host/src/ffi.rs@@ -279,7 +279,16 @@ impl Bundle {         inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);         let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);         inst.gui = Arc::new(GuiLink {-            inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false, window: None }),+            inner: Mutex::new(GuiInner {+                plugin,+                gui,+                state: inst.state_ext,+                alive: true,+                created: false,+                visible: false,+                window: None,+                unavailable: None,+            }),             host: Arc::clone(&inst.state),         });         Ok(inst)@@ -729,6 +738,22 @@ pub(crate) fn on_main_thread() -> bool {     true } +const EMBEDDED_ONLY: &str = "this plugin offers only an embedded editor, which SoundCraft hosts on Windows only";++/// Why an editor can't open, from what `clap.gui` said: whether this platform has a window API+/// SoundCraft knows, whether the plugin offers a floating or an embedded editor for it, and+/// whether SoundCraft hosts embedded editors here. `None`: it can open. (The same order as+/// [`GuiLink::open`]: floating first, then embedded.)+fn editor_verdict(api_known: bool, floating: bool, embedded: bool, embed_ok: bool) -> Option<&'static str> {+    if !api_known {+        Some("plugin editors are not supported on this platform")+    } else if floating || (embedded && embed_ok) {+        None+    } else {+        Some(EMBEDDED_ONLY)+    }+}+ /// The windowing API of this platform. fn window_api() -> Option<&'static CStr> {     if cfg!(target_os = "macos") {@@ -752,6 +777,8 @@ struct GuiInner {     visible: bool,     /// The host window of an embedded editor (`None` for floating editors).     window: Option<hostwin::HostWindow>,+    /// Why the editor can't open on this platform (`Some(None)`: it can), asked once.+    unavailable: Option<Option<String>>, }  /// The link between an instance and its editor and state handles. The instance may process on@@ -776,6 +803,7 @@ impl Default for GuiLink {                 created: false,                 visible: false,                 window: None,+                unavailable: None,             }),             host: Arc::new(HostState::default()),         }@@ -805,6 +833,35 @@ impl GuiLink {         (g.alive).then(|| unsafe { g.gui.as_ref() }.copied()).flatten()     } +    /// Why the editor can't open on this platform, asked of `clap.gui` without creating it (once,+    /// then cached): it offers only an embedded editor where SoundCraft hosts none, or no window+    /// API SoundCraft has. `None` when it can open, or when this isn't the main thread (where+    /// the plugin can't be asked; [`GuiLink::open`] then reports what goes wrong).+    pub fn unavailable(&self) -> Option<String> {+        if !on_main_thread() {+            return None;+        }+        let mut g = self.lock();+        if let Some(known) = &g.unavailable {+            return known.clone();+        }+        let vt = GuiLink::vt(&g)?;+        let why = match (window_api(), vt.is_api_supported) {+            (None, _) => editor_verdict(false, false, false, hostwin::SUPPORTED).map(str::to_string),+            (Some(_), None) => Some("incomplete clap.gui extension".to_string()),+            (Some(api), Some(supported)) => {+                // SAFETY: live plugin (`vt` is only given while `alive`); `api` is a static+                // NUL-terminated string; main thread (checked). Asking creates no GUI.+                let floating = unsafe { supported(g.plugin, api.as_ptr(), true) };+                // SAFETY: as above.+                let embedded = unsafe { supported(g.plugin, api.as_ptr(), false) };+                editor_verdict(true, floating, embedded, hostwin::SUPPORTED).map(str::to_string)+            }+        };+        g.unavailable = Some(why.clone());+        why+    }+     /// Opens (creates and shows) the plugin's floating editor window.     pub fn open(&self, title: &str) -> Result<(), String> {         if !on_main_thread() {@@ -831,7 +888,7 @@ impl GuiLink {             if hostwin::SUPPORTED && unsafe { supported(g.plugin, api.as_ptr(), false) } {                 return self.open_embedded(&mut g, vt, api, title);             }-            return Err("this plugin offers only an embedded editor, which SoundCraft hosts on Windows only".into());+            return Err(EMBEDDED_ONLY.into());         }         self.host.gui_closed.store(false, Ordering::Relaxed);         self.host.gui_destroyed.store(false, Ordering::Relaxed);@@ -1643,6 +1700,14 @@ mod hostwin { mod tests {     use super::*; +    #[test]+    fn an_editor_opens_where_its_window_kind_is_hosted() {+        assert_eq!(editor_verdict(false, true, true, true), Some("plugin editors are not supported on this platform"));+        assert_eq!(editor_verdict(true, true, false, false), None, "floating opens everywhere");+        assert_eq!(editor_verdict(true, false, true, true), None, "embedded, where SoundCraft hosts it (Windows)");+        assert_eq!(editor_verdict(true, false, true, false), Some(EMBEDDED_ONLY), "embedded elsewhere");+    }+     #[test]     fn the_transport_reaches_clap_in_fixed_point() {         let t = Transport {diff --git a/crates/clap-host/src/plugin.rs b/crates/clap-host/src/plugin.rs--- a/crates/clap-host/src/plugin.rs+++ b/crates/clap-host/src/plugin.rs@@ -338,6 +338,10 @@ impl PluginEditor for ClapEditor {         self.link.is_open()     } +    fn unavailable(&self) -> Option<String> {+        self.link.unavailable()+    }+     fn idle(&mut self) -> Vec<(String, f32)> {         let raw = self.link.idle();         let mut out: Vec<(String, f32)> = Vec::new();diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs--- a/crates/dsp/src/lib.rs+++ b/crates/dsp/src/lib.rs@@ -268,6 +268,12 @@ pub trait PluginEditor: Send {     /// Closes the editor window.     fn close(&mut self);     fn is_open(&self) -> bool;+    /// Why this editor can't open on this platform (e.g. a CLAP plugin that offers only an+    /// embedded editor where SoundCraft hosts none), found without opening anything; `None` when+    /// it can, or can't tell before trying. Main thread only.+    fn unavailable(&self) -> Option<String> {+        None+    }     /// Call every UI frame while open: services the plugin's GUI requests, notices a window the     /// user closed, and returns parameter edits made in the editor as `(param id, value)` in     /// SoundCraft units (ids and ranges of [`PluginInfo::params`]).diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs--- a/crates/playback/src/lib.rs+++ b/crates/playback/src/lib.rs@@ -717,6 +717,20 @@ impl Player {         self.side().editors.contains_key(&(track, slot))     } +    /// Why the plugin's own editor can't open on this platform, found without opening it+    /// (`PluginEditor::unavailable`); `None` when it can, or when the slot has no editor. Main+    /// thread only.+    pub fn editor_unavailable(&self, track: TrackId, slot: usize) -> Option<String> {+        self.side().editors.get(&(track, slot))?.unavailable()+    }++    /// Gives a slot this editor in place of its instance's own, for tests driving a fake one (an+    /// editor that can't open here, say). Main thread only.+    #[doc(hidden)]+    pub fn set_editor(&self, track: TrackId, slot: usize, editor: Box<dyn PluginEditor>) {+        self.side().add_editor((track, slot), editor);+    }+     /// Opens the plugin's own editor window. Main thread only.     pub fn open_editor(&self, track: TrackId, slot: usize) -> Result<(), String> {         self.side().open_editor((track, slot))@@ -1422,6 +1436,37 @@ mod tests {         }     } +    /// An editor that can't open on this platform (a CLAP plugin with only an embedded editor).+    struct Embedded;++    impl PluginEditor for Embedded {+        fn open(&mut self) -> Result<(), String> {+            Err("tried to open".into())+        }+        fn close(&mut self) {}+        fn is_open(&self) -> bool {+            false+        }+        fn unavailable(&self) -> Option<String> {+            Some("embedded only".into())+        }+        fn idle(&mut self) -> Vec<(String, f32)> {+            Vec::new()+        }+    }++    #[test]+    fn an_editor_says_why_it_cant_open_before_it_is_tried() {+        let player = Player::new(Arc::new(Session::default()));+        let (t, slot) = (TrackId(1), 0);+        assert_eq!(player.editor_unavailable(t, slot), None, "no editor, nothing to say");+        player.set_editor(t, slot, Box::new(Window));+        assert_eq!(player.editor_unavailable(t, slot), None);+        player.set_editor(t, slot, Box::new(Embedded));+        assert!(player.has_editor(t, slot));+        assert_eq!(player.editor_unavailable(t, slot).as_deref(), Some("embedded only"));+    }+     #[test]     fn a_refused_state_is_kept_until_the_editor_opens() {         let mut side = Side::new(48_000.0, channel().1);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@@ -53,6 +53,26 @@ pub enum MainWindow {     Mix, } +/// The two windows a plugin slot opens (`window.plugin`'s `view`): the plugin's own editor, or+/// SoundCraft's parameter window.+#[derive(Debug, Clone, Copy, PartialEq, Eq)]+pub(crate) enum SlotView {+    Editor,+    Parameters,+}++impl SlotView {+    pub(crate) fn id(self) -> &'static str {+        match self {+            SlotView::Editor => "editor",+            SlotView::Parameters => "parameters",+        }+    }+}++/// Audio Unit instances have no editor handle yet (Cocoa `AUCocoaUIBase` views aren't hosted).+pub(crate) const AU_EDITOR_UNAVAILABLE: &str = "SoundCraft can't host Audio Unit editors yet (#501)";+ /// UI state (serde, so agents can read and set it). #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(default)]@@ -680,25 +700,125 @@ 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.+    /// Opens the insert just picked from its menu, as a click on it would ([`SoundApp::open_slot`]).     fn open_inserted(&mut self) {         let Some((track, slot)) = 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;-        };-        if soundcraft_mix::is_third_party(&plugin)-            && let Some(p) = &self.player-            && p.has_editor(track, slot)+        let Some(plugin) = self.slot_plugin(track, slot) else { return };+        let _ = self.open_slot(track, slot, &plugin);+    }++    /// The plugin in an insert slot, or in the instrument slot ([`soundcraft_mix::INSTRUMENT_SLOT`]).+    pub(crate) fn slot_plugin(&self, track: TrackId, slot: usize) -> Option<String> {+        let t = self.engine.session().track(track)?;+        let ins = if slot == soundcraft_mix::INSTRUMENT_SLOT { t.instrument.as_ref() } else { t.mixer.inserts.get(slot)?.as_ref() };+        ins.map(|i| i.plugin.clone())+    }++    /// Opens an occupied insert or instrument slot as DAWs do (`window.plugin` without a `view`): a+    /// hosted plugin's own editor, else SoundCraft's parameter window: for a built-in plugin, no+    /// audio engine, and an editor known not to open here ([`SoundApp::editor_unavailable`]: an+    /// Audio Unit, an embedded-only CLAP editor outside Windows, a VST3 editor outside macOS and+    /// Windows), straight away and without an error. Opening an editor that is open already+    /// raises it (VST3, and embedded CLAP editors on Windows) or shows it again (a floating CLAP+    /// editor, which the plugin may or may not bring forward). Returns what opened; when an+    /// editor fails to open and the parameter window can't either, the editor's reason.+    pub(crate) fn open_slot(&mut self, track: TrackId, slot: usize, plugin: &str) -> Result<SlotView, String> {+        let mut editor_failed = None;+        if soundcraft_mix::is_third_party(plugin)+            && self.player.as_ref().is_some_and(|p| p.has_editor(track, slot))+            && self.editor_unavailable(track, slot, plugin).is_none()         {-            match p.open_editor(track, slot) {-                Ok(()) => return,-                Err(e) => self.ui.status = format!("{}: {e}", mix_window::plugin_info(&plugin).map_or(plugin.as_str(), |i| i.name)),+            match self.open_editor(track, slot, plugin) {+                Ok(()) => return Ok(SlotView::Editor),+                Err(e) => editor_failed = Some(e),             }         }+        match (self.open_parameters(track, slot, plugin), editor_failed) {+            (Ok(()), _) => Ok(SlotView::Parameters),+            (Err(_), Some(first)) => {+                self.ui.status.clone_from(&first);+                Err(first)+            }+            (Err(e), None) => Err(e),+        }+    }++    /// Opens a hosted plugin's own editor (`window.plugin {"view":"editor"}`). A failure goes to the+    /// status line as "{plugin name}: {reason}".+    pub(crate) fn open_editor(&mut self, track: TrackId, slot: usize, plugin: &str) -> Result<(), String> {+        let opened = if !soundcraft_mix::is_third_party(plugin) {+            Err("a built-in plugin has no editor of its own".to_string())+        } else if let Some(why) = self.editor_unavailable(track, slot, plugin) {+            Err(why)+        } else {+            match &self.player {+                None => Err("no audio engine, so the plugin is not loaded".to_string()),+                Some(p) if !p.has_editor(track, slot) => Err("this plugin has no editor of its own, or it is not loaded".to_string()),+                Some(p) => p.open_editor(track, slot),+            }+        };+        opened.map_err(|e| self.slot_error(plugin, &e))+    }++    /// Why a slot's own editor can't open here, known without trying: SoundCraft doesn't host Audio+    /// Unit editors yet, or the editor says so ([`soundcraft_playback::Player::editor_unavailable`]).+    pub(crate) fn editor_unavailable(&self, track: TrackId, slot: usize, plugin: &str) -> Option<String> {+        // Audio Unit instances have no editor handle to ask; this goes once #501 gives them one.+        if plugin.starts_with(soundcraft_au_host::ID_PREFIX) {+            return Some(AU_EDITOR_UNAVAILABLE.to_string());+        }+        self.player.as_ref()?.editor_unavailable(track, slot)+    }++    /// Whether a hosted slot's own editor is open, or why it can't open: one known not to open here+    /// ([`SoundApp::editor_unavailable`]), no audio engine, or none at all (a plugin without one,+    /// or an instance that didn't load). The reasons are those of `window.plugin {"view":"editor"}`.+    pub(crate) fn editor_state(&self, track: TrackId, slot: usize, plugin: &str) -> Result<bool, String> {+        if let Some(why) = self.editor_unavailable(track, slot, plugin) {+            return Err(why);+        }+        match &self.player {+            None => Err("no audio engine, so the plugin is not loaded".to_string()),+            Some(p) if p.has_editor(track, slot) => Ok(p.editor_open(track, slot)),+            Some(_) => Err("this plugin has no editor of its own, or it is not loaded".to_string()),+        }+    }++    /// Shows SoundCraft's parameter window for a slot (generic controls, presets, bypass). A+    /// plugin that isn't installed or scanned has none: that goes to the status line.+    pub(crate) fn open_parameters(&mut self, track: TrackId, slot: usize, plugin: &str) -> Result<(), String> {+        if mix_window::plugin_info(plugin).is_none() {+            return Err(self.slot_error(plugin, "not installed, or not scanned yet, so it has no parameters to show"));+        }         if !self.ui.plugin_windows.contains(&(track, slot)) {             self.ui.plugin_windows.push((track, slot));         }+        Ok(())+    }++    /// Closes a slot's own editor and its parameter window (`window.plugin {"value":false}`), or+    /// only the one `view` names.+    pub(crate) fn close_slot(&mut self, track: TrackId, slot: usize, view: Option<SlotView>) {+        if view != Some(SlotView::Parameters)+            && let Some(p) = &self.player+        {+            p.close_editor(track, slot);+        }+        if view != Some(SlotView::Editor) {+            self.ui.plugin_windows.retain(|w| *w != (track, slot));+        }+    }++    /// Whether a slot's own editor or its parameter window is open: the slot is drawn lit.+    pub(crate) fn slot_window_open(&self, track: TrackId, slot: usize) -> bool {+        self.ui.plugin_windows.contains(&(track, slot)) || self.player.as_ref().is_some_and(|p| p.editor_open(track, slot))+    }++    /// "{plugin name}: {reason}" on the status line; returned for the caller.+    fn slot_error(&mut self, plugin: &str, why: &str) -> String {+        let msg = format!("{}: {why}", mix_window::plugin_info(plugin).map_or(plugin, |i| i.name));+        self.ui.status.clone_from(&msg);+        msg     }      /// Parameter edits made in plugin editors go into the session (one undo step per gesture).@@ -1413,6 +1533,29 @@ mod tests {     use serde_json::json;     use soundcraft_model::TrackId; +    /// Without an audio engine no hosted plugin is loaded, so its editor can't open and says why;+    /// a click then falls back to the parameter window, which a plugin that isn't installed lacks+    /// too (the fallback with a scanned plugin: `tests/it/open_on_insert.rs`).+    #[test]+    fn with_no_audio_engine_a_hosted_slot_falls_back_and_says_why() {+        let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());+        let t = TrackId(7);+        let plugin = "clap:test.not.installed";+        assert!(app.open_editor(t, 2, plugin).is_err());+        assert_eq!(app.ui.status, "clap:test.not.installed: no audio engine, so the plugin is not loaded");+        assert!(app.open_slot(t, 2, plugin).is_err());+        assert!(app.ui.status.starts_with("clap:test.not.installed: not installed"), "{}", app.ui.status);+        assert!(app.ui.plugin_windows.is_empty(), "no empty window");+        // A built-in has no editor; the parameter window opens once, however often it is asked for.+        assert!(app.open_editor(t, 0, "gain").is_err());+        assert_eq!(app.open_slot(t, 0, "gain"), Ok(super::SlotView::Parameters));+        app.open_parameters(t, 0, "gain").unwrap();+        assert_eq!(app.ui.plugin_windows, vec![(t, 0)]);+        assert!(app.slot_window_open(t, 0) && !app.slot_window_open(t, 2));+        app.close_slot(t, 0, None);+        assert!(!app.slot_window_open(t, 0));+    }+     #[test]     fn new_session_stops_playback_and_the_old_clock() {         for (command, params) in [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@@ -27,6 +27,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[     ("window.close", "Close Window", "Window > Close Window", Some("Cmd+W")),     ("window.hide_floating", "Hide All Floating Windows", "Window > Hide All Floating Windows", Some("Cmd+Ctrl+W")),     ("window.midi_editor", "MIDI Editor", "Window > MIDI Editor", None),+    ("window.plugin", "Open Plugin", "", None),     ("window.universe", "Universe", "View > Other Displays > Universe", Some("Alt+7")),     ("window.video", "Video", "Window > Video", Some("Cmd+9")),     ("window.video_universe", "Video Universe", "Window > Video Universe", None),@@ -626,9 +627,16 @@ 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();+            if id == "window.hide_floating"+                && let Some(p) = &app.player+            {+                // Hosted plugins' own editors float too (as plugin windows do in Pro Tools).+                p.close_editors();+            }             app.ui.audiosuite = None;             json!({})         }+        "window.plugin" => return Some(plugin_window(app, p)),         "view.mix_section" => {             let sec = p.get("section").and_then(Value::as_str).unwrap_or("");             match sec {@@ -704,6 +712,63 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<     Some(Ok(r)) } +/// `window.plugin {track, slot, view?, value?}`: opens an occupied insert or instrument slot the+/// way a click on it does (no `view`), or one of its windows: `"editor"` (the hosted plugin's own+/// editor, else an error) or `"parameters"` (SoundCraft's parameter window). A missing `value`+/// opens, like a click; `value: false` closes the named window, or both, and succeeds whatever+/// is open (an empty slot included). `slot` is 0-9, a letter a-j, or `"instrument"`. Replies+/// `{track, slot, view, value}`: the view that opened, or the one closed (null for both).+fn plugin_window(app: &mut SoundApp, p: &Value) -> Result<Value, String> {+    const CMD: &str = "window.plugin";+    let track = soundcraft_engine::cmd::track_param(&app.engine, CMD, p, "track").map_err(|e| e.to_string())?;+    let track = track.ok_or_else(|| format!("{CMD}: `track` required"))?;+    let slot = plugin_slot_param(p).ok_or_else(|| format!("{CMD}: `slot` must be 0-9, a letter a-j, or \"instrument\""))?;+    let view = match p.get("view") {+        None | Some(Value::Null) => None,+        Some(v) => match v.as_str() {+            Some("editor") => Some(crate::SlotView::Editor),+            Some("parameters") => Some(crate::SlotView::Parameters),+            _ => return Err(format!("{CMD}: `view` must be \"editor\" or \"parameters\"")),+        },+    };+    let open = match p.get("value") {+        None | Some(Value::Null) => true,+        Some(Value::Bool(b)) => *b,+        Some(_) => return Err(format!("{CMD}: `value` must be true or false")),+    };+    let slot_json = if slot == soundcraft_mix::INSTRUMENT_SLOT { json!("instrument") } else { json!(slot) };+    if !open {+        app.close_slot(track, slot, view);+        return Ok(json!({"track": track.0, "slot": slot_json, "view": view.map(crate::SlotView::id), "value": false}));+    }+    let plugin = app.slot_plugin(track, slot).ok_or_else(|| format!("{CMD}: that slot is empty"))?;+    let opened = match view {+        None => app.open_slot(track, slot, &plugin)?,+        Some(crate::SlotView::Editor) => app.open_editor(track, slot, &plugin).map(|()| crate::SlotView::Editor)?,+        Some(crate::SlotView::Parameters) => app.open_parameters(track, slot, &plugin).map(|()| crate::SlotView::Parameters)?,+    };+    Ok(json!({"track": track.0, "slot": slot_json, "view": opened.id(), "value": true}))+}++/// A plugin slot: an insert (0-9 or a-j) or `"instrument"` ([`soundcraft_mix::INSTRUMENT_SLOT`]).+fn plugin_slot_param(p: &Value) -> Option<usize> {+    let v = p.get("slot")?;+    let n = match v.as_u64() {+        Some(n) => usize::try_from(n).ok()?,+        None => {+            let s = v.as_str()?.trim().to_ascii_lowercase();+            if s == "instrument" {+                return Some(soundcraft_mix::INSTRUMENT_SLOT);+            }+            match s.as_bytes() {+                [c @ b'a'..=b'z'] => usize::from(c - b'a'),+                _ => s.parse().ok()?,+            }+        }+    };+    (n < soundcraft_model::INSERT_SLOTS).then_some(n)+}+ /// Parity including UI-layer commands. pub fn parity() -> Value {     let extra = ui_aliases();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@@ -497,9 +497,10 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh     ui.painter().line_segment([pos2(r.max.x, r.min.y), pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border)); } -/// An instrument track's INSTRUMENT row: click opens a hosted instrument's plugin window,-/// right-click (or click, when none is set) picks another instrument. A built-in's parameters-/// open from that menu. Dropping an audio file on it loads the sampler.+/// An instrument track's INSTRUMENT row: click opens a hosted instrument (`window.plugin`: its own+/// editor, else its parameter window), right-click (or click, when none is set) picks another+/// instrument. A built-in's parameters open from that menu. Lit while one of its windows is open.+/// Dropping an audio file on it loads the sampler. pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {     let t = Tokens::current();     let (id, slot) = (track.id, soundcraft_mix::INSTRUMENT_SLOT);@@ -508,12 +509,16 @@ pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r:     let resp = ui.interact(r, ui.id().with(("instrument", id.0)), Sense::click());     resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Instrument"));     match info {-        Some(info) => used_slot(ui.painter(), r, t.slot_face, Mark::Dot, info.short_name, t.text, None),+        Some(info) => {+            let look = slot_look(&t, false, app.slot_window_open(id, slot), t.text);+            used_slot(ui.painter(), r, look.faces, Mark::Dot, info.short_name, look.text, None);+            look.outline(ui.painter(), 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 {+        let _ = app.run("window.plugin", json!({"track": id.0, "slot": "instrument"}));     }     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) };@@ -552,6 +557,38 @@ fn draw_mark(p: &egui::Painter, r: Rect, mark: Mark, color: Color32) {     } } +/// How an occupied insert or instrument slot is drawn ([`slot_look`]).+#[derive(Debug, Clone, Copy, PartialEq)]+pub(crate) struct SlotLook {+    pub faces: [Color32; 2],+    pub text: Color32,+    /// A rim drawn over the key's own, where the theme needs one to set a lit slot apart.+    pub edge: Option<Color32>,+}++impl SlotLook {+    fn outline(&self, p: &egui::Painter, r: Rect) {+        if let Some(edge) = self.edge {+            p.rect_stroke(r.expand(1.0), 3.0, Stroke::new(1.0, edge), StrokeKind::Inside);+        }+    }+}++/// An occupied insert or instrument slot's look: grey, blue when bypassed, and lit (a light face+/// with dark text, as Pro Tools shows it) while its editor or parameter window is open. A+/// bypassed slot with a window open is a lighter blue.+pub(crate) fn slot_look(t: &Tokens, bypassed: bool, open: bool, text: Color32) -> SlotLook {+    let edge = (t.slot_open_edge.a() > 0).then_some(t.slot_open_edge);+    match (bypassed, open) {+        (false, false) => SlotLook { faces: t.slot_face, text, edge: None },+        (true, false) => SlotLook { faces: t.slot_bypassed, text, edge: None },+        (false, true) => SlotLook { faces: t.slot_open, text: t.slot_open_text, edge },+        (true, true) => {+            SlotLook { faces: std::array::from_fn(|i| t.slot_bypassed[i].lerp_to_gamma(t.slot_open[i], 0.45)), text: t.slot_open_text, edge }+        }+    }+}+ fn empty_slot(p: &egui::Painter, r: Rect, hovered: bool, mark: Mark) {     let t = Tokens::current();     p.rect_filled(r, 2.0, if hovered { shade(t.slot_empty, 0.06) } else { t.slot_empty });@@ -603,16 +640,17 @@ pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot:     match &ins {         None => empty_slot(ui.painter(), r, resp.hovered(), Mark::Dot),         Some(i) => {-            let faces = if i.bypass { t.slot_bypassed } else { t.slot_face };             let color = if info.is_some() { t.text } else { t.text_dim };-            used_slot(ui.painter(), r, faces, Mark::Dot, name, color, None);+            let look = slot_look(&t, i.bypass, app.slot_window_open(id, slot), color);+            used_slot(ui.painter(), r, look.faces, Mark::Dot, name, look.text, None);+            look.outline(ui.painter(), r);         }     }     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 {+            let _ = app.run("window.plugin", json!({"track": id.0, "slot": slot}));         }     }     let cmd = if ui.ctx().os().is_mac() { "⌘" } else { "Ctrl+" };@@ -634,6 +672,18 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc         return;     }     if occupied {+        // A hosted plugin opens in its own editor on click; its parameter window (when the plugin+        // is installed, so its parameters are known) and the editor toggle stay here.+        let hosted = app.slot_plugin(id, slot).filter(|p| soundcraft_mix::is_third_party(p));+        if let Some(plugin) = hosted {+            if plugin_info(&plugin).is_some() && ui.button("Edit Parameters…").clicked() {+                let _ = app.run("window.plugin", json!({"track": id.0, "slot": slot, "view": "parameters"}));+                ui.close();+            }+            if editor_toggle(app, ui, id, slot) {+                ui.close();+            }+        }         if ui.button("Bypass").clicked() {             run_pick(app, ui, "mix.insert_bypass", json!({"track": id.0, "slot": slot}));         }@@ -677,26 +727,14 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {     let current = app.engine.session().track(id).and_then(|t| t.instrument.clone());     if let Some(info) = current.as_ref().and_then(|i| plugin_info(&i.plugin)) {         ui.label(egui::RichText::new(info.name).strong());+        // A hosted instrument keeps its sounds and settings in its own editor: its parameter+        // window has nothing to edit, so only a built-in offers one here.         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));-            }+            let _ = app.run("window.plugin", json!({"track": id.0, "slot": "instrument", "view": "parameters"}));             ui.close();         }-        if let Some(p) = &app.player-            && p.has_editor(id, slot)-        {-            if p.editor_open(id, slot) {-                if ui.button("Close Plugin Editor").clicked() {-                    p.close_editor(id, slot);-                    ui.close();-                }-            } else if ui.button("Open Plugin Editor").clicked() {-                if let Err(e) = p.open_editor(id, slot) {-                    app.ui.status = format!("{}: {e}", info.name);-                }-                ui.close();-            }+        if editor_toggle(app, ui, id, slot) {+            ui.close();         }         ui.separator();     }@@ -716,6 +754,32 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {     } } +/// Open/Close Plugin Editor for a hosted plugin (`window.plugin`), in its slot menu and its+/// parameter window: greyed out, with the reason as its hint, when the editor can't open.+/// Returns whether it was clicked.+pub(crate) fn editor_toggle(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize) -> bool {+    let Some(plugin) = app.slot_plugin(id, slot).filter(|p| soundcraft_mix::is_third_party(p)) else { return false };+    let state = app.editor_state(id, slot, &plugin);+    let open = state == Ok(true);+    let resp = ui.add_enabled(state.is_ok(), egui::Button::new(if open { "Close Plugin Editor" } else { "Open Plugin Editor" }));+    let resp = match &state {+        Err(why) => resp.on_disabled_hover_text(capitalized(why)),+        Ok(_) => resp,+    };+    if !resp.clicked() {+        return false;+    }+    let slot = if slot == soundcraft_mix::INSTRUMENT_SLOT { json!("instrument") } else { json!(slot) };+    let _ = app.run("window.plugin", json!({"track": id.0, "slot": slot, "view": "editor", "value": !open}));+    true+}++/// `s` with its first letter in upper case, for a reason shown on its own.+fn capitalized(s: &str) -> String {+    let mut c = s.chars();+    c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default()+}+ /// Inserts the plugin picked in an insert's menu and, as DAWs do, opens it once the audio engine has /// it (`SoundApp::open_inserted`). fn pick_insert(app: &mut SoundApp, ui: &Ui, id: TrackId, slot: usize, plugin: &str) {@@ -1312,6 +1376,36 @@ mod tests {     use super::{dragged_send_db, editable_readout, parse_db, parse_pan};     use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2}; +    /// A slot whose plugin window is open is lit, in both themes: its face is lighter than a closed+    /// slot's where that one shades, and the key differs strongly from a closed one, by its face+    /// (dark theme) or, where the closed face is already near white (light theme), by a dark rim.+    /// Its name stays dark and readable; a bypassed one stays blue, lighter while open.+    #[test]+    fn a_slot_with_its_window_open_is_lit() {+        use super::slot_look;+        use crate::theme::Tokens;+        let luma = |c: egui::Color32| (0.2126 * f32::from(c.r()) + 0.7152 * f32::from(c.g()) + 0.0722 * f32::from(c.b())) / 255.0;+        let mean = |f: [egui::Color32; 2]| (luma(f[0]) + luma(f[1])) / 2.0;+        for (name, t) in [("dark", Tokens::DARK), ("light", Tokens::LIGHT)] {+            let text = t.text;+            let closed = slot_look(&t, false, false, text);+            assert_eq!((closed.faces, closed.text, closed.edge), (t.slot_face, text, None), "{name}: closed is unchanged");+            assert_eq!(slot_look(&t, true, false, text).faces, t.slot_bypassed);+            let open = slot_look(&t, false, true, text);+            assert_eq!((open.faces, open.text), (t.slot_open, t.slot_open_text));+            assert!(luma(open.faces[1]) - luma(closed.faces[1]) >= 0.15, "{name}: lit is lighter where the closed face shades");+            let rim = crate::widgets::shade(t.slot_well, -0.3); // `raised_key`'s rim+            let by_face = mean(open.faces) - mean(closed.faces) >= 0.3;+            let by_rim = open.edge.is_some_and(|e| luma(rim) - luma(e) >= 0.3 && luma(open.faces[0]) - luma(e) >= 0.6);+            assert!(by_face || by_rim, "{name}: a lit key stands out from a closed one: {open:?} vs {closed:?}");+            assert!(luma(open.faces[1]) - luma(open.text) >= 0.6, "{name}: its name stays readable");+            let bypassed_open = slot_look(&t, true, true, text).faces;+            assert!(mean(bypassed_open) - mean(t.slot_bypassed) >= 0.09, "{name}: bypassed and open is a lighter blue");+            assert!(bypassed_open[1].b() > bypassed_open[1].r(), "{name}: still blue");+        }+        assert!(slot_look(&Tokens::LIGHT, false, true, Tokens::LIGHT.text).edge.is_some(), "the light theme's lit key has a rim");+    }+     #[test]     fn typed_db_accepts_console_notation_and_clamps() {         assert_eq!(parse_db("-6 dB"), Some(-6.0));diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs--- a/crates/ui-egui/src/panels.rs+++ b/crates/ui-egui/src/panels.rs@@ -529,27 +529,15 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {                         }                         ui.menu_button("Presets ▾", |ui| preset_menu(app, ui, tid, slot, info, &ins));                     }-                    if soundcraft_mix::is_third_party(&ins.plugin) {-                        let open = app.player.as_ref().is_some_and(|p| p.editor_open(tid, slot));-                        let available = app.player.as_ref().is_some_and(|p| p.has_editor(tid, slot));-                        let label = if open { "Close Plugin Editor" } else { "Open Plugin Editor" };-                        let resp = ui.add_enabled(available, egui::Button::new(label));-                        let resp =-                            if available { resp } else { resp.on_disabled_hover_text("This plugin has no editor of its own, or it is not loaded") };-                        if resp.clicked()-                            && let Some(p) = &app.player-                        {-                            if open {-                                p.close_editor(tid, slot);-                            } else if let Err(e) = p.open_editor(tid, slot) {-                                app.ui.status = format!("{}: {e}", info.name);-                            }-                        }-                    }+                    crate::mix_window::editor_toggle(app, ui, tid, slot);                 });                 if instrument && soundcraft_mix::is_third_party(&ins.plugin) {                     // A hosted instrument keeps its sounds and settings in its own editor.-                    ui.label(egui::RichText::new("Load and edit sounds in the plugin's own editor.").color(Tokens::current().text_dim));+                    let hint = match app.editor_state(tid, slot, &ins.plugin) {+                        Ok(_) => "Load and edit sounds in the plugin's own editor.".to_string(),+                        Err(why) => format!("This instrument keeps its sounds in its own editor, which SoundCraft can't open here: {why}."),+                    };+                    ui.label(egui::RichText::new(hint).color(Tokens::current().text_dim));                     return;                 }                 if instrument && ins.plugin == crate::sampler_window::SAMPLER {diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs--- a/crates/ui-egui/src/theme.rs+++ b/crates/ui-egui/src/theme.rs@@ -210,6 +210,11 @@ pub struct Tokens {     pub slot_face: [Color32; 2],     pub slot_bypassed: [Color32; 2],     pub slot_mark: Color32,+    /// A used slot whose plugin window (its own editor or the parameter window) is open: a lit+    /// face, the name on it, and a rim around it (transparent: none).+    pub slot_open: [Color32; 2],+    pub slot_open_text: Color32,+    pub slot_open_edge: Color32, }  impl Tokens {@@ -296,6 +301,9 @@ impl Tokens {         slot_face: [Color32::from_rgb(92, 92, 92), Color32::from_rgb(75, 75, 75)],         slot_bypassed: [Color32::from_rgb(66, 96, 146), Color32::from_rgb(50, 76, 120)],         slot_mark: Color32::from_rgb(159, 159, 159),+        slot_open: [Color32::from_rgb(226, 226, 226), Color32::from_rgb(196, 196, 196)],+        slot_open_text: Color32::from_rgb(16, 16, 16),+        slot_open_edge: Color32::TRANSPARENT,     };      pub const LIGHT: Tokens = Tokens {@@ -381,6 +389,9 @@ impl Tokens {         slot_face: [Color32::from_rgb(244, 245, 247), Color32::from_rgb(214, 216, 220)],         slot_bypassed: [Color32::from_rgb(190, 210, 240), Color32::from_rgb(160, 186, 228)],         slot_mark: Color32::from_rgb(92, 95, 102),+        slot_open: [Color32::from_rgb(255, 255, 255), Color32::from_rgb(255, 255, 255)],+        slot_open_text: Color32::from_rgb(0, 0, 0),+        slot_open_edge: Color32::from_rgb(38, 41, 48),     };      pub fn current() -> Tokens {diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -11,6 +11,9 @@ mod clip_gain_fader; mod clip_menu; mod command_reachability; mod commands_filter;+/// The synthetic CLAP plugin (built and installed once per run), shared by the tests that host it.+#[path = "../../../clap-host/tests/common/mod.rs"]+mod common; mod docking_workflows; mod edit_menu; mod edit_modes;diff --git a/crates/ui-egui/tests/it/open_on_insert.rs b/crates/ui-egui/tests/it/open_on_insert.rs--- a/crates/ui-egui/tests/it/open_on_insert.rs+++ b/crates/ui-egui/tests/it/open_on_insert.rs@@ -3,7 +3,7 @@  use egui::{Key, vec2}; use egui_kittest::Harness;-use egui_kittest::kittest::Queryable;+use egui_kittest::kittest::{NodeT, Queryable}; use serde_json::json; use soundcraft_model::TrackId; use soundcraft_ui_egui::{MainWindow, Services, SoundApp};@@ -107,3 +107,261 @@ fn the_edit_windows_inserts_column_opens_it_too() {     let id = vox_id(&h);     assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)]); }++/// A filled slot of `plugin` on Vox's insert A, set straight into the session.+fn vox_with(plugin: &str) -> soundcraft_engine::Engine {+    let mut engine = vox();+    engine.session_mut().tracks[0].mixer.inserts[0] = Some(soundcraft_model::Insert::new(plugin));+    engine+}++#[test]+fn clicking_a_built_in_insert_opens_its_parameter_window() {+    let (_, plugin, _) = two_effects();+    let mut h = harness(vox_with(plugin.id), MainWindow::Mix);+    h.get_by_label("Insert A").click();+    h.run_steps(3);+    let id = vox_id(&h);+    assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)]);+}++/// The synthetic CLAP effect, installed so its parameters are known.+fn hosted() -> &'static str {+    crate::common::fixture_dir();+    "clap:org.soundcraft.test.gain"+}++/// With no audio engine nothing is hosted, so a click on a hosted insert can't open the plugin's+/// own editor: it opens the parameter window instead, through the slot itself.+#[test]+fn clicking_a_hosted_insert_without_an_audio_engine_opens_its_parameter_window() {+    let mut h = harness(vox_with(hosted()), MainWindow::Mix);+    h.get_by_label("Insert A").click();+    h.run_steps(3);+    let id = vox_id(&h);+    assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)]);+}++/// An insert's right-click menu, on its own.+fn menu(engine: soundcraft_engine::Engine) -> (Harness<'static, SoundApp>, TrackId) {+    let id = engine.session().tracks[0].id;+    let app = SoundApp::new(engine, None, Services::default());+    let mut h = Harness::builder()+        .with_size(vec2(400.0, 600.0))+        .build_ui_state(move |ui, app: &mut SoundApp| soundcraft_ui_egui::mix_window::plugin_menu(app, ui, id, 0, true), app);+    h.step();+    (h, id)+}++#[test]+fn a_hosted_inserts_menu_edits_its_parameters_when_it_is_installed() {+    let (mut h, id) = menu(vox_with(hosted()));+    h.get_by_label("Edit Parameters…").click();+    h.step();+    assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)]);+}++#[test]+fn a_missing_hosted_plugin_offers_no_parameter_window() {+    let (h, _) = menu(vox_with("clap:org.soundcraft.test.not.installed"));+    assert!(h.query_by_label("Edit Parameters…").is_none(), "it would open nothing");+    assert!(h.query_by_label("Bypass").is_some());+}++/// A hosted instrument's parameter window has nothing to edit (its sounds live in its own+/// editor), so its menu doesn't offer one; a built-in's does (`sampler_window.rs`).+#[test]+fn a_hosted_instruments_menu_offers_no_parameter_window() {+    let synth = {+        crate::common::fixture_dir();+        "clap:org.soundcraft.test.synth"+    };+    let mut engine = soundcraft_engine::Engine::default();+    engine.execute("track.new", &json!({"name": "Keys", "kind": "instrument"})).unwrap();+    let keys = engine.session().tracks[0].id;+    engine.session_mut().tracks[0].instrument = Some(soundcraft_model::Insert::new(synth));+    let app = SoundApp::new(engine, None, Services::default());+    let mut h = Harness::builder()+        .with_size(vec2(400.0, 600.0))+        .build_ui_state(move |ui, app: &mut SoundApp| soundcraft_ui_egui::mix_window::instrument_menu(app, ui, keys), app);+    h.step();+    assert!(h.query_by_label("Fixture Synth").is_some(), "the instrument's name heads the menu");+    assert!(h.query_by_label("Edit Parameters…").is_none());+}++/// `window.plugin` opens a slot as a click does, or the view it names, and closes it again.+#[test]+fn window_plugin_opens_and_closes_a_slots_windows() {+    let (_, plugin, _) = two_effects();+    let mut engine = vox_with(plugin.id);+    engine.session_mut().tracks[0].mixer.inserts[1] = Some(soundcraft_model::Insert::new(hosted()));+    let id = engine.session().tracks[0].id;+    let mut app = SoundApp::new(engine, None, Services::default());+    let r = app.run("window.plugin", json!({"track": "Vox", "slot": "a"})).unwrap();+    assert_eq!(r, json!({"track": id.0, "slot": 0, "view": "parameters", "value": true}), "a built-in opens its parameter window");+    assert_eq!(app.ui.plugin_windows, vec![(id, 0)]);+    let e = app.run("window.plugin", json!({"track": id.0, "slot": 1, "view": "editor"})).unwrap_err();+    assert!(e.contains("no audio engine"), "{e}");+    assert!(app.ui.status.starts_with(&format!("{}: ", soundcraft_ui_egui::mix_window::plugin_info(hosted()).unwrap().name)), "{}", app.ui.status);+    assert_eq!(app.run("window.plugin", json!({"track": id.0, "slot": 1, "value": true})).unwrap()["view"], "parameters");+    assert_eq!(app.ui.plugin_windows, vec![(id, 0), (id, 1)]);+    let r = app.run("window.plugin", json!({"track": id.0, "slot": 0, "value": false})).unwrap();+    assert_eq!(r, json!({"track": id.0, "slot": 0, "view": null, "value": false}));+    assert_eq!(app.ui.plugin_windows, vec![(id, 1)]);+    let r = app.run("window.plugin", json!({"track": id.0, "slot": "instrument", "view": "editor", "value": false})).unwrap();+    assert_eq!(r, json!({"track": id.0, "slot": "instrument", "view": "editor", "value": false}), "closing an empty slot is a no-op");+    for bad in+        [json!({"value": "false"}), json!({"value": 0}), json!({"view": "gui"}), json!({"view": 1}), json!({"slot": 10}), json!({"track": null})]+    {+        let mut p = json!({"track": id.0, "slot": 0});+        for (k, v) in bad.as_object().unwrap() {+            p[k] = v.clone();+        }+        assert!(app.run("window.plugin", p.clone()).is_err(), "{p} is refused");+    }+    assert_eq!(app.ui.plugin_windows, vec![(id, 1)], "refused calls open nothing");+    assert!(app.run("window.plugin", json!({"track": id.0, "slot": 2})).unwrap_err().contains("empty"));+    app.run("window.close", json!({})).unwrap();+    assert!(app.ui.plugin_windows.is_empty());+}++/// The top colour of the raised key painted under `label`: an open slot's is lit, a closed+/// one's is grey. Read from the shapes the real Mix and Edit drawing emits.+fn face_under(h: &Harness<SoundApp>, label: &str) -> egui::Color32 {+    use egui::Shape;+    let shapes = &h.output().shapes;+    // The label may appear elsewhere too (a parameter called the same): take the one on a key.+    let key_under = |center: egui::Pos2| {+        shapes+            .iter()+            .filter_map(|s| match &s.shape {+                Shape::Mesh(m) if m.calc_bounds().contains(center) && m.calc_bounds().height() <= 18.0 => Some(m),+                _ => None,+            })+            .min_by(|a, b| a.calc_bounds().area().total_cmp(&b.calc_bounds().area()))+    };+    let face = shapes+        .iter()+        .filter_map(|s| match &s.shape {+            Shape::Text(t) if t.galley.job.text == label => key_under(t.visual_bounding_rect().center()),+            _ => None,+        })+        .next()+        .unwrap_or_else(|| panic!("no slot named {label}"));+    let top = face.vertices.iter().filter(|v| v.color.a() == 255).min_by(|a, b| a.pos.y.total_cmp(&b.pos.y)).unwrap();+    top.color+}++fn near(a: egui::Color32, b: egui::Color32) -> bool {+    a.to_array().iter().zip(b.to_array()).all(|(p, q)| p.abs_diff(q) <= 2)+}++/// As in Pro Tools, an insert or instrument slot whose window is open is drawn lit, in the Mix+/// window and the Edit window's Inserts column; the others stay grey.+#[test]+fn a_slot_with_its_window_open_is_drawn_lit() {+    let t = soundcraft_ui_egui::theme::Tokens::DARK;+    let (_, a, b) = two_effects();+    let mut engine = vox();+    engine.execute("mix.insert", &json!({"track": "Vox", "slot": 0, "plugin": a.id})).unwrap();+    engine.execute("mix.insert", &json!({"track": "Vox", "slot": 1, "plugin": b.id})).unwrap();+    engine.execute("track.new", &json!({"kind": "instrument", "name": "Keys"})).unwrap();+    let synth = soundcraft_mix_plugin_name(&engine);+    let mut h = harness(engine, MainWindow::Mix);+    h.state_mut().run("view.mix_section", json!({"section": "instruments"})).unwrap();+    h.run_steps(3);+    assert!(near(face_under(&h, a.short_name), t.slot_face[0]) && near(face_under(&h, synth), t.slot_face[0]), "closed slots are grey");+    h.state_mut().run("window.plugin", json!({"track": "Vox", "slot": 0})).unwrap();+    h.state_mut().run("window.plugin", json!({"track": "Keys", "slot": "instrument", "view": "parameters"})).unwrap();+    h.run_steps(3);+    assert!(near(face_under(&h, a.short_name), t.slot_open[0]), "Mix: the open insert is lit");+    assert!(near(face_under(&h, b.short_name), t.slot_face[0]), "Mix: the other insert stays grey");+    assert!(near(face_under(&h, synth), t.slot_open[0]), "Mix: the open instrument is lit");+    h.state_mut().run("window.edit", json!({})).unwrap();+    h.run_steps(3);+    assert!(near(face_under(&h, a.short_name), t.slot_open[0]), "Edit: the open insert is lit");+    assert!(near(face_under(&h, b.short_name), t.slot_face[0]), "Edit: the other insert stays grey");+    h.state_mut().run("window.plugin", json!({"track": "Vox", "slot": 0, "value": false})).unwrap();+    h.run_steps(3);+    assert!(near(face_under(&h, a.short_name), t.slot_face[0]), "closing it greys it again");+}++/// The short name the Keys track's instrument slot shows.+fn soundcraft_mix_plugin_name(engine: &soundcraft_engine::Engine) -> &'static str {+    let keys = engine.session().track_by_name("Keys").unwrap();+    soundcraft_ui_egui::mix_window::plugin_info(&keys.instrument.as_ref().unwrap().plugin).unwrap().short_name+}++/// An editor that exists but can't open on this platform (as a CLAP plugin with only an embedded+/// editor outside Windows); counts the attempts to open it anyway.+struct EmbeddedOnly(std::sync::Arc<std::sync::atomic::AtomicUsize>);++const EMBEDDED_ONLY: &str = "this plugin offers only an embedded editor, which SoundCraft hosts on Windows only";++impl soundcraft_dsp::PluginEditor for EmbeddedOnly {+    fn open(&mut self) -> Result<(), String> {+        self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);+        Err("tried to open".into())+    }+    fn close(&mut self) {}+    fn is_open(&self) -> bool {+        false+    }+    fn unavailable(&self) -> Option<String> {+        Some(EMBEDDED_ONLY.into())+    }+    fn idle(&mut self) -> Vec<(String, f32)> {+        Vec::new()+    }+}++/// An editor known not to open here isn't tried: a click on the slot opens the parameter window+/// with no error, and its Open Plugin Editor is greyed out, saying why.+#[test]+fn an_editor_that_cant_open_here_is_greyed_out_and_a_click_opens_the_parameters() {+    let engine = vox_with(hosted());+    let id = engine.session().tracks[0].id;+    let player = soundcraft_playback::Player::new(engine.session_arc());+    let tries = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));+    player.set_editor(id, 0, Box::new(EmbeddedOnly(std::sync::Arc::clone(&tries))));+    let mut app = SoundApp::new(engine, Some(player), Services::default());+    app.ui.window = MainWindow::Mix;+    let mut h = Harness::builder().with_size(vec2(1400.0, 1000.0)).build_ui_state(+        |ui, app: &mut SoundApp| {+            app.logic(&ui.ctx().clone());+            app.ui(ui);+        },+        app,+    );+    h.run_steps(4);+    h.get_by_label("Insert A").click();+    h.run_steps(3);+    assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)]);+    assert_eq!(h.state().ui.status, "", "no error for an editor known not to open");+    let button = h.get_by_label("Open Plugin Editor");+    assert!(button.accesskit_node().is_disabled());+    button.hover();+    h.run_steps(40);+    assert!(+        h.query_by_label("This plugin offers only an embedded editor, which SoundCraft hosts on Windows only").is_some(),+        "the reason is its hint"+    );+    let e = h.state_mut().run("window.plugin", json!({"track": id.0, "slot": 0, "view": "editor"})).unwrap_err();+    assert!(e.ends_with(EMBEDDED_ONLY), "{e}");+    assert_eq!(tries.load(std::sync::atomic::Ordering::Relaxed), 0, "never tried");+}++/// SoundCraft hosts no Audio Unit editors yet: the slot menu says so on a greyed-out entry, and+/// `window.plugin` refuses the editor with that reason.+#[test]+fn an_audio_units_editor_is_greyed_out_with_the_reason() {+    // A component no Mac has, so nothing is loaded.+    let (mut h, id) = menu(vox_with("au:aufx:zzzz:Zzzz"));+    let entry = h.get_by_label("Open Plugin Editor");+    assert!(entry.accesskit_node().is_disabled());+    entry.hover();+    h.run_steps(40);+    assert!(h.query_by_label("SoundCraft can't host Audio Unit editors yet (#501)").is_some());+    let e = h.state_mut().run("window.plugin", json!({"track": id.0, "slot": 0, "view": "editor"})).unwrap_err();+    assert!(e.ends_with("SoundCraft can't host Audio Unit editors yet (#501)"), "{e}");+}diff --git a/crates/ui-egui/tests/it/track_preset_state.rs b/crates/ui-egui/tests/it/track_preset_state.rs--- a/crates/ui-egui/tests/it/track_preset_state.rs+++ b/crates/ui-egui/tests/it/track_preset_state.rs@@ -1,8 +1,6 @@ //! Track presets capture the hosted plugin's current state before writing the model. -#[path = "../../../clap-host/tests/common/mod.rs"]-mod common;-+use crate::common; use serde_json::{Value, json}; use soundcraft_engine::Engine; use soundcraft_model::{ChannelFormat, Insert, TrackKind, b64};diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs--- a/crates/vst3-host/src/ffi.rs+++ b/crates/vst3-host/src/ffi.rs@@ -1802,6 +1802,8 @@ struct EditorInner {     window: Option<window::HostWindow>, } +const NOT_HOSTED: &str = "VST3 editors are not hosted on this platform yet";+ /// The link between an instance and its editor and state handles. The instance may process on /// the audio thread while the editor (the edit controller's `IPlugView`) is driven and the state /// read from the main thread, which is VST3's own threading model (controller, views and@@ -1849,12 +1851,18 @@ impl EditorLink {         Ok((w as u32, h as u32))     } +    /// Why the editor can't open on this platform, known without opening it: SoundCraft hosts+    /// VST3 editors on macOS and Windows only.+    pub fn unavailable(&self) -> Option<String> {+        window::PLATFORM_TYPE.is_none().then(|| NOT_HOSTED.to_string())+    }+     /// Opens (or raises) the editor in a host window. Main thread only.     pub fn open(&self, title: &str) -> Result<(), String> {         if !window::on_main_thread() {             return Err("plugin editors must be opened on the main thread".into());         }-        let platform = window::PLATFORM_TYPE.ok_or_else(|| "VST3 editors are not hosted on this platform yet".to_string())?;+        let platform = window::PLATFORM_TYPE.ok_or_else(|| NOT_HOSTED.to_string())?;         let mut g = self.lock();         if let Some(w) = &g.window {             if w.is_visible() {diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs--- a/crates/vst3-host/src/plugin.rs+++ b/crates/vst3-host/src/plugin.rs@@ -489,6 +489,10 @@ impl Vst3Editor { }  impl PluginEditor for Vst3Editor {+    fn unavailable(&self) -> Option<String> {+        self.link.unavailable()+    }+     fn open(&mut self) -> Result<(), String> {         // Edits reported before the editor opened (e.g. during setup) are stale.         let _ = self.host.take_edits();diff --git a/docs/control-protocol.md b/docs/control-protocol.md--- a/docs/control-protocol.md+++ b/docs/control-protocol.md@@ -93,6 +93,21 @@ System to Dark, or on Linux to the desktop's portal setting when one is availabl track) brings the track to the top of the Edit window and the left of the Mix window, the same as Scroll Into View in the track name menu. A window not on screen catches up when it is next shown. +`window.plugin {"track":"Vox","slot":"a"}` opens an occupied insert (`slot` 0-9 or a-j) or+instrument (`"slot":"instrument"`) as a click on it does: a hosted plugin's own editor, else+SoundCraft's parameter window. Without `value` (or with `"value":true`) it opens; it replies+`{"track":…,"slot":…,"view":"editor"|"parameters","value":true}`, naming what opened.+`"view":"editor"` opens only the plugin's own editor and fails, with the reason on the status+line, when there is none here: a built-in plugin, an Audio Unit, no audio engine, an embedded-only+CLAP editor outside Windows, or a VST3 editor outside macOS and Windows. Without `view` (a+click), each of these opens the parameter window instead, without an error. `"view":"parameters"`+opens only the parameter window and fails for a plugin that isn't installed. `"value":false`+closes the named view, or both without `view`, and replies `{…,"view":<that view or+null>,"value":false}`; closing succeeds whatever is open, also on an empty slot. A `value` that+isn't true or false, or an unknown `view`, is an error. `window.hide_floating` also closes hosted+plugin editors; `window.close` closes SoundCraft's floating windows, parameter windows included,+but leaves hosted editors open.+ ## Panel docking  Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Commands target the active Edit or Mix view; floating panels stay inside the current viewport. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md--- a/docs/plugin-parity.md+++ b/docs/plugin-parity.md@@ -1,6 +1,6 @@ # Plugin parity: built-in processors and plugin hosting -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (an editor that can't open here shows a greyed-out Open Plugin Editor with the reason, and a click on its slot opens the parameter window; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1  Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins cover the rest. This doc tracks both. Our plugins have our own names and are written from@@ -44,9 +44,9 @@ User report #152: a saved instrument level does not reach the bounce. | Notes / MIDI to instruments | ✅ | ✅ | But no live MIDI input (G-02) | | State saved in sessions, presets | ✅ | ✅ | | | Create/destroy off the audio thread | ✅ | ✅ | |-| Editors: CLAP | — | 🟡 floating windows; embedded editors on Windows | Embedded on macOS/Linux missing |-| Editors: VST3 | — | 🟡 macOS (verified with a commercial pitch editor) and Windows (verified with a free JUCE compressor) | Linux views missing |-| Editors: AU | — | ❌ | Cocoa view hosting missing |+| Editors: CLAP | — | 🟡 floating windows; embedded editors on Windows | Embedded on macOS/Linux missing: such an editor's Open Plugin Editor is greyed out with the reason, found without opening it |+| Editors: VST3 | — | 🟡 macOS (verified with a commercial pitch editor) and Windows (verified with a free JUCE compressor) | Linux views missing (greyed out there, with the reason) |+| Editors: AU | — | ❌ | Cocoa view hosting missing (#501); Open Plugin Editor is greyed out with the reason | | Multiple editor pages | ✅ | 🟡 | #6 | | macOS hardened-runtime entitlement for third-party code | ✅ | ❌ | #153, #149 — signed builds refuse third-party VST3 | | Sidechain / key input | ✅ | ❌ | All three hosts |@@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce.  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Editors that can't open here (embedded-only CLAP outside Windows, VST3 outside macOS and Windows, Audio Units): Open Plugin Editor greyed out with the reason; a click on the slot opens the parameter window without an error | | 2026-10-23 | trivial | Modulation: Ring Modulator (`ring_mod`), also used by the AudioSuite Modulation menu | | 2026-10-13 | minor | VST3 host: IMessage and IAttributeList for plugins whose halves talk through messages (Line 6 Helix Stadium Native verified) | | 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) |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-16 · **Last updated:** 2026-10-24 · **Change:** minor (a click on an insert opens the plugin's own editor; open plugin slots are lit; editors that can't open here are greyed out with the reason) · **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@@ -52,6 +52,8 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Window configurations, Tile/Cascade | 🟡 | Configurations ✅; Tile/Cascade in PR #151 | | Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6) | | A plugin opens when it's inserted | ✅ | #486; a hosted plugin's own editor, else its plugin window |+| A click on an insert opens the plugin | ✅ | A hosted plugin's own editor, else its parameter window: for built-in plugins, without an audio engine, and for editors that can't open here (Audio Units, not hosted yet; embedded-only CLAP editors outside Windows; VST3 editors outside macOS and Windows), whose Open Plugin Editor is greyed out with the reason as its hint. An editor that fails to open says why on the status line. Clicking again raises an open VST3 editor, or an embedded CLAP one on Windows; a floating CLAP editor is shown again, and the plugin decides whether it comes forward. A hosted insert's right-click menu has Edit Parameters… (when the plugin is installed) and Open/Close Plugin Editor; a hosted instrument's has Open/Close Plugin Editor. `window.plugin` does the same |+| Open plugin slots are lit | ✅ | An insert or instrument slot whose editor or parameter window is open has a light face, as in Pro Tools; Mix window and Edit window Inserts columns | | Command palette (Search) | ✅ | Over every command |  ## Look and meters@@ -69,6 +71,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | A click on an insert or instrument slot opens a hosted plugin's own editor, as DAWs do; a hosted insert's parameter window moves to its menu (Edit Parameters…); slots with an open window are lit; `window.plugin`; editors that can't open here are greyed out with the reason | | 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) |