o7 0.1.1

O7 workflow DSL runner
Documentation
//! Command palette overlay for the O7 TUI.
//!
//! Provides a filterable command list that appears as a centered overlay
//! when the user presses Ctrl+P.

use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};

/// Action that a palette command can trigger.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaletteAction {
    Resume,
    Reset,
    Quit,
    Inspect,
}

/// A single command in the palette.
#[derive(Debug, Clone)]
pub struct PaletteCommand {
    pub name: String,
    pub description: String,
    pub action: PaletteAction,
}

/// State of the command palette.
#[derive(Debug)]
pub struct CommandPaletteState {
    /// Current filter text.
    pub filter: String,
    /// Index of the selected command in the filtered list.
    pub selected_index: usize,
    /// All available commands.
    pub commands: Vec<PaletteCommand>,
}

impl CommandPaletteState {
    pub fn new() -> Self {
        CommandPaletteState {
            filter: String::new(),
            selected_index: 0,
            commands: Vec::new(),
        }
    }

    /// Open the command palette, populating commands based on the current app mode.
    pub fn open(&mut self, current_mode: &super::app::AppMode) {
        self.filter.clear();
        self.selected_index = 0;
        self.commands = build_commands(current_mode);
    }

    /// Close the palette.
    pub fn close(&mut self) {
        self.filter.clear();
        self.selected_index = 0;
    }

    /// Move selection up.
    pub fn move_up(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
        }
    }

    /// Move selection down.
    pub fn move_down(&mut self) {
        let filtered = self.filtered_commands();
        if self.selected_index + 1 < filtered.len() {
            self.selected_index += 1;
        }
    }

    /// Append a character to the filter.
    pub fn push_filter(&mut self, c: char) {
        self.filter.push(c);
        self.selected_index = 0;
    }

    /// Remove the last character from the filter.
    pub fn pop_filter(&mut self) {
        self.filter.pop();
        self.selected_index = 0;
    }

    /// Get filtered commands based on the current filter text.
    pub fn filtered_commands(&self) -> Vec<&PaletteCommand> {
        if self.filter.is_empty() {
            self.commands.iter().collect()
        } else {
            let lower_filter = self.filter.to_lowercase();
            self.commands
                .iter()
                .filter(|cmd| {
                    cmd.name.to_lowercase().contains(&lower_filter)
                        || cmd.description.to_lowercase().contains(&lower_filter)
                })
                .collect()
        }
    }

    /// Get the action of the currently selected command (if any).
    pub fn selected_action(&self) -> Option<PaletteAction> {
        let filtered = self.filtered_commands();
        filtered
            .get(self.selected_index)
            .map(|cmd| cmd.action.clone())
    }
}

/// Build the command list based on the current mode.
fn build_commands(mode: &super::app::AppMode) -> Vec<PaletteCommand> {
    use super::app::AppMode;

    let mut cmds = Vec::new();

    match mode {
        AppMode::Paused => {
            cmds.push(PaletteCommand {
                name: "Resume".to_string(),
                description: "Resume the paused workflow".to_string(),
                action: PaletteAction::Resume,
            });
            cmds.push(PaletteCommand {
                name: "Reset".to_string(),
                description: "Reset to last safe boundary".to_string(),
                action: PaletteAction::Reset,
            });
        }
        AppMode::Running => {
            // No resume/reset when running.
        }
        _ => {}
    }

    cmds.push(PaletteCommand {
        name: "Inspect".to_string(),
        description: "Inspect the selected step".to_string(),
        action: PaletteAction::Inspect,
    });

    cmds.push(PaletteCommand {
        name: "Quit".to_string(),
        description: "Quit the TUI".to_string(),
        action: PaletteAction::Quit,
    });

    cmds
}

/// Compute a centered rectangle for the palette overlay.
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(area);
    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(popup_layout[1])[1]
}

/// Draw the command palette overlay.
pub fn draw_command_palette(frame: &mut Frame, palette: &CommandPaletteState) {
    let area = centered_rect(50, 40, frame.area());

    // Clear the background behind the overlay.
    frame.render_widget(Clear, area);

    let block = Block::default()
        .title(" Command Palette ")
        .borders(Borders::ALL)
        .style(Style::default().fg(Color::White).bg(Color::DarkGray));

    let inner = block.inner(area);
    frame.render_widget(block, area);

    // Split inner area into filter input and command list.
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(3), Constraint::Min(1)])
        .split(inner);

    // Filter input.
    let filter_text = if palette.filter.is_empty() {
        "Type to filter...".to_string()
    } else {
        palette.filter.clone()
    };
    let filter_style = if palette.filter.is_empty() {
        Style::default().fg(Color::DarkGray)
    } else {
        Style::default().fg(Color::White)
    };
    let filter_widget = Paragraph::new(filter_text).style(filter_style).block(
        Block::default()
            .borders(Borders::BOTTOM)
            .border_style(Style::default().fg(Color::Gray)),
    );
    frame.render_widget(filter_widget, chunks[0]);

    // Command list.
    let filtered = palette.filtered_commands();
    let items: Vec<ListItem> = filtered
        .iter()
        .enumerate()
        .map(|(i, cmd)| {
            let style = if i == palette.selected_index {
                Style::default()
                    .fg(Color::Black)
                    .bg(Color::Cyan)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::White)
            };

            let line = Line::from(vec![
                Span::styled(&cmd.name, style.add_modifier(Modifier::BOLD)),
                Span::styled(format!("  {}", cmd.description), style),
            ]);
            ListItem::new(line)
        })
        .collect();

    let list = List::new(items);
    frame.render_widget(list, chunks[1]);
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tui::app::AppMode;

    #[test]
    fn test_palette_commands_when_running() {
        let cmds = build_commands(&AppMode::Running);
        // Should have Inspect and Quit.
        assert_eq!(cmds.len(), 2);
        assert_eq!(cmds[0].action, PaletteAction::Inspect);
        assert_eq!(cmds[1].action, PaletteAction::Quit);
    }

    #[test]
    fn test_palette_commands_when_paused() {
        let cmds = build_commands(&AppMode::Paused);
        // Should have Resume, Reset, Inspect, Quit.
        assert_eq!(cmds.len(), 4);
        assert_eq!(cmds[0].action, PaletteAction::Resume);
        assert_eq!(cmds[1].action, PaletteAction::Reset);
    }

    #[test]
    fn test_filter() {
        let mut state = CommandPaletteState::new();
        state.open(&AppMode::Running);
        assert_eq!(state.filtered_commands().len(), 2);

        state.push_filter('q');
        let filtered = state.filtered_commands();
        assert_eq!(filtered.len(), 1);
        assert_eq!(filtered[0].action, PaletteAction::Quit);
    }

    #[test]
    fn test_navigation() {
        let mut state = CommandPaletteState::new();
        state.open(&AppMode::Paused);
        assert_eq!(state.selected_index, 0);

        state.move_down();
        assert_eq!(state.selected_index, 1);

        state.move_up();
        assert_eq!(state.selected_index, 0);

        // Don't go below 0.
        state.move_up();
        assert_eq!(state.selected_index, 0);
    }
}