Merge #338
This commit is contained in:
commit
4fcc387571
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue