563 lines
20 KiB
Rust
563 lines
20 KiB
Rust
/// Instrument Browser pane — browse, search, load, and save instrument presets
|
|
///
|
|
/// Scans factory presets from `src/assets/instruments/` organized by category.
|
|
/// Presets are loaded into the currently selected track's audio graph.
|
|
|
|
use eframe::egui;
|
|
use std::path::PathBuf;
|
|
use super::{NodePath, PaneRenderer, SharedPaneState};
|
|
|
|
/// Format of a preset file
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
enum PresetFormat {
|
|
Json,
|
|
Lbins,
|
|
}
|
|
|
|
/// Metadata extracted from a preset file
|
|
struct PresetInfo {
|
|
name: String,
|
|
path: PathBuf,
|
|
format: PresetFormat,
|
|
category: String,
|
|
description: String,
|
|
author: String,
|
|
tags: Vec<String>,
|
|
is_factory: bool,
|
|
}
|
|
|
|
/// State for the save-preset dialog
|
|
struct SaveDialogState {
|
|
name: String,
|
|
description: String,
|
|
tags_str: String,
|
|
}
|
|
|
|
impl Default for SaveDialogState {
|
|
fn default() -> Self {
|
|
Self {
|
|
name: String::new(),
|
|
description: String::new(),
|
|
tags_str: String::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct PresetBrowserPane {
|
|
presets: Vec<PresetInfo>,
|
|
search_query: String,
|
|
/// Index into `self.presets` of the currently selected preset
|
|
selected_index: Option<usize>,
|
|
selected_category: Option<String>,
|
|
needs_reload: bool,
|
|
save_dialog: Option<SaveDialogState>,
|
|
/// Sorted unique category names extracted from presets
|
|
categories: Vec<String>,
|
|
}
|
|
|
|
impl PresetBrowserPane {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
presets: Vec::new(),
|
|
search_query: String::new(),
|
|
selected_index: None,
|
|
selected_category: None,
|
|
needs_reload: true,
|
|
save_dialog: None,
|
|
categories: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Scan preset directories and populate the preset list
|
|
fn scan_presets(&mut self) {
|
|
self.presets.clear();
|
|
self.categories.clear();
|
|
|
|
// Factory presets: check installed location first, fall back to dev source tree
|
|
let factory_dirs = [
|
|
// Installed location (Linux packages / AppImage)
|
|
PathBuf::from("/usr/share/lightningbeam-editor/presets"),
|
|
// Next to the binary (AppImage / portable)
|
|
std::env::current_exe()
|
|
.ok()
|
|
.and_then(|p| p.parent().map(|d| d.join("presets")))
|
|
.unwrap_or_default(),
|
|
// Development: relative to CARGO_MANIFEST_DIR
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../src/assets/instruments"),
|
|
];
|
|
|
|
for dir in &factory_dirs {
|
|
if let Ok(factory_dir) = dir.canonicalize() {
|
|
if factory_dir.is_dir() {
|
|
self.scan_directory(&factory_dir, &factory_dir, true);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// User presets
|
|
let user_dir = user_presets_dir();
|
|
if user_dir.is_dir() {
|
|
self.scan_directory(&user_dir, &user_dir, false);
|
|
}
|
|
|
|
// Sort presets alphabetically by name within each category
|
|
self.presets.sort_by(|a, b| {
|
|
a.category.cmp(&b.category).then(a.name.cmp(&b.name))
|
|
});
|
|
|
|
// Extract unique categories
|
|
let mut cats: Vec<String> = self.presets.iter()
|
|
.map(|p| p.category.clone())
|
|
.collect();
|
|
cats.sort();
|
|
cats.dedup();
|
|
self.categories = cats;
|
|
|
|
self.needs_reload = false;
|
|
}
|
|
|
|
/// Recursively scan a directory for .json preset files
|
|
fn scan_directory(&mut self, dir: &std::path::Path, base_dir: &std::path::Path, is_factory: bool) {
|
|
let entries = match std::fs::read_dir(dir) {
|
|
Ok(entries) => entries,
|
|
Err(_) => return,
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
self.scan_directory(&path, base_dir, is_factory);
|
|
} else {
|
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
|
if ext == "json" || ext == "lbins" {
|
|
if let Some(info) = self.load_preset_info(&path, base_dir, is_factory) {
|
|
self.presets.push(info);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load metadata from a preset file (.json or .lbins)
|
|
fn load_preset_info(&self, path: &std::path::Path, base_dir: &std::path::Path, is_factory: bool) -> Option<PresetInfo> {
|
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
|
let (preset, format) = if ext == "lbins" {
|
|
let (p, _assets) = daw_backend::audio::node_graph::lbins::load_lbins(path).ok()?;
|
|
(p, PresetFormat::Lbins)
|
|
} else {
|
|
let contents = std::fs::read_to_string(path).ok()?;
|
|
let p: daw_backend::audio::node_graph::GraphPreset =
|
|
serde_json::from_str(&contents).ok()?;
|
|
(p, PresetFormat::Json)
|
|
};
|
|
|
|
// Category = first directory component relative to base_dir
|
|
let relative = path.strip_prefix(base_dir).ok()?;
|
|
let category = relative.components().next()
|
|
.and_then(|c| c.as_os_str().to_str())
|
|
.unwrap_or("other")
|
|
.to_string();
|
|
|
|
Some(PresetInfo {
|
|
name: preset.metadata.name,
|
|
path: path.to_path_buf(),
|
|
format,
|
|
category,
|
|
description: preset.metadata.description,
|
|
author: preset.metadata.author,
|
|
tags: preset.metadata.tags,
|
|
is_factory,
|
|
})
|
|
}
|
|
|
|
/// Get indices of presets matching the current search query and category filter
|
|
fn filtered_indices(&self) -> Vec<usize> {
|
|
let query = self.search_query.to_lowercase();
|
|
self.presets.iter().enumerate()
|
|
.filter(|(_, p)| {
|
|
// Category filter
|
|
if let Some(ref cat) = self.selected_category {
|
|
if &p.category != cat {
|
|
return false;
|
|
}
|
|
}
|
|
// Search filter
|
|
if !query.is_empty() {
|
|
let name_match = p.name.to_lowercase().contains(&query);
|
|
let desc_match = p.description.to_lowercase().contains(&query);
|
|
let tag_match = p.tags.iter().any(|t| t.to_lowercase().contains(&query));
|
|
if !name_match && !desc_match && !tag_match {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
})
|
|
.map(|(i, _)| i)
|
|
.collect()
|
|
}
|
|
|
|
/// Load the selected preset into the current track
|
|
fn load_preset(&self, preset_index: usize, shared: &mut SharedPaneState) {
|
|
let preset = &self.presets[preset_index];
|
|
|
|
let track_id = match shared.active_layer_id.and_then(|lid| shared.layer_to_track_map.get(&lid)) {
|
|
Some(&tid) => tid,
|
|
None => return,
|
|
};
|
|
|
|
if let Some(audio_controller) = &shared.audio_controller {
|
|
let mut controller = audio_controller.lock().unwrap();
|
|
match preset.format {
|
|
PresetFormat::Json => {
|
|
controller.graph_load_preset(track_id, preset.path.to_string_lossy().to_string());
|
|
}
|
|
PresetFormat::Lbins => {
|
|
controller.graph_load_lbins(track_id, preset.path.clone());
|
|
}
|
|
}
|
|
}
|
|
// Note: project_generation is incremented by the GraphPresetLoaded event handler
|
|
// in main.rs, which fires after the audio thread has actually processed the load.
|
|
// This avoids a race where the node graph queries stale backend state.
|
|
}
|
|
|
|
/// Render the save preset dialog
|
|
fn render_save_dialog(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) {
|
|
let dialog = match &mut self.save_dialog {
|
|
Some(d) => d,
|
|
None => return,
|
|
};
|
|
|
|
ui.add_space(8.0);
|
|
ui.heading("Save Preset");
|
|
ui.add_space(4.0);
|
|
|
|
ui.horizontal(|ui| {
|
|
ui.label("Name:");
|
|
ui.text_edit_singleline(&mut dialog.name);
|
|
});
|
|
|
|
ui.add_space(4.0);
|
|
ui.label("Description:");
|
|
ui.add(egui::TextEdit::multiline(&mut dialog.description)
|
|
.desired_rows(3)
|
|
.desired_width(f32::INFINITY));
|
|
|
|
ui.add_space(4.0);
|
|
ui.horizontal(|ui| {
|
|
ui.label("Tags:");
|
|
ui.text_edit_singleline(&mut dialog.tags_str);
|
|
});
|
|
ui.label(egui::RichText::new("Comma-separated, e.g. bass, synth, warm")
|
|
.small()
|
|
.color(ui.visuals().weak_text_color()));
|
|
|
|
ui.add_space(8.0);
|
|
let name_valid = !dialog.name.trim().is_empty();
|
|
let mut do_save = false;
|
|
let mut do_cancel = false;
|
|
ui.horizontal(|ui| {
|
|
if ui.add_enabled(name_valid, egui::Button::new("Save")).clicked() {
|
|
do_save = true;
|
|
}
|
|
if ui.button("Cancel").clicked() {
|
|
do_cancel = true;
|
|
}
|
|
});
|
|
|
|
// Act after dialog borrow is released
|
|
if do_save {
|
|
self.do_save_preset(shared);
|
|
} else if do_cancel {
|
|
self.save_dialog = None;
|
|
}
|
|
}
|
|
|
|
/// Execute the save action
|
|
fn do_save_preset(&mut self, shared: &mut SharedPaneState) {
|
|
let dialog = match self.save_dialog.take() {
|
|
Some(d) => d,
|
|
None => return,
|
|
};
|
|
|
|
let track_id = match shared.active_layer_id.and_then(|lid| shared.layer_to_track_map.get(&lid)) {
|
|
Some(&tid) => tid,
|
|
None => return,
|
|
};
|
|
|
|
let name = dialog.name.trim().to_string();
|
|
let description = dialog.description.trim().to_string();
|
|
let tags: Vec<String> = dialog.tags_str.split(',')
|
|
.map(|t| t.trim().to_string())
|
|
.filter(|t| !t.is_empty())
|
|
.collect();
|
|
|
|
// Save to user presets directory
|
|
let save_dir = user_presets_dir();
|
|
if let Err(e) = std::fs::create_dir_all(&save_dir) {
|
|
eprintln!("Failed to create presets directory: {}", e);
|
|
return;
|
|
}
|
|
|
|
let filename = sanitize_filename(&name);
|
|
let save_path = save_dir.join(format!("{}.json", filename));
|
|
|
|
if let Some(audio_controller) = &shared.audio_controller {
|
|
let mut controller = audio_controller.lock().unwrap();
|
|
controller.graph_save_preset(
|
|
track_id,
|
|
save_path.to_string_lossy().to_string(),
|
|
name,
|
|
description,
|
|
tags,
|
|
);
|
|
}
|
|
|
|
self.needs_reload = true;
|
|
}
|
|
}
|
|
|
|
/// Get the user presets directory ($XDG_DATA_HOME/lightningbeam/presets or ~/.local/share/lightningbeam/presets)
|
|
fn user_presets_dir() -> PathBuf {
|
|
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
|
PathBuf::from(xdg).join("lightningbeam").join("presets")
|
|
} else if let Ok(home) = std::env::var("HOME") {
|
|
PathBuf::from(home).join(".local/share/lightningbeam/presets")
|
|
} else {
|
|
PathBuf::from("presets")
|
|
}
|
|
}
|
|
|
|
/// Sanitize a string for use as a filename
|
|
fn sanitize_filename(name: &str) -> String {
|
|
name.chars()
|
|
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' { c } else { '_' })
|
|
.collect::<String>()
|
|
.trim()
|
|
.to_string()
|
|
}
|
|
|
|
impl PaneRenderer for PresetBrowserPane {
|
|
fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool {
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
let has_track = shared.active_layer_id
|
|
.and_then(|lid| shared.layer_to_track_map.get(&lid))
|
|
.is_some();
|
|
if ui.add_enabled(has_track, egui::Button::new("Save")).clicked() {
|
|
self.save_dialog = Some(SaveDialogState::default());
|
|
}
|
|
});
|
|
true
|
|
}
|
|
|
|
fn render_content(
|
|
&mut self,
|
|
ui: &mut egui::Ui,
|
|
rect: egui::Rect,
|
|
_path: &NodePath,
|
|
shared: &mut SharedPaneState,
|
|
) {
|
|
if self.needs_reload {
|
|
self.scan_presets();
|
|
}
|
|
|
|
// Background
|
|
let bg_style = shared.theme.style(".pane-content", ui.ctx());
|
|
let bg_color = bg_style.background_color().unwrap_or(egui::Color32::from_rgb(47, 47, 47));
|
|
ui.painter().rect_filled(rect, 0.0, bg_color);
|
|
|
|
let text_color = shared.theme.style(".text-primary", ui.ctx())
|
|
.text_color.unwrap_or(egui::Color32::from_gray(246));
|
|
let text_secondary = shared.theme.style(".text-secondary", ui.ctx())
|
|
.text_color.unwrap_or(egui::Color32::from_gray(170));
|
|
|
|
let content_rect = rect.shrink(4.0);
|
|
let mut content_ui = ui.new_child(
|
|
egui::UiBuilder::new()
|
|
.max_rect(content_rect)
|
|
.layout(egui::Layout::top_down(egui::Align::LEFT)),
|
|
);
|
|
let ui = &mut content_ui;
|
|
|
|
// Save dialog takes over the content area
|
|
if self.save_dialog.is_some() {
|
|
self.render_save_dialog(ui, shared);
|
|
return;
|
|
}
|
|
|
|
// Search bar
|
|
ui.horizontal(|ui| {
|
|
ui.label("Search:");
|
|
ui.text_edit_singleline(&mut self.search_query);
|
|
});
|
|
|
|
ui.add_space(4.0);
|
|
|
|
// Category chips
|
|
ui.horizontal_wrapped(|ui| {
|
|
let all_selected = self.selected_category.is_none();
|
|
if ui.selectable_label(all_selected, "All").clicked() {
|
|
self.selected_category = None;
|
|
self.selected_index = None;
|
|
}
|
|
for cat in &self.categories.clone() {
|
|
let is_selected = self.selected_category.as_ref() == Some(cat);
|
|
let display = capitalize_first(cat);
|
|
if ui.selectable_label(is_selected, &display).clicked() {
|
|
if is_selected {
|
|
self.selected_category = None;
|
|
} else {
|
|
self.selected_category = Some(cat.clone());
|
|
}
|
|
self.selected_index = None;
|
|
}
|
|
}
|
|
});
|
|
|
|
ui.separator();
|
|
|
|
// Preset list
|
|
let filtered = self.filtered_indices();
|
|
|
|
if filtered.is_empty() {
|
|
ui.centered_and_justified(|ui| {
|
|
ui.label(egui::RichText::new("No presets found")
|
|
.color(text_secondary));
|
|
});
|
|
return;
|
|
}
|
|
|
|
let mut load_index = None;
|
|
let mut delete_path = None;
|
|
|
|
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
|
let mut new_selection = self.selected_index;
|
|
|
|
for &idx in &filtered {
|
|
let preset = &self.presets[idx];
|
|
let is_selected = self.selected_index == Some(idx);
|
|
|
|
let response = ui.push_id(idx, |ui| {
|
|
let frame = egui::Frame::NONE
|
|
.inner_margin(egui::Margin::same(6))
|
|
.corner_radius(4.0);
|
|
|
|
let mut button_clicked = false;
|
|
|
|
let frame_response = frame.show(ui, |ui| {
|
|
ui.set_min_width(ui.available_width());
|
|
|
|
ui.label(
|
|
egui::RichText::new(&preset.name).strong().color(text_color)
|
|
);
|
|
|
|
if is_selected {
|
|
if !preset.description.is_empty() {
|
|
ui.label(egui::RichText::new(&preset.description)
|
|
.color(text_secondary)
|
|
.small());
|
|
}
|
|
|
|
if !preset.tags.is_empty() {
|
|
ui.horizontal_wrapped(|ui| {
|
|
for tag in &preset.tags {
|
|
let tag_frame = egui::Frame::NONE
|
|
.inner_margin(egui::Margin::symmetric(6, 2))
|
|
.corner_radius(8.0)
|
|
.fill(ui.visuals().selection.bg_fill.linear_multiply(0.3));
|
|
tag_frame.show(ui, |ui| {
|
|
ui.label(egui::RichText::new(tag).small().color(text_color));
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
ui.horizontal(|ui| {
|
|
if !preset.author.is_empty() {
|
|
ui.label(egui::RichText::new(format!("by {}", preset.author))
|
|
.small()
|
|
.color(text_secondary));
|
|
}
|
|
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if !preset.is_factory {
|
|
if ui.small_button("Delete").clicked() {
|
|
delete_path = Some(preset.path.clone());
|
|
button_clicked = true;
|
|
}
|
|
}
|
|
|
|
let has_track = shared.active_layer_id
|
|
.and_then(|lid| shared.layer_to_track_map.get(&lid))
|
|
.is_some();
|
|
if ui.add_enabled(has_track, egui::Button::new("Load")).clicked() {
|
|
load_index = Some(idx);
|
|
button_clicked = true;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
// Hover highlight and click-to-select (no ui.interact overlay)
|
|
let frame_rect = frame_response.response.rect;
|
|
let is_hovered = ui.rect_contains_pointer(frame_rect);
|
|
|
|
let fill = if is_selected {
|
|
ui.visuals().selection.bg_fill.linear_multiply(0.3)
|
|
} else if is_hovered {
|
|
ui.visuals().widgets.hovered.bg_fill.linear_multiply(0.3)
|
|
} else {
|
|
egui::Color32::TRANSPARENT
|
|
};
|
|
if fill != egui::Color32::TRANSPARENT {
|
|
ui.painter().rect_filled(frame_rect, 4.0, fill);
|
|
}
|
|
|
|
if is_hovered {
|
|
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
|
}
|
|
if is_hovered && !button_clicked && ui.input(|i| i.pointer.any_released()) {
|
|
new_selection = if is_selected { None } else { Some(idx) };
|
|
}
|
|
});
|
|
|
|
let rect = response.response.rect;
|
|
ui.painter().line_segment(
|
|
[rect.left_bottom(), rect.right_bottom()],
|
|
egui::Stroke::new(0.5, ui.visuals().widgets.noninteractive.bg_stroke.color),
|
|
);
|
|
}
|
|
|
|
self.selected_index = new_selection;
|
|
});
|
|
|
|
// Deferred actions after ScrollArea borrow is released
|
|
if let Some(idx) = load_index {
|
|
self.load_preset(idx, shared);
|
|
// Signal that we're expecting a GraphPresetLoaded event so the
|
|
// repaint loop stays alive until the audio thread responds.
|
|
shared.pending_graph_loads.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
if let Some(path) = delete_path {
|
|
if let Err(e) = std::fs::remove_file(&path) {
|
|
eprintln!("Failed to delete preset: {e}");
|
|
}
|
|
self.needs_reload = true;
|
|
}
|
|
}
|
|
|
|
fn name(&self) -> &str {
|
|
"Instrument Browser"
|
|
}
|
|
}
|
|
|
|
fn capitalize_first(s: &str) -> String {
|
|
let mut chars = s.chars();
|
|
match chars.next() {
|
|
None => String::new(),
|
|
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
|
}
|
|
}
|