nightshade 0.8.0

A cross-platform data-oriented game engine.
Documentation
use crate::prelude::*;

pub struct Command {
    pub name: String,
    pub shortcut: Option<String>,
    pub action: Box<dyn FnMut() + Send>,
}

pub struct CommandPalette {
    commands: Vec<Command>,
    search_text: String,
    open: bool,
    selected_index: usize,
    filtered_indices: Vec<usize>,
}

impl CommandPalette {
    pub fn new() -> Self {
        Self {
            commands: Vec::new(),
            search_text: String::new(),
            open: false,
            selected_index: 0,
            filtered_indices: Vec::new(),
        }
    }

    pub fn register(
        &mut self,
        name: impl Into<String>,
        shortcut: Option<String>,
        action: impl FnMut() + Send + 'static,
    ) {
        self.commands.push(Command {
            name: name.into(),
            shortcut,
            action: Box::new(action),
        });
    }

    pub fn toggle(&mut self) {
        self.open = !self.open;
        if self.open {
            self.search_text.clear();
            self.selected_index = 0;
        }
    }

    pub fn show(&mut self) {
        self.open = true;
        self.search_text.clear();
        self.selected_index = 0;
    }

    pub fn hide(&mut self) {
        self.open = false;
    }

    pub fn is_open(&self) -> bool {
        self.open
    }

    pub fn render(&mut self, ui_context: &egui::Context) {
        if !self.open {
            return;
        }

        let screen_rect = ui_context.content_rect();
        let palette_width = (screen_rect.width() * 0.5).clamp(300.0, 500.0);

        let mut should_close = false;
        let mut execute_index: Option<usize> = None;

        egui::Area::new(egui::Id::new("command_palette_area"))
            .fixed_pos(egui::pos2(
                (screen_rect.width() - palette_width) / 2.0,
                screen_rect.height() * 0.15,
            ))
            .order(egui::Order::Foreground)
            .show(ui_context, |ui| {
                egui::Frame::popup(ui.style())
                    .inner_margin(8.0)
                    .show(ui, |ui| {
                        ui.set_width(palette_width);

                        let response = ui.add(
                            egui::TextEdit::singleline(&mut self.search_text)
                                .desired_width(palette_width - 16.0)
                                .hint_text("Type a command..."),
                        );
                        response.request_focus();

                        if ui.input(|input| input.key_pressed(egui::Key::Escape)) {
                            should_close = true;
                        }

                        let search_lower = self.search_text.to_lowercase();
                        self.filtered_indices.clear();
                        self.filtered_indices.extend(
                            self.commands
                                .iter()
                                .enumerate()
                                .filter(|(_, command)| {
                                    search_lower.is_empty()
                                        || command.name.to_lowercase().contains(&search_lower)
                                })
                                .map(|(index, _)| index),
                        );
                        let filtered_indices = &self.filtered_indices;

                        if ui.input(|input| input.key_pressed(egui::Key::ArrowDown))
                            && !filtered_indices.is_empty()
                        {
                            self.selected_index =
                                (self.selected_index + 1) % filtered_indices.len();
                        }
                        if ui.input(|input| input.key_pressed(egui::Key::ArrowUp))
                            && !filtered_indices.is_empty()
                        {
                            self.selected_index = self
                                .selected_index
                                .checked_sub(1)
                                .unwrap_or(filtered_indices.len() - 1);
                        }
                        if ui.input(|input| input.key_pressed(egui::Key::Enter))
                            && !filtered_indices.is_empty()
                        {
                            execute_index = Some(filtered_indices[self.selected_index]);
                            should_close = true;
                        }

                        self.selected_index = self
                            .selected_index
                            .min(filtered_indices.len().saturating_sub(1));

                        ui.add_space(4.0);

                        egui::ScrollArea::vertical()
                            .max_height(300.0)
                            .show(ui, |ui| {
                                if filtered_indices.is_empty() {
                                    ui.label("No matching commands");
                                    return;
                                }

                                for (display_index, &command_index) in
                                    filtered_indices.iter().enumerate()
                                {
                                    let command = &self.commands[command_index];
                                    let is_selected = display_index == self.selected_index;

                                    let response = ui.horizontal(|ui| {
                                        if is_selected {
                                            let rect = ui.available_rect_before_wrap();
                                            ui.painter().rect_filled(
                                                rect,
                                                4.0,
                                                ui.style().visuals.selection.bg_fill,
                                            );
                                        }

                                        ui.label(&command.name);

                                        if let Some(shortcut) = &command.shortcut {
                                            ui.with_layout(
                                                egui::Layout::right_to_left(egui::Align::Center),
                                                |ui| {
                                                    ui.label(
                                                        egui::RichText::new(shortcut)
                                                            .color(egui::Color32::from_gray(140))
                                                            .small(),
                                                    );
                                                },
                                            );
                                        }
                                    });

                                    if response.response.clicked() {
                                        execute_index = Some(command_index);
                                        should_close = true;
                                    }
                                }
                            });
                    });
            });

        if let Some(index) = execute_index {
            (self.commands[index].action)();
        }
        if should_close {
            self.open = false;
        }
    }
}

impl Default for CommandPalette {
    fn default() -> Self {
        Self::new()
    }
}