use ratatui::{
layout::Rect,
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::themes::Theme;
pub mod defaults {
pub const HEADER_TEXT: &str = " Slash command mode \u{2014} Use arrow keys to select, Enter to execute, Esc to cancel";
pub const NO_MATCHES_MESSAGE: &str = " No matching commands";
pub const COMMAND_PREFIX: &str = " /";
pub const DESCRIPTION_INDENT: &str = " ";
}
#[derive(Clone)]
pub struct SlashPopupConfig {
pub header_text: String,
pub no_matches_message: String,
pub command_prefix: String,
pub description_indent: String,
}
impl Default for SlashPopupConfig {
fn default() -> Self {
Self::new()
}
}
impl SlashPopupConfig {
pub fn new() -> Self {
Self {
header_text: defaults::HEADER_TEXT.to_string(),
no_matches_message: defaults::NO_MATCHES_MESSAGE.to_string(),
command_prefix: defaults::COMMAND_PREFIX.to_string(),
description_indent: defaults::DESCRIPTION_INDENT.to_string(),
}
}
pub fn with_header_text(mut self, text: impl Into<String>) -> Self {
self.header_text = text.into();
self
}
pub fn with_no_matches_message(mut self, message: impl Into<String>) -> Self {
self.no_matches_message = message.into();
self
}
pub fn with_command_prefix(mut self, prefix: impl Into<String>) -> Self {
self.command_prefix = prefix.into();
self
}
}
pub trait SlashCommandDisplay {
fn name(&self) -> &str;
fn description(&self) -> &str;
}
impl<T: crate::commands::SlashCommand + ?Sized> SlashCommandDisplay for T {
fn name(&self) -> &str {
crate::commands::SlashCommand::name(self)
}
fn description(&self) -> &str {
crate::commands::SlashCommand::description(self)
}
}
impl SlashCommandDisplay for &dyn crate::commands::SlashCommand {
fn name(&self) -> &str {
crate::commands::SlashCommand::name(*self)
}
fn description(&self) -> &str {
crate::commands::SlashCommand::description(*self)
}
}
pub struct SlashPopupState {
pub active: bool,
pub selected_index: usize,
filtered_count: usize,
config: SlashPopupConfig,
}
impl SlashPopupState {
pub fn new() -> Self {
Self::with_config(SlashPopupConfig::new())
}
pub fn with_config(config: SlashPopupConfig) -> Self {
Self {
active: false,
selected_index: 0,
filtered_count: 0,
config,
}
}
pub fn config(&self) -> &SlashPopupConfig {
&self.config
}
pub fn set_config(&mut self, config: SlashPopupConfig) {
self.config = config;
}
pub fn activate(&mut self) {
self.active = true;
self.selected_index = 0;
}
pub fn deactivate(&mut self) {
self.active = false;
self.selected_index = 0;
self.filtered_count = 0;
}
pub fn set_filtered_count(&mut self, count: usize) {
self.filtered_count = count;
if count > 0 {
self.selected_index = self.selected_index.min(count - 1);
} else {
self.selected_index = 0;
}
}
pub fn select_previous(&mut self) {
if self.filtered_count == 0 {
return;
}
if self.selected_index == 0 {
self.selected_index = self.filtered_count - 1;
} else {
self.selected_index -= 1;
}
}
pub fn select_next(&mut self) {
if self.filtered_count == 0 {
return;
}
self.selected_index = (self.selected_index + 1) % self.filtered_count;
}
pub fn selected_index(&self) -> usize {
self.selected_index
}
pub fn popup_height(&self, max_height: u16) -> u16 {
let filtered_count = self.filtered_count.max(1);
let content_height = 2 + (filtered_count * 3) + 2;
(content_height as u16).min(max_height.saturating_sub(10))
}
}
impl Default for SlashPopupState {
fn default() -> Self {
Self::new()
}
}
use std::any::Any;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::{widget_ids, Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult};
#[derive(Debug, Clone, PartialEq)]
pub enum SlashKeyAction {
None,
Navigated,
Selected(usize),
Cancelled,
CharTyped(char),
Backspace,
}
impl SlashPopupState {
pub fn process_key(&mut self, key: KeyEvent) -> SlashKeyAction {
if !self.active {
return SlashKeyAction::None;
}
match key.code {
KeyCode::Up => {
self.select_previous();
SlashKeyAction::Navigated
}
KeyCode::Down => {
self.select_next();
SlashKeyAction::Navigated
}
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.select_previous();
SlashKeyAction::Navigated
}
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.select_next();
SlashKeyAction::Navigated
}
KeyCode::Enter => {
let idx = self.selected_index;
SlashKeyAction::Selected(idx)
}
KeyCode::Esc => {
self.deactivate();
SlashKeyAction::Cancelled
}
KeyCode::Backspace => SlashKeyAction::Backspace,
KeyCode::Char(c) => SlashKeyAction::CharTyped(c),
_ => {
self.deactivate();
SlashKeyAction::Cancelled
}
}
}
}
impl Widget for SlashPopupState {
fn id(&self) -> &'static str {
widget_ids::SLASH_POPUP
}
fn priority(&self) -> u8 {
150 }
fn is_active(&self) -> bool {
self.active
}
fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
if !self.active {
return WidgetKeyResult::NotHandled;
}
if ctx.nav.is_move_up(&key) {
self.select_previous();
return WidgetKeyResult::Handled;
}
if ctx.nav.is_move_down(&key) {
self.select_next();
return WidgetKeyResult::Handled;
}
if ctx.nav.is_select(&key) {
let idx = self.selected_index;
return WidgetKeyResult::Action(WidgetAction::ExecuteCommand {
command: format!("__SLASH_INDEX_{}", idx),
});
}
if ctx.nav.is_cancel(&key) {
self.deactivate();
return WidgetKeyResult::Action(WidgetAction::Close);
}
match key.code {
KeyCode::Backspace => WidgetKeyResult::NotHandled,
KeyCode::Char(_) => WidgetKeyResult::NotHandled,
_ => {
self.deactivate();
WidgetKeyResult::Action(WidgetAction::Close)
}
}
}
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
if self.active {
render_slash_popup(self, &[] as &[SimpleCommand], frame, area, theme);
}
}
fn required_height(&self, max_height: u16) -> u16 {
if self.active {
self.popup_height(max_height)
} else {
0
}
}
fn blocks_input(&self) -> bool {
self.active }
fn is_overlay(&self) -> bool {
false }
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn into_any(self: Box<Self>) -> Box<dyn Any> {
self
}
}
pub fn render_slash_popup<C: SlashCommandDisplay>(
state: &SlashPopupState,
commands: &[C],
frame: &mut Frame,
area: Rect,
theme: &Theme,
) {
if !state.active {
return;
}
let inner_width = area.width.saturating_sub(2) as usize;
let mut lines = Vec::new();
lines.push(Line::from(vec![Span::styled(
state.config.header_text.clone(),
theme.popup_header(),
)]));
lines.push(Line::from(""));
for (idx, cmd) in commands.iter().enumerate() {
let is_selected = idx == state.selected_index;
let name_text = format!("{}{}", state.config.command_prefix, cmd.name());
let name_style = if is_selected {
theme.popup_selected_bg().patch(theme.popup_item_selected())
} else {
theme.popup_item()
};
if is_selected {
let padded = format!("{:<width$}", name_text, width = inner_width);
lines.push(Line::from(Span::styled(padded, name_style)));
} else {
lines.push(Line::from(Span::styled(name_text, name_style)));
}
let desc_text = format!("{}{}", state.config.description_indent, cmd.description());
let desc_style = if is_selected {
theme.popup_selected_bg().patch(theme.popup_item_desc_selected())
} else {
theme.popup_item_desc()
};
if is_selected {
let padded = format!("{:<width$}", desc_text, width = inner_width);
lines.push(Line::from(Span::styled(padded, desc_style)));
} else {
lines.push(Line::from(Span::styled(desc_text, desc_style)));
}
if idx < commands.len() - 1 {
lines.push(Line::from(""));
}
}
if commands.is_empty() {
lines.push(Line::from(Span::styled(
state.config.no_matches_message.clone(),
theme.popup_empty(),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.popup_border());
frame.render_widget(Clear, area);
let popup = Paragraph::new(lines).block(block);
frame.render_widget(popup, area);
}
#[derive(Clone)]
pub struct SimpleCommand {
name: String,
description: String,
}
impl SimpleCommand {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
}
}
}
impl SlashCommandDisplay for SimpleCommand {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_popup_state_navigation() {
let mut state = SlashPopupState::new();
state.activate();
state.set_filtered_count(3);
assert_eq!(state.selected_index, 0);
state.select_next();
assert_eq!(state.selected_index, 1);
state.select_next();
assert_eq!(state.selected_index, 2);
state.select_next();
assert_eq!(state.selected_index, 0);
state.select_previous();
assert_eq!(state.selected_index, 2);
}
#[test]
fn test_popup_state_empty() {
let mut state = SlashPopupState::new();
state.activate();
state.set_filtered_count(0);
state.select_next();
state.select_previous();
assert_eq!(state.selected_index, 0);
}
#[test]
fn test_simple_command() {
let cmd = SimpleCommand::new("help", "Show help message");
assert_eq!(cmd.name(), "help");
assert_eq!(cmd.description(), "Show help message");
}
}