tuillem-tui 0.1.2

Ratatui TUI layer for tuillem
Documentation
use ratatui::{
    Frame,
    layout::Rect,
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Clear, List, ListItem, ListState},
};

use crate::theme::Theme;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ControlAction {
    SwitchModel,
    SwitchProvider,
    NewConversation,
    RegenerateResponse,
    SaveTranscript,
    OpenInEditor,
    ToggleThinking,
}

impl ControlAction {
    pub const ALL: &'static [ControlAction] = &[
        ControlAction::SwitchModel,
        ControlAction::SwitchProvider,
        ControlAction::NewConversation,
        ControlAction::RegenerateResponse,
        ControlAction::SaveTranscript,
        ControlAction::OpenInEditor,
        ControlAction::ToggleThinking,
    ];

    pub fn label(&self) -> &'static str {
        match self {
            ControlAction::SwitchModel => "Switch Model",
            ControlAction::SwitchProvider => "Switch Provider",
            ControlAction::NewConversation => "New Conversation",
            ControlAction::RegenerateResponse => "Regenerate Response",
            ControlAction::SaveTranscript => "Save Transcript",
            ControlAction::OpenInEditor => "Open in Editor",
            ControlAction::ToggleThinking => "Toggle Thinking",
        }
    }

    pub fn hint(&self) -> &'static str {
        match self {
            ControlAction::SwitchModel => "Ctrl+K > m",
            ControlAction::SwitchProvider => "Ctrl+K > p",
            ControlAction::NewConversation => "Ctrl+N",
            ControlAction::RegenerateResponse => "Ctrl+R",
            ControlAction::SaveTranscript => "",
            ControlAction::OpenInEditor => "Ctrl+E",
            ControlAction::ToggleThinking => "",
        }
    }
}

#[derive(Debug, Clone)]
pub struct ControlPanel {
    pub selected: usize,
}

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

impl ControlPanel {
    pub fn new() -> Self {
        Self { selected: 0 }
    }

    pub fn move_down(&mut self) {
        if self.selected + 1 < ControlAction::ALL.len() {
            self.selected += 1;
        }
    }

    pub fn move_up(&mut self) {
        self.selected = self.selected.saturating_sub(1);
    }

    pub fn selected_action(&self) -> ControlAction {
        ControlAction::ALL[self.selected]
    }

    pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
        let popup_width = 45u16.min(area.width.saturating_sub(10));
        let popup_height = (ControlAction::ALL.len() as u16 + 2).min(area.height.saturating_sub(6));
        let x = (area.width.saturating_sub(popup_width)) / 2;
        let y = (area.height.saturating_sub(popup_height)) / 2;
        let popup_area = Rect::new(x, y, popup_width, popup_height);

        frame.render_widget(Clear, popup_area);

        let items: Vec<ListItem> = ControlAction::ALL
            .iter()
            .enumerate()
            .map(|(i, action)| {
                let selected = i == self.selected;
                let style = if selected {
                    Style::default()
                        .fg(theme.accent)
                        .add_modifier(Modifier::BOLD)
                } else {
                    Style::default().fg(theme.fg)
                };
                let marker = if selected { "" } else { "  " };
                let hint = action.hint();
                let label = action.label();
                let inner_width = popup_width.saturating_sub(2) as usize;
                let pad = inner_width.saturating_sub(marker.len() + label.len() + hint.len());
                let line = if hint.is_empty() {
                    Line::from(Span::styled(format!("{}{}", marker, label), style))
                } else {
                    Line::from(vec![
                        Span::styled(format!("{}{}", marker, label), style),
                        Span::styled(
                            format!("{:>width$}", hint, width = pad + hint.len()),
                            Style::default().fg(theme.thinking_fg),
                        ),
                    ])
                };
                ListItem::new(line)
            })
            .collect();

        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(theme.accent))
            .title(Line::from(Span::styled(
                " Commands ",
                Style::default()
                    .fg(theme.accent)
                    .add_modifier(Modifier::BOLD),
            )))
            .title_bottom(Line::from(Span::styled(
                " j/k:select  Enter:run  Esc:cancel ",
                Style::default().fg(theme.thinking_fg),
            )))
            .style(Style::default().bg(theme.bg));

        let mut list_state = ListState::default();
        list_state.select(Some(self.selected));

        let list = List::new(items).block(block);
        frame.render_stateful_widget(list, popup_area, &mut list_state);
    }
}