diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs index 2740f49..15da961 100644 --- a/crates/clap-host/src/lib.rs +++ b/crates/clap-host/src/lib.rs @@ -88,6 +88,9 @@ pub enum ClapError { mod api { use super::*; + pub fn cached() -> Option> { + registry::cached() + } pub fn scan() -> Vec { registry::scan() } @@ -115,6 +118,9 @@ mod api { mod api { use super::*; + pub fn cached() -> Option> { + Some(Vec::new()) + } pub fn scan() -> Vec { Vec::new() } @@ -161,6 +167,11 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec { api::scan_paths(dirs) } +/// The plugins of the last scan, without scanning: `None` until something has scanned. +pub fn cached() -> Option> { + api::cached() +} + /// The plugins in one `.clap` file or bundle. pub fn load_bundle(path: &Path) -> Result, ClapError> { api::load_bundle(path) diff --git a/crates/clap-host/src/registry.rs b/crates/clap-host/src/registry.rs index 32eedce..89169e5 100644 --- a/crates/clap-host/src/registry.rs +++ b/crates/clap-host/src/registry.rs @@ -83,6 +83,10 @@ pub fn scan() -> Vec { ensure_scanned(&mut r).clone() } +pub fn cached() -> Option> { + registry().lock().unwrap_or_else(PoisonError::into_inner).scanned.clone() +} + pub fn rescan() -> Vec { let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner); r.scanned = None; diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index c7e615c..592c968 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1137,7 +1137,9 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele let name = track.instrument.as_ref().and_then(|i| crate::mix_window::plugin_info(&i.plugin)).map_or("no instrument", |p| p.short_name); ui.painter().with_clip_rect(field).text(pos2(field.min.x + 5.0, field.center().y), Align2::LEFT_CENTER, name, bold(10.0), t.text); - egui::Popup::menu(&resp.on_hover_text("Instrument")).show(|ui| crate::mix_window::instrument_menu(app, ui, id)); + egui::Popup::menu(&resp.on_hover_text("Instrument")) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| crate::mix_window::instrument_menu(app, ui, id)); } else { let c = pos2(field.min.x + 7.0, field.center().y); ui.painter().line_segment([c + vec2(0.0, -3.5), c + vec2(0.0, 1.0)], Stroke::new(1.0, t.text)); diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 5622f74..ced3ca2 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -257,9 +257,11 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0)); let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).max_rect(in_r)); let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input_name(app, track), t.text); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Input")); route_popup(app, &resp, id, true); let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-output", id.0)).max_rect(out_r)); let resp = selector_box(&mut c, out_r.width(), out_r.height(), &route_name(app, &track.mixer.output), t.text); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Output")); route_popup(app, &resp, id, false); // Automation mode. let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 51.0), vec2(inner_w - 4.0, 16.0)); @@ -461,6 +463,7 @@ pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: let ins = track.instrument.as_ref(); let info = ins.and_then(|i| plugin_info(&i.plugin)); 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), None => empty_slot(ui.painter(), r, resp.hovered(), Mark::Dot), @@ -471,6 +474,7 @@ pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: } 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) }; + let popup = popup.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); popup.show(|ui| instrument_menu(app, ui, id)); } @@ -549,6 +553,7 @@ pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: let id = track.id; let ins = track.mixer.inserts.get(slot).cloned().flatten(); let resp = ui.interact(r, ui.id().with(("ins", id.0, slot)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Insert {}", (b'A' + slot as u8) as char))); let info = ins.as_ref().and_then(|i| plugin_info(&i.plugin)); let name = ins.as_ref().map(|i| info.map_or(i.plugin.as_str(), |p| p.short_name)).unwrap_or_default(); match &ins { @@ -570,16 +575,26 @@ pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: let tip = format!("{name}: click to open · {cmd}click to bypass · right-click to change"); let menu_resp = if ins.is_none() { resp.clone() } else { resp.clone().on_hover_text(tip) }; let popup = if ins.is_none() { egui::Popup::menu(&menu_resp) } else { egui::Popup::context_menu(&menu_resp) }; + let popup = popup.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); popup.show(|ui| plugin_menu(app, ui, id, slot, ins.is_some())); } +/// An insert's menu: a search field that takes typing at once, then (until something is typed) +/// Bypass and removal and the plugins by category and format. pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, occupied: bool) { + let search = MenuSearch::show(ui, egui::Id::new(("plugin-search", id.0, slot)), "Search plugins…"); + if search.typing() { + if let Some(plugin) = plugin_search(ui, &search, false) { + run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin})); + } + return; + } if occupied { if ui.button("Bypass").clicked() { - let _ = app.run("mix.insert_bypass", json!({"track": id.0, "slot": slot})); + run_pick(app, ui, "mix.insert_bypass", json!({"track": id.0, "slot": slot})); } if ui.button("no insert").clicked() { - let _ = app.run("mix.insert_remove", json!({"track": id.0, "slot": slot})); + run_pick(app, ui, "mix.insert_remove", json!({"track": id.0, "slot": slot})); } ui.separator(); } @@ -593,22 +608,28 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc ui.menu_button(format!("{c:?}"), |ui| { for p in soundcraft_dsp::plugins().iter().filter(|p| p.category == c && !p.is_instrument) { if ui.button(p.name).clicked() { - let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": p.id})); + run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": p.id})); } } }); } - if let Some(plugin) = hosted_menus(ui, false) - && let Err(e) = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin})) - { - app.ui.status = e; + if let Some(plugin) = hosted_menus(ui, false) { + run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin})); } } -/// The instrument picker of an instrument track: its instrument's editor, then the built-in, -/// CLAP, VST3 and Audio Units instruments. +/// The instrument picker of an instrument track: a search field that takes typing at once, then +/// (until something is typed) its instrument's editor and the built-in, CLAP, VST3 and Audio Units +/// instruments. pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { let slot = soundcraft_mix::INSTRUMENT_SLOT; + let search = MenuSearch::show(ui, egui::Id::new(("instrument-search", id.0)), "Search instruments…"); + if search.typing() { + if let Some(plugin) = plugin_search(ui, &search, true) { + run_pick(app, ui, "mix.instrument", json!({"track": id.0, "plugin": plugin})); + } + return; + } 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()); @@ -618,11 +639,13 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { 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() - && let Err(e) = p.open_editor(id, slot) - { - app.ui.status = format!("{}: {e}", info.name); + } 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(); } } ui.separator(); @@ -638,11 +661,136 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { if let Some(p) = hosted_menus(ui, true) { picked = Some(p); } - if let Some(plugin) = picked - && let Err(e) = app.run("mix.instrument", json!({"track": id.0, "plugin": plugin})) - { + if let Some(plugin) = picked { + run_pick(app, ui, "mix.instrument", json!({"track": id.0, "plugin": plugin})); + } +} + +/// Runs a menu's pick, shows its error in the status line and closes the menu. +fn run_pick(app: &mut SoundApp, ui: &Ui, cmd: &str, params: serde_json::Value) { + if let Err(e) = app.run(cmd, params) { app.ui.status = e; } + ui.close(); +} + +/// The plugins whose name or vendor holds the search text: the built-in ones, then Audio Units +/// (listing them creates no instances) and whichever of CLAP and VST3 have been scanned, with a +/// button to scan each of the others. Returns the id picked. +fn plugin_search(ui: &mut Ui, search: &MenuSearch, instruments: bool) -> Option { + let mut built_in: Vec<&soundcraft_dsp::PluginInfo> = + soundcraft_dsp::plugins().iter().copied().filter(|p| p.is_instrument == instruments && search.matches(p.name)).collect(); + built_in.sort_by_cached_key(|p| p.name.to_lowercase()); + let (clap, vst3) = (soundcraft_clap_host::cached(), soundcraft_vst3_host::cached()); + let mut hosted: Vec<(MenuPlugin, &str)> = Vec::new(); + for d in clap.iter().flatten().filter(|d| d.is_instrument == instruments) { + hosted.push((MenuPlugin { id: d.id.clone(), name: d.name.clone(), vendor: d.vendor.clone() }, "CLAP")); + } + for d in vst3.iter().flatten().filter(|d| d.is_instrument == instruments) { + hosted.push((MenuPlugin { id: d.id.clone(), name: d.name.clone(), vendor: d.vendor.clone() }, "VST3")); + } + for d in soundcraft_au_host::scan().into_iter().filter(|d| d.is_instrument == instruments) { + hosted.push((MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }, "AU")); + } + hosted.retain(|(p, _)| search.matches(&p.name) || search.matches(&p.vendor)); + hosted.sort_by_cached_key(|(p, format)| (p.name.to_lowercase(), *format)); + let n = built_in.len() + hosted.len(); + let picked = search.rows(ui, n, 320.0, |ui, i, highlighted| match built_in.get(i) { + Some(p) => menu_row(ui, highlighted, p.name), + None => { + let (p, format) = &hosted[i - built_in.len()]; + let label = if p.vendor.is_empty() { format!("{} ({format})", p.name) } else { format!("{} — {} ({format})", p.name, p.vendor) }; + menu_row(ui, highlighted, &label) + } + }); + if n == 0 { + ui.weak("No matches"); + } + if clap.is_none() || vst3.is_none() { + ui.separator(); + ui.horizontal(|ui| { + if clap.is_none() && ui.small_button("Scan CLAP").clicked() { + soundcraft_clap_host::scan(); + } + if vst3.is_none() && ui.small_button("Scan VST3").clicked() { + soundcraft_vst3_host::scan(); + } + }); + } + picked.map(|i| built_in.get(i).map_or_else(|| hosted[i - built_in.len()].0.id.clone(), |p| p.id.to_string())) +} + +/// A picker menu's search field. It has the keyboard as soon as the menu opens and starts empty +/// each time; Up/Down move the highlight through the matches and Enter picks it. +struct MenuSearch { + id: egui::Id, + query: String, + highlight: usize, + keys: [bool; 3], +} + +impl MenuSearch { + fn show(ui: &mut Ui, id: egui::Id, hint: &str) -> Self { + let pass = ui.ctx().cumulative_pass_nr(); + // Kept only while the menu stays open: one not shown last pass has just opened. + let kept = ui.data(|d| d.get_temp::<(String, usize, u64)>(id)).filter(|s| s.2 + 1 >= pass); + let opened = kept.is_none(); + let (mut text, mut highlight, _) = kept.unwrap_or_default(); + let field = ui.add(egui::TextEdit::singleline(&mut text).id(id.with("field")).hint_text(hint).return_key(None)); + if opened { + field.request_focus(); + } + if field.changed() { + highlight = 0; + } + // egui's focus, as typing uses: `has_focus` also wants the window focused, which scripted input lacks. + let focused = ui.memory(|m| m.has_focus(field.id)); + let keys = [egui::Key::ArrowUp, egui::Key::ArrowDown, egui::Key::Enter].map(|k| focused && ui.input(|i| i.key_pressed(k))); + ui.data_mut(|d| d.insert_temp(id, (text.clone(), highlight, pass))); + Self { id, query: text.trim().to_lowercase(), highlight, keys } + } + + fn typing(&self) -> bool { + !self.query.is_empty() + } + + fn matches(&self, text: &str) -> bool { + text.to_lowercase().contains(&self.query) + } + + /// `n` matches in a scrolling list, each drawn by `row` (told whether it is highlighted); + /// returns the one clicked, or the highlighted one on Enter. + fn rows(&self, ui: &mut Ui, n: usize, max_height: f32, mut row: impl FnMut(&mut Ui, usize, bool) -> bool) -> Option { + let [up, down, enter] = self.keys; + let typing = self.typing(); + let highlight = match (up, down) { + (_, true) => self.highlight + 1, + (true, _) => self.highlight.saturating_sub(1), + _ => self.highlight, + } + .min(n.saturating_sub(1)); + ui.data_mut(|d| d.get_temp_mut_or_default::<(String, usize, u64)>(self.id).1 = highlight); + let mut picked = (typing && enter && n > 0).then_some(highlight); + let row_h = ui.spacing().interact_size.y; + let step = row_h + ui.spacing().item_spacing.y; + egui::ScrollArea::vertical().id_salt(self.id.with("rows")).max_height(max_height).show_rows(ui, row_h, n, |ui, range| { + if typing && (up || down) { + let top = ui.max_rect().top() + (highlight as f32 - range.start as f32) * step; + ui.scroll_to_rect(Rect::from_min_size(pos2(ui.max_rect().left(), top), vec2(1.0, row_h)), None); + } + for i in range { + if row(ui, i, typing && i == highlight) { + picked = Some(i); + } + } + }); + picked + } +} + +fn menu_row(ui: &mut Ui, selected: bool, text: &str) -> bool { + let text = if selected { egui::RichText::new(text).color(Color32::WHITE) } else { egui::RichText::new(text) }; + ui.add(egui::Button::selectable(selected, text).min_size(vec2(0.0, ui.spacing().interact_size.y))).clicked() } /// A hosted plugin as the plugin menus list it. @@ -727,6 +875,7 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us let level_r = if compact { r } else { level_icon(r).expand2(vec2(3.0, 0.0)).with_min_y(r.min.y).with_max_y(r.max.y) }; let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y)); let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click()); + route_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {}", (b'A' + slot as u8) as char))); let mark = if compact { Mark::Arrow } else { Mark::Letter((b'a' + slot as u8) as char) }; match &snd { Some(s) => { @@ -1034,26 +1183,16 @@ fn send_level_scale(ui: &mut Ui, db: f32) { fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, current: Option<&Route>) { let busses = app.engine.session().busses.clone(); - let search_id = egui::Id::new(("send-bus-search", id.0, slot)); - let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); - ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search buses…")); - ui.data_mut(|d| d.insert_temp(search_id, search.clone())); - let needle = search.trim().to_ascii_lowercase(); - let visible: Vec<_> = busses.iter().filter(|bus| needle.is_empty() || bus.name.to_ascii_lowercase().contains(&needle)).collect(); - egui::ScrollArea::vertical().id_salt(("send-route-busses", id.0, slot)).max_height(260.0).auto_shrink([false, true]).show_rows( - ui, - 22.0, - visible.len(), - |ui, rows| { - for bus in &visible[rows] { - let selected = matches!(current, Some(Route::Bus(bus_id)) if *bus_id == bus.id); - if ui.selectable_label(selected, &bus.name).clicked() { - let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus.name})); - ui.close(); - } - } - }, - ); + let search = MenuSearch::show(ui, egui::Id::new(("send-bus-search", id.0, slot)), "Search buses…"); + let visible: Vec<_> = busses.iter().filter(|bus| search.matches(&bus.name)).collect(); + let picked = search.rows(ui, visible.len(), 260.0, |ui, i, highlighted| { + let current = matches!(current, Some(Route::Bus(bus_id)) if *bus_id == visible[i].id); + menu_row(ui, highlighted || (current && !search.typing()), &visible[i].name) + }); + if let Some(i) = picked { + let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": visible[i].name})); + ui.close(); + } ui.separator(); if ui.button("new bus...").clicked() { let mut n = busses.len() + 1; @@ -1088,19 +1227,12 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { ui.separator(); let busses: Vec = app.engine.session().busses.iter().map(|b| b.name.clone()).collect(); ui.strong("Bus"); - let search_id = egui::Id::new(("route-bus-search", id.0, input)); - let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); - ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search buses…")); - ui.data_mut(|d| d.insert_temp(search_id, search.clone())); - let needle = search.trim().to_ascii_lowercase(); - egui::ScrollArea::vertical().id_salt(("track-route-busses", id.0, input)).max_height(420.0).show(ui, |ui| { - for b in busses.iter().filter(|b| needle.is_empty() || b.to_ascii_lowercase().contains(&needle)) { - if ui.button(b).clicked() { - let _ = app.run(cmd, json!({"track": id.0, key: b})); - ui.close(); - } - } - }); + let search = MenuSearch::show(ui, egui::Id::new(("route-bus-search", id.0, input)), "Search buses…"); + let visible: Vec<&String> = busses.iter().filter(|b| search.matches(b)).collect(); + if let Some(i) = search.rows(ui, visible.len(), 420.0, |ui, i, highlighted| menu_row(ui, highlighted, visible[i])) { + let _ = app.run(cmd, json!({"track": id.0, key: visible[i]})); + ui.close(); + } if ui.button("new bus...").clicked() { let mut n = busses.len() + 1; while busses.contains(&format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() { diff --git a/crates/ui-egui/tests/menu_search.rs b/crates/ui-egui/tests/menu_search.rs new file mode 100644 index 0000000..0f2c7f5 --- /dev/null +++ b/crates/ui-egui/tests/menu_search.rs @@ -0,0 +1,171 @@ +//! Opening an insert, instrument, send or I/O menu gives its search field the keyboard: typing +//! filters at once, Up/Down move through the matches and Enter picks one. + +use egui::accesskit::Role; +use egui::{Key, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_model::Route; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp}; + +fn harness(engine: soundcraft_engine::Engine) -> Harness<'static, SoundApp> { + let mut app = SoundApp::new(engine, None, 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 +} + +fn open(h: &mut Harness, label: &str) { + h.get_by_label(label).click(); + h.run_steps(3); +} + +fn type_text(h: &mut Harness, text: &str) { + h.event(egui::Event::Text(text.into())); + h.run_steps(2); +} + +fn press(h: &mut Harness, key: Key) { + h.key_press(key); + h.run_steps(2); +} + +/// Built-in plugins whose name holds `text`, as the search lists them. +fn built_in(instruments: bool, text: &str) -> Vec<&'static soundcraft_dsp::PluginInfo> { + let mut found: Vec<_> = + soundcraft_dsp::plugins().iter().copied().filter(|p| p.is_instrument == instruments && p.name.to_lowercase().contains(text)).collect(); + found.sort_by_key(|p| p.name.to_lowercase()); + found +} + +fn vox() -> soundcraft_engine::Engine { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"name": "Vox"})).unwrap(); + engine +} + +#[test] +fn an_insert_menu_searches_as_soon_as_it_opens() { + let reverbs = built_in(false, "reverb"); + assert!(reverbs.len() >= 2, "the test needs two built-in reverbs"); + let mut h = harness(vox()); + open(&mut h, "Insert A"); + type_text(&mut h, "reverb"); + assert!(h.query_by_label(reverbs[0].name).is_some(), "typing went to the search"); + assert!(h.query_by_label_contains("Dynamics").is_none(), "the matches replace the categories"); + + h.get_by_role(Role::TextInput).click(); + h.run_steps(3); + assert!(h.query_by_label(reverbs[0].name).is_some(), "a click in the field keeps the menu open"); + + press(&mut h, Key::ArrowUp); + press(&mut h, Key::ArrowDown); + press(&mut h, Key::Enter); + let insert = h.state().engine.session().tracks[0].mixer.inserts[0].clone().expect("Enter inserts the highlighted plugin"); + assert_eq!(insert.plugin, reverbs[1].id); + assert!(h.query_by_label(reverbs[0].name).is_none(), "picking closes the menu"); +} + +#[test] +fn a_menu_opens_with_an_empty_search() { + let mut h = harness(vox()); + open(&mut h, "Insert B"); + type_text(&mut h, "reverb"); + press(&mut h, Key::Escape); + assert!(h.query_by_label(built_in(false, "reverb")[0].name).is_none(), "Escape closes the menu"); + + open(&mut h, "Insert B"); + let category = format!("{:?}", soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap().category); + assert!(h.query_by_label_contains(&category).is_some(), "the old search is gone, so the categories show"); + type_text(&mut h, "zzzz"); + assert!(h.query_by_label("No matches").is_some()); + press(&mut h, Key::Enter); + assert!(h.state().engine.session().tracks[0].mixer.inserts.iter().all(Option::is_none), "Enter without a match picks nothing"); +} + +#[test] +fn picks_without_the_search_still_close_the_menu() { + let first = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap(); + let other = soundcraft_dsp::plugins().iter().find(|p| p.category == first.category && p.id != first.id).unwrap(); + let category = format!("{:?} ⏵", first.category); + let mut h = harness(vox()); + for (plugin, click) in [(first, egui::PointerButton::Primary), (other, egui::PointerButton::Secondary)] { + h.get_by_label("Insert C").click_button(click); + h.run_steps(3); + h.get_by_label(&category).hover(); + h.run_steps(3); + h.get_by_label(plugin.name).click(); + h.run_steps(3); + assert_eq!(h.state().engine.session().tracks[0].mixer.inserts[2].as_ref().unwrap().plugin, plugin.id); + assert!(h.query_by_label(&category).is_none(), "a plugin from a category closes the menu"); + } + + for (item, bypassed) in [("Bypass", Some(true)), ("no insert", None)] { + h.get_by_label("Insert C").click_secondary(); + h.run_steps(3); + h.get_by_label(item).click(); + h.run_steps(3); + assert_eq!(h.state().engine.session().tracks[0].mixer.inserts[2].as_ref().map(|i| i.bypass), bypassed); + assert!(h.query_by_label(item).is_none(), "{item} closes the menu"); + } +} + +#[test] +fn an_instrument_menu_searches_as_soon_as_it_opens() { + let drums = built_in(true, "drum"); + assert!(!drums.is_empty(), "the test needs a built-in drum instrument"); + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"kind": "instrument", "name": "Keys"})).unwrap(); + let mut h = harness(engine); + h.state_mut().ui.mix_views.push("instruments".into()); + h.run_steps(2); + open(&mut h, "Instrument"); + type_text(&mut h, "drum"); + press(&mut h, Key::Enter); + let instrument = h.state().engine.session().tracks[0].instrument.clone().unwrap(); + assert_eq!(instrument.plugin, drums[0].id); + assert!(h.query_by_label(drums[0].name).is_none(), "picking closes the menu"); +} + +#[test] +fn send_and_output_menus_search_buses_as_soon_as_they_open() { + let mut engine = vox(); + let vox = engine.session().tracks[0].id; + for (slot, bus) in [(0, "Verb"), (1, "Delay")] { + engine.execute("mix.send", &json!({"track": vox.0, "slot": slot, "bus": bus})).unwrap(); + } + let mut h = harness(engine); + let bus = |h: &Harness, name: &str| h.state().engine.session().busses.iter().find(|b| b.name == name).unwrap().id; + + open(&mut h, "Send C"); + type_text(&mut h, "del"); + assert!(h.query_by_label("Verb").is_none()); + press(&mut h, Key::Enter); + let send = h.state().engine.session().tracks[0].mixer.sends[2].clone().expect("Enter picks the bus"); + assert_eq!(send.target, Route::Bus(bus(&h, "Delay"))); + + open(&mut h, "Output"); + type_text(&mut h, "ver"); + press(&mut h, Key::Enter); + assert_eq!(h.state().engine.session().tracks[0].mixer.output, Route::Bus(bus(&h, "Verb"))); +} + +#[test] +fn scripted_keys_reach_the_search_with_the_window_in_the_background() { + let plates = built_in(false, "plate"); + let mut h = harness(vox()); + h.input_mut().focused = false; + open(&mut h, "Insert A"); + type_text(&mut h, "plate"); + press(&mut h, Key::Enter); + let insert = h.state().engine.session().tracks[0].mixer.inserts[0].clone().expect("Enter works without window focus, as typing does"); + assert_eq!(insert.plugin, plates[0].id); +} diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs index b872dec..17580bb 100644 --- a/crates/vst3-host/src/lib.rs +++ b/crates/vst3-host/src/lib.rs @@ -114,6 +114,9 @@ pub enum Vst3Error { mod api { use super::*; + pub fn cached() -> Option> { + registry::cached() + } pub fn scan() -> Vec { registry::scan() } @@ -144,6 +147,9 @@ mod api { mod api { use super::*; + pub fn cached() -> Option> { + Some(Vec::new()) + } pub fn scan() -> Vec { Vec::new() } @@ -191,6 +197,11 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec { api::scan_paths(dirs) } +/// The plugins of the last scan, without scanning: `None` until something has scanned. +pub fn cached() -> Option> { + api::cached() +} + /// Shuts plugin hosting down: runs the module exit of every loaded binary that has no live /// instance left, each on the thread that loaded it; later loads fail. Call it on the main /// thread once the plugin instances are gone, before the process exits. Without it the exits diff --git a/crates/vst3-host/src/registry.rs b/crates/vst3-host/src/registry.rs index 453fbe3..629b8f3 100644 --- a/crates/vst3-host/src/registry.rs +++ b/crates/vst3-host/src/registry.rs @@ -87,6 +87,10 @@ pub fn scan() -> Vec { ensure_scanned(&mut r).clone() } +pub fn cached() -> Option> { + registry().lock().unwrap_or_else(PoisonError::into_inner).scanned.clone() +} + pub fn rescan() -> Vec { let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner); r.scanned = None;