diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs index 1ccaa62..612086c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs @@ -6,6 +6,7 @@ use eframe::egui; use egui_node_graph2::*; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use crate::widgets; /// Signal types for audio node graph #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -887,9 +888,10 @@ impl NodeDataTrait for NodeData { let mut close_popup = false; egui::Popup::from_toggle_button_response(&button) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .width(160.0) .show(|ui| { - ui.set_min_width(200.0); - ui.text_edit_singleline(&mut user_state.sampler_search_text); + let search_width = ui.available_width(); + ui.add_sized([search_width, 0.0], egui::TextEdit::singleline(&mut user_state.sampler_search_text).hint_text("Search...")); ui.separator(); let search = user_state.sampler_search_text.to_lowercase(); @@ -901,7 +903,7 @@ impl NodeDataTrait for NodeData { continue; } let label = format!("📁 {} ({} clips)", folder.name, folder.clip_pool_indices.len()); - if ui.selectable_label(false, label).clicked() { + if widgets::list_item(ui, false, &label) { user_state.pending_sampler_load = Some(PendingSamplerLoad::MultiFromFolder { node_id, folder_id: folder.folder_id, @@ -916,31 +918,29 @@ impl NodeDataTrait for NodeData { if is_multi { ui.label(egui::RichText::new("Audio Clips").small().weak()); } - egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { - for clip in &user_state.available_clips { - if !search.is_empty() && !clip.name.to_lowercase().contains(&search) { - continue; - } - if ui.selectable_label(false, &clip.name).clicked() { - if is_multi { - user_state.pending_sampler_load = Some(PendingSamplerLoad::MultiFromPool { - node_id, - backend_node_id, - pool_index: clip.pool_index, - name: clip.name.clone(), - }); - } else { - user_state.pending_sampler_load = Some(PendingSamplerLoad::SimpleFromPool { - node_id, - backend_node_id, - pool_index: clip.pool_index, - name: clip.name.clone(), - }); - } - close_popup = true; - } + let filtered_clips: Vec<&SamplerClipInfo> = user_state.available_clips.iter() + .filter(|clip| search.is_empty() || clip.name.to_lowercase().contains(&search)) + .collect(); + let items = filtered_clips.iter().map(|clip| (false, clip.name.as_str())); + if let Some(idx) = widgets::scrollable_list(ui, 200.0, items) { + let clip = filtered_clips[idx]; + if is_multi { + user_state.pending_sampler_load = Some(PendingSamplerLoad::MultiFromPool { + node_id, + backend_node_id, + pool_index: clip.pool_index, + name: clip.name.clone(), + }); + } else { + user_state.pending_sampler_load = Some(PendingSamplerLoad::SimpleFromPool { + node_id, + backend_node_id, + pool_index: clip.pool_index, + name: clip.name.clone(), + }); } - }); + close_popup = true; + } ui.separator(); if ui.button("Open...").clicked() { if is_multi { @@ -971,18 +971,17 @@ impl NodeDataTrait for NodeData { let mut close_root = false; egui::Popup::from_toggle_button_response(&root_btn) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .width(80.0) .show(|ui| { - ui.set_min_width(120.0); - egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { - // Show notes from C1 (24) to C7 (96) - for note in (24..=96).rev() { - let name = midi_note_name(note); - if ui.selectable_label(note == self.root_note, &name).clicked() { - user_state.pending_root_note_changes.push((node_id, backend_node_id, note)); - close_root = true; - } - } - }); + let notes: Vec<(u8, String)> = (24..=96).rev() + .map(|n| (n, midi_note_name(n))) + .collect(); + let items = notes.iter().map(|(n, name)| (*n == self.root_note, name.as_str())); + if let Some(idx) = widgets::scrollable_list(ui, 200.0, items) { + let (note, _) = ¬es[idx]; + user_state.pending_root_note_changes.push((node_id, backend_node_id, *note)); + close_root = true; + } }); if close_root { egui::Popup::close_id(ui.ctx(), root_popup_id); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs index adade75..bd04137 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -587,12 +587,27 @@ impl NodeGraphPane { .add_filter("Audio", &["wav", "flac", "mp3", "ogg", "aiff"]) .pick_file() { - let path_str = path.to_string_lossy().to_string(); let file_name = path.file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "Sample".to_string()); + + // Import into audio pool + asset library, then load from pool let mut controller = controller_arc.lock().unwrap(); - controller.sampler_load_sample(backend_track_id, backend_node_id, path_str); + match controller.import_audio_sync(path.to_path_buf()) { + Ok(pool_index) => { + // Add to document asset library + let metadata = daw_backend::io::read_metadata(&path).ok(); + let duration = metadata.as_ref().map(|m| m.duration).unwrap_or(0.0); + let clip = lightningbeam_core::clip::AudioClip::new_sampled(&file_name, pool_index, duration); + shared.action_executor.document_mut().add_audio_clip(clip); + + // Load into sampler from pool + controller.sampler_load_from_pool(backend_track_id, backend_node_id, pool_index); + } + Err(e) => { + eprintln!("Failed to import audio '{}': {}", path.display(), e); + } + } if let Some(node) = self.state.graph.nodes.get_mut(node_id) { node.user_data.sample_display_name = Some(file_name); } @@ -635,17 +650,28 @@ impl NodeGraphPane { .add_filter("Audio", &["wav", "flac", "mp3", "ogg", "aiff"]) .pick_file() { - let path_str = path.to_string_lossy().to_string(); let file_name = path.file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "Sample".to_string()); let mut controller = controller_arc.lock().unwrap(); - // Add as layer spanning full key range - controller.multi_sampler_add_layer( - backend_track_id, backend_node_id, path_str, - 0, 127, 60, 0, 127, None, None, - daw_backend::audio::node_graph::nodes::LoopMode::OneShot, - ); + // Import into audio pool + asset library, then load from pool + match controller.import_audio_sync(path.to_path_buf()) { + Ok(pool_index) => { + let metadata = daw_backend::io::read_metadata(&path).ok(); + let duration = metadata.as_ref().map(|m| m.duration).unwrap_or(0.0); + let clip = lightningbeam_core::clip::AudioClip::new_sampled(&file_name, pool_index, duration); + shared.action_executor.document_mut().add_audio_clip(clip); + + // Add as layer spanning full key range + controller.multi_sampler_add_layer_from_pool( + backend_track_id, backend_node_id, pool_index, + 0, 127, 60, + ); + } + Err(e) => { + eprintln!("Failed to import audio '{}': {}", path.display(), e); + } + } if let Some(node) = self.state.graph.nodes.get_mut(node_id) { node.user_data.sample_display_name = Some(file_name); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/widgets/dropdown_list.rs b/lightningbeam-ui/lightningbeam-editor/src/widgets/dropdown_list.rs new file mode 100644 index 0000000..49c4106 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/widgets/dropdown_list.rs @@ -0,0 +1,77 @@ +//! Full-width selectable list for use inside popups and dropdowns. +//! +//! Solves the recurring issue where `selectable_label` inside `ScrollArea` +//! inside a `Popup` doesn't fill the available width, making only the text +//! portion clickable. + +use eframe::egui; +use egui::Ui; + +/// Render a full-width selectable list item. +/// +/// Unlike `ui.selectable_label()`, this allocates the full available width +/// for the clickable area, matching native menu item behavior. +pub fn list_item(ui: &mut Ui, selected: bool, label: &str) -> bool { + let desired_width = ui.available_width(); + let height = ui.spacing().interact_size.y; + let (rect, response) = ui.allocate_exact_size( + egui::vec2(desired_width, height), + egui::Sense::click(), + ); + + if ui.is_rect_visible(rect) { + let visuals = ui.visuals(); + if selected { + ui.painter().rect_filled(rect, 2.0, visuals.selection.bg_fill); + } else if response.hovered() { + ui.painter().rect_filled(rect, 2.0, visuals.widgets.hovered.bg_fill); + } + + let text_color = if selected { + visuals.selection.stroke.color + } else if response.hovered() { + visuals.widgets.hovered.text_color() + } else { + visuals.widgets.inactive.text_color() + }; + + let text_pos = rect.min + egui::vec2(4.0, (rect.height() - 14.0) / 2.0); + ui.painter().text( + text_pos, + egui::Align2::LEFT_TOP, + label, + egui::FontId::proportional(14.0), + text_color, + ); + } + + response.clicked() +} + +/// Render a scrollable list of items inside a popup, ensuring full-width +/// clickable areas and proper ScrollArea sizing. +/// +/// Returns the index of the clicked item, if any. +pub fn scrollable_list<'a>( + ui: &mut Ui, + max_height: f32, + items: impl Iterator, +) -> Option { + let mut clicked_index = None; + + // Force the ScrollArea to use the full width set by the parent + let width = ui.available_width(); + + egui::ScrollArea::vertical() + .max_height(max_height) + .show(ui, |ui| { + ui.set_min_width(width); + for (i, (selected, label)) in items.enumerate() { + if list_item(ui, selected, label) { + clicked_index = Some(i); + } + } + }); + + clicked_index +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs index 9996661..1e47eb5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs @@ -1,5 +1,7 @@ //! Reusable UI widgets for the editor mod text_field; +pub mod dropdown_list; pub use text_field::ImeTextField; +pub use dropdown_list::{list_item, scrollable_list};