This commit is contained in:
Brandon Thomas 2026-10-13 05:51:51 -04:00
commit 4fcc387571
7 changed files with 385 additions and 50 deletions

View File

@ -88,6 +88,9 @@ pub enum ClapError {
mod api {
use super::*;
pub fn cached() -> Option<Vec<ClapDescriptor>> {
registry::cached()
}
pub fn scan() -> Vec<ClapDescriptor> {
registry::scan()
}
@ -115,6 +118,9 @@ mod api {
mod api {
use super::*;
pub fn cached() -> Option<Vec<ClapDescriptor>> {
Some(Vec::new())
}
pub fn scan() -> Vec<ClapDescriptor> {
Vec::new()
}
@ -161,6 +167,11 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec<ClapDescriptor> {
api::scan_paths(dirs)
}
/// The plugins of the last scan, without scanning: `None` until something has scanned.
pub fn cached() -> Option<Vec<ClapDescriptor>> {
api::cached()
}
/// The plugins in one `.clap` file or bundle.
pub fn load_bundle(path: &Path) -> Result<Vec<ClapDescriptor>, ClapError> {
api::load_bundle(path)

View File

@ -83,6 +83,10 @@ pub fn scan() -> Vec<ClapDescriptor> {
ensure_scanned(&mut r).clone()
}
pub fn cached() -> Option<Vec<ClapDescriptor>> {
registry().lock().unwrap_or_else(PoisonError::into_inner).scanned.clone()
}
pub fn rescan() -> Vec<ClapDescriptor> {
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
r.scanned = None;

View File

@ -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));

View File

@ -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<String> {
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<usize> {
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<String> = 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() {

View File

@ -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<SoundApp>, label: &str) {
h.get_by_label(label).click();
h.run_steps(3);
}
fn type_text(h: &mut Harness<SoundApp>, text: &str) {
h.event(egui::Event::Text(text.into()));
h.run_steps(2);
}
fn press(h: &mut Harness<SoundApp>, 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<SoundApp>, 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);
}

View File

@ -114,6 +114,9 @@ pub enum Vst3Error {
mod api {
use super::*;
pub fn cached() -> Option<Vec<Vst3Descriptor>> {
registry::cached()
}
pub fn scan() -> Vec<Vst3Descriptor> {
registry::scan()
}
@ -144,6 +147,9 @@ mod api {
mod api {
use super::*;
pub fn cached() -> Option<Vec<Vst3Descriptor>> {
Some(Vec::new())
}
pub fn scan() -> Vec<Vst3Descriptor> {
Vec::new()
}
@ -191,6 +197,11 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec<Vst3Descriptor> {
api::scan_paths(dirs)
}
/// The plugins of the last scan, without scanning: `None` until something has scanned.
pub fn cached() -> Option<Vec<Vst3Descriptor>> {
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

View File

@ -87,6 +87,10 @@ pub fn scan() -> Vec<Vst3Descriptor> {
ensure_scanned(&mut r).clone()
}
pub fn cached() -> Option<Vec<Vst3Descriptor>> {
registry().lock().unwrap_or_else(PoisonError::into_inner).scanned.clone()
}
pub fn rescan() -> Vec<Vst3Descriptor> {
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
r.scanned = None;