scrin 0.1.37

A terminal UI toolkit with panes, widgets, overlays, animations, and Aisling-powered effects/loaders.
Documentation
use crate::core::buffer::Buffer;
use crate::core::color::Color;
use crate::core::rect::Rect;

#[derive(Debug, Clone)]
pub struct Command {
    pub name: String,
    pub shortcut: Option<String>,
    pub description: String,
    pub action_id: String,
}

#[derive(Debug, Clone, PartialEq)]
pub enum PaletteState {
    Closed,
    Open,
    Filtering,
}

pub struct CommandPalette {
    pub state: PaletteState,
    pub commands: Vec<Command>,
    pub filtered: Vec<usize>,
    pub query: String,
    pub selected_index: usize,
    pub max_visible: usize,
    pub border_color: Color,
    pub bg: Color,
    pub selected_bg: Color,
}

impl CommandPalette {
    pub fn new() -> Self {
        Self {
            state: PaletteState::Closed,
            commands: Vec::new(),
            filtered: Vec::new(),
            query: String::new(),
            selected_index: 0,
            max_visible: 10,
            border_color: Color::rgb(88, 166, 255),
            bg: Color::rgb(13, 17, 23),
            selected_bg: Color::rgb(31, 111, 235),
        }
    }

    pub fn with_commands(mut self, commands: Vec<Command>) -> Self {
        self.commands = commands;
        self.rebuild_filter();
        self
    }

    pub fn add_command(&mut self, command: Command) {
        self.commands.push(command);
        self.rebuild_filter();
    }

    pub fn open(&mut self) {
        self.state = PaletteState::Open;
        self.query.clear();
        self.selected_index = 0;
        self.rebuild_filter();
    }

    pub fn close(&mut self) {
        self.state = PaletteState::Closed;
        self.query.clear();
    }

    pub fn is_open(&self) -> bool {
        self.state != PaletteState::Closed
    }

    pub fn input_char(&mut self, c: char) {
        if self.state == PaletteState::Closed {
            return;
        }
        self.query.push(c);
        self.selected_index = 0;
        self.rebuild_filter();
    }

    pub fn backspace(&mut self) {
        if self.state == PaletteState::Closed {
            return;
        }
        self.query.pop();
        self.selected_index = 0;
        self.rebuild_filter();
    }

    pub fn select_next(&mut self) {
        if self.filtered.is_empty() {
            return;
        }
        self.selected_index = (self.selected_index + 1) % self.filtered.len();
    }

    pub fn select_prev(&mut self) {
        if self.filtered.is_empty() {
            return;
        }
        self.selected_index = if self.selected_index == 0 {
            self.filtered.len() - 1
        } else {
            self.selected_index - 1
        };
    }

    pub fn execute_selected(&self) -> Option<&str> {
        self.filtered
            .get(self.selected_index)
            .and_then(|&idx| self.commands.get(idx))
            .map(|cmd| cmd.action_id.as_str())
    }

    fn rebuild_filter(&mut self) {
        self.filtered.clear();
        let query_lower = self.query.to_lowercase();
        for (i, cmd) in self.commands.iter().enumerate() {
            if query_lower.is_empty()
                || cmd.name.to_lowercase().contains(&query_lower)
                || cmd.description.to_lowercase().contains(&query_lower)
            {
                self.filtered.push(i);
            }
        }
    }

    pub fn render(&self, buffer: &mut Buffer, area: Rect) {
        if self.state == PaletteState::Closed {
            return;
        }
        let w = 50.min(area.width);
        let h = (self.max_visible as u16 + 4).min(area.height);
        let x = area.x + (area.width.saturating_sub(w)) / 2;
        let y = area.y + (area.height.saturating_sub(h)) / 2;
        let rect = Rect::new(x, y, w, h);

        buffer.fill(rect, ' ', Color::WHITE, Some(self.bg));

        for dx in 1..w.saturating_sub(1) {
            buffer.set(
                (x + dx) as usize,
                y as usize,
                crate::core::buffer::Cell {
                    ch: '',
                    fg: self.border_color,
                    bg: Some(self.bg),
                    bold: false,
                    italic: false,
                    underlined: false,
                },
            );
            buffer.set(
                (x + dx) as usize,
                (y + h - 1) as usize,
                crate::core::buffer::Cell {
                    ch: '',
                    fg: self.border_color,
                    bg: Some(self.bg),
                    bold: false,
                    italic: false,
                    underlined: false,
                },
            );
        }

        buffer.set(
            x as usize,
            y as usize,
            crate::core::buffer::Cell {
                ch: '',
                fg: self.border_color,
                bg: Some(self.bg),
                bold: true,
                italic: false,
                underlined: false,
            },
        );
        buffer.set(
            (x + w - 1) as usize,
            y as usize,
            crate::core::buffer::Cell {
                ch: '',
                fg: self.border_color,
                bg: Some(self.bg),
                bold: true,
                italic: false,
                underlined: false,
            },
        );
        buffer.set(
            x as usize,
            (y + h - 1) as usize,
            crate::core::buffer::Cell {
                ch: '',
                fg: self.border_color,
                bg: Some(self.bg),
                bold: true,
                italic: false,
                underlined: false,
            },
        );
        buffer.set(
            (x + w - 1) as usize,
            (y + h - 1) as usize,
            crate::core::buffer::Cell {
                ch: '',
                fg: self.border_color,
                bg: Some(self.bg),
                bold: true,
                italic: false,
                underlined: false,
            },
        );

        let search_text = format!(" > {}", self.query);
        let search_display: String = search_text.chars().take((w - 4) as usize).collect();
        buffer.set_str(
            (x + 2) as usize,
            (y + 1) as usize,
            &search_display,
            self.border_color,
            Some(self.bg),
        );

        for (i, &cmd_idx) in self.filtered.iter().take(self.max_visible).enumerate() {
            let opt_y = (y + 3 + i as u16) as usize;
            if opt_y >= (y + h - 1) as usize {
                break;
            }
            let cmd = &self.commands[cmd_idx];
            let selected = i == self.selected_index;
            let prefix = if selected { "" } else { "   " };
            let shortcut = cmd.shortcut.as_deref().unwrap_or("");
            let text = format!("{}{} [{}]", prefix, cmd.name, shortcut);
            let display: String = text.chars().take((w - 4) as usize).collect();
            let color = if selected {
                Color::WHITE
            } else {
                Color::rgb(139, 148, 158)
            };
            let bg = if selected {
                Some(self.selected_bg)
            } else {
                Some(self.bg)
            };
            buffer.set_str((x + 2) as usize, opt_y, &display, color, bg);
        }
    }
}

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