Lightningbeam/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/palette.rs

172 lines
5.4 KiB
Rust

//! Node Palette UI
//!
//! Left sidebar showing available node types organized by category
use super::node_types::{NodeCategory, NodeTypeRegistry};
use eframe::egui;
/// Node palette state
pub struct NodePalette {
/// Node type registry
registry: NodeTypeRegistry,
/// Category collapse states
collapsed_categories: std::collections::HashSet<NodeCategory>,
/// Search filter text
search_filter: String,
}
impl NodePalette {
pub fn new() -> Self {
Self {
registry: NodeTypeRegistry::new(),
collapsed_categories: std::collections::HashSet::new(),
search_filter: String::new(),
}
}
/// Render the palette UI
///
/// The `on_node_clicked` callback is called when the user clicks a node type to add it
pub fn render<F>(&mut self, ui: &mut egui::Ui, rect: egui::Rect, mut on_node_clicked: F)
where
F: FnMut(&str),
{
// Draw background
ui.painter()
.rect_filled(rect, 0.0, egui::Color32::from_rgb(30, 30, 30));
// Create UI within the palette rect
ui.allocate_ui_at_rect(rect, |ui| {
ui.vertical(|ui| {
ui.add_space(8.0);
// Title
ui.heading("Node Palette");
ui.add_space(4.0);
// Search box
ui.horizontal(|ui| {
ui.label("Search:");
ui.text_edit_singleline(&mut self.search_filter);
});
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// Scrollable node list
egui::ScrollArea::vertical()
.id_salt("node_palette_scroll")
.show(ui, |ui| {
self.render_categories(ui, &mut on_node_clicked);
});
});
});
}
fn render_categories<F>(&mut self, ui: &mut egui::Ui, on_node_clicked: &mut F)
where
F: FnMut(&str),
{
let search_lower = self.search_filter.to_lowercase();
for category in self.registry.all_categories() {
// Get nodes in this category
let mut nodes = self.registry.get_by_category(category);
// Filter by search text (node names only)
if !search_lower.is_empty() {
nodes.retain(|node| {
node.display_name.to_lowercase().contains(&search_lower)
});
}
// Skip empty categories
if nodes.is_empty() {
continue;
}
// Sort nodes by name
nodes.sort_by(|a, b| a.display_name.cmp(&b.display_name));
// Render category header
let is_collapsed = self.collapsed_categories.contains(&category);
let arrow = if is_collapsed { ">" } else { "v" };
let label = format!("{} {} ({})", arrow, category.display_name(), nodes.len());
let header_response = ui.selectable_label(false, label);
// Toggle collapse on click
if header_response.clicked() {
if is_collapsed {
self.collapsed_categories.remove(&category);
} else {
self.collapsed_categories.insert(category);
}
}
// Render nodes if not collapsed
if !is_collapsed {
ui.indent(category.display_name(), |ui| {
for node in nodes {
self.render_node_button(ui, node.id.as_str(), &node.display_name, on_node_clicked);
}
});
}
ui.add_space(4.0);
}
}
fn render_node_button<F>(
&self,
ui: &mut egui::Ui,
node_id: &str,
display_name: &str,
on_node_clicked: &mut F,
) where
F: FnMut(&str),
{
// Use drag source to enable dragging
let drag_id = egui::Id::new(format!("node_palette_{}", node_id));
let response = ui.dnd_drag_source(
drag_id,
node_id.to_string(),
|ui| {
let button = egui::Button::new(display_name)
.min_size(egui::vec2(ui.available_width() - 8.0, 24.0))
.fill(egui::Color32::from_rgb(50, 50, 50));
ui.add(button)
},
);
// Handle click: detect clicks by checking if drag stopped with minimal movement
// dnd_drag_source always sets is_being_dragged=true on press, so we can't use that
if response.response.drag_stopped() {
// Check if this was actually a drag or just a click (minimal movement)
if let Some(start_pos) = response.response.interact_pointer_pos() {
if let Some(current_pos) = ui.input(|i| i.pointer.interact_pos()) {
let drag_distance = (current_pos - start_pos).length();
if drag_distance < 5.0 {
// This was a click, not a drag
on_node_clicked(node_id);
}
}
}
}
// Show tooltip with description
if let Some(node_info) = self.registry.get(node_id) {
response.response.on_hover_text(&node_info.description);
}
}
}
impl Default for NodePalette {
fn default() -> Self {
Self::new()
}
}