1use ratatui::{
10 Frame,
11 layout::Rect,
12 text::{Line, Span},
13 widgets::{Block, Borders, Clear, Paragraph},
14};
15
16use crate::themes::Theme;
17
18pub mod defaults {
20 pub const HEADER_TEXT: &str =
22 " Slash command mode \u{2014} Use arrow keys to select, Enter to execute, Esc to cancel";
23 pub const NO_MATCHES_MESSAGE: &str = " No matching commands";
25 pub const COMMAND_PREFIX: &str = " /";
27 pub const DESCRIPTION_INDENT: &str = " ";
29}
30
31#[derive(Clone)]
33pub struct SlashPopupConfig {
34 pub header_text: String,
36 pub no_matches_message: String,
38 pub command_prefix: String,
40 pub description_indent: String,
42}
43
44impl Default for SlashPopupConfig {
45 fn default() -> Self {
46 Self::new()
47 }
48}
49
50impl SlashPopupConfig {
51 pub fn new() -> Self {
53 Self {
54 header_text: defaults::HEADER_TEXT.to_string(),
55 no_matches_message: defaults::NO_MATCHES_MESSAGE.to_string(),
56 command_prefix: defaults::COMMAND_PREFIX.to_string(),
57 description_indent: defaults::DESCRIPTION_INDENT.to_string(),
58 }
59 }
60
61 pub fn with_header_text(mut self, text: impl Into<String>) -> Self {
63 self.header_text = text.into();
64 self
65 }
66
67 pub fn with_no_matches_message(mut self, message: impl Into<String>) -> Self {
69 self.no_matches_message = message.into();
70 self
71 }
72
73 pub fn with_command_prefix(mut self, prefix: impl Into<String>) -> Self {
75 self.command_prefix = prefix.into();
76 self
77 }
78}
79
80pub trait SlashCommandDisplay {
85 fn name(&self) -> &str;
87
88 fn description(&self) -> &str;
90}
91
92impl<T: crate::commands::SlashCommand + ?Sized> SlashCommandDisplay for T {
94 fn name(&self) -> &str {
95 crate::commands::SlashCommand::name(self)
96 }
97
98 fn description(&self) -> &str {
99 crate::commands::SlashCommand::description(self)
100 }
101}
102
103impl SlashCommandDisplay for &dyn crate::commands::SlashCommand {
105 fn name(&self) -> &str {
106 crate::commands::SlashCommand::name(*self)
107 }
108
109 fn description(&self) -> &str {
110 crate::commands::SlashCommand::description(*self)
111 }
112}
113
114pub struct SlashPopupState {
116 pub active: bool,
118 pub selected_index: usize,
120 filtered_count: usize,
122 config: SlashPopupConfig,
124}
125
126impl SlashPopupState {
127 pub fn new() -> Self {
129 Self::with_config(SlashPopupConfig::new())
130 }
131
132 pub fn with_config(config: SlashPopupConfig) -> Self {
134 Self {
135 active: false,
136 selected_index: 0,
137 filtered_count: 0,
138 config,
139 }
140 }
141
142 pub fn config(&self) -> &SlashPopupConfig {
144 &self.config
145 }
146
147 pub fn set_config(&mut self, config: SlashPopupConfig) {
149 self.config = config;
150 }
151
152 pub fn activate(&mut self) {
154 self.active = true;
155 self.selected_index = 0;
156 }
157
158 pub fn deactivate(&mut self) {
160 self.active = false;
161 self.selected_index = 0;
162 self.filtered_count = 0;
163 }
164
165 pub fn set_filtered_count(&mut self, count: usize) {
167 self.filtered_count = count;
168 if count > 0 {
169 self.selected_index = self.selected_index.min(count - 1);
170 } else {
171 self.selected_index = 0;
172 }
173 }
174
175 pub fn select_previous(&mut self) {
177 if self.filtered_count == 0 {
178 return;
179 }
180 if self.selected_index == 0 {
181 self.selected_index = self.filtered_count - 1;
182 } else {
183 self.selected_index -= 1;
184 }
185 }
186
187 pub fn select_next(&mut self) {
189 if self.filtered_count == 0 {
190 return;
191 }
192 self.selected_index = (self.selected_index + 1) % self.filtered_count;
193 }
194
195 pub fn selected_index(&self) -> usize {
197 self.selected_index
198 }
199
200 pub fn popup_height(&self, max_height: u16) -> u16 {
202 let filtered_count = self.filtered_count.max(1);
203 let content_height = 2 + (filtered_count * 3) + 2;
205 (content_height as u16).min(max_height.saturating_sub(10))
206 }
207}
208
209impl Default for SlashPopupState {
210 fn default() -> Self {
211 Self::new()
212 }
213}
214
215use super::{Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult, widget_ids};
218use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
219use std::any::Any;
220
221#[derive(Debug, Clone, PartialEq)]
223pub enum SlashKeyAction {
224 None,
226 Navigated,
228 Selected(usize),
230 Cancelled,
232 CharTyped(char),
234 Backspace,
236}
237
238impl SlashPopupState {
239 pub fn process_key(&mut self, key: KeyEvent) -> SlashKeyAction {
244 if !self.active {
245 return SlashKeyAction::None;
246 }
247
248 match key.code {
249 KeyCode::Up => {
250 self.select_previous();
251 SlashKeyAction::Navigated
252 }
253 KeyCode::Down => {
254 self.select_next();
255 SlashKeyAction::Navigated
256 }
257 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
258 self.select_previous();
259 SlashKeyAction::Navigated
260 }
261 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
262 self.select_next();
263 SlashKeyAction::Navigated
264 }
265 KeyCode::Enter => {
266 let idx = self.selected_index;
267 SlashKeyAction::Selected(idx)
268 }
269 KeyCode::Esc => {
270 self.deactivate();
271 SlashKeyAction::Cancelled
272 }
273 KeyCode::Backspace => SlashKeyAction::Backspace,
274 KeyCode::Char(c) => SlashKeyAction::CharTyped(c),
275 _ => {
276 self.deactivate();
277 SlashKeyAction::Cancelled
278 }
279 }
280 }
281}
282
283impl Widget for SlashPopupState {
284 fn id(&self) -> &'static str {
285 widget_ids::SLASH_POPUP
286 }
287
288 fn priority(&self) -> u8 {
289 150 }
291
292 fn is_active(&self) -> bool {
293 self.active
294 }
295
296 fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
297 if !self.active {
298 return WidgetKeyResult::NotHandled;
299 }
300
301 if ctx.nav.is_move_up(&key) {
303 self.select_previous();
304 return WidgetKeyResult::Handled;
305 }
306 if ctx.nav.is_move_down(&key) {
307 self.select_next();
308 return WidgetKeyResult::Handled;
309 }
310 if ctx.nav.is_select(&key) {
311 let idx = self.selected_index;
312 return WidgetKeyResult::Action(WidgetAction::ExecuteCommand {
313 command: format!("__SLASH_INDEX_{}", idx),
314 });
315 }
316 if ctx.nav.is_cancel(&key) {
317 self.deactivate();
318 return WidgetKeyResult::Action(WidgetAction::Close);
319 }
320
321 match key.code {
323 KeyCode::Backspace => WidgetKeyResult::NotHandled,
324 KeyCode::Char(_) => WidgetKeyResult::NotHandled,
325 _ => {
326 self.deactivate();
328 WidgetKeyResult::Action(WidgetAction::Close)
329 }
330 }
331 }
332
333 fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
334 if self.active {
338 render_slash_popup(self, &[] as &[SimpleCommand], frame, area, theme);
339 }
340 }
341
342 fn required_height(&self, max_height: u16) -> u16 {
343 if self.active {
344 self.popup_height(max_height)
345 } else {
346 0
347 }
348 }
349
350 fn blocks_input(&self) -> bool {
351 self.active }
353
354 fn is_overlay(&self) -> bool {
355 false }
357
358 fn as_any(&self) -> &dyn Any {
359 self
360 }
361
362 fn as_any_mut(&mut self) -> &mut dyn Any {
363 self
364 }
365
366 fn into_any(self: Box<Self>) -> Box<dyn Any> {
367 self
368 }
369}
370
371pub fn render_slash_popup<C: SlashCommandDisplay>(
380 state: &SlashPopupState,
381 commands: &[C],
382 frame: &mut Frame,
383 area: Rect,
384 theme: &Theme,
385) {
386 if !state.active {
387 return;
388 }
389
390 let inner_width = area.width.saturating_sub(2) as usize;
392
393 let mut lines = Vec::new();
395
396 lines.push(Line::from(vec![Span::styled(
398 state.config.header_text.clone(),
399 theme.popup_header(),
400 )]));
401 lines.push(Line::from("")); for (idx, cmd) in commands.iter().enumerate() {
405 let is_selected = idx == state.selected_index;
406
407 let name_text = format!("{}{}", state.config.command_prefix, cmd.name());
409 let name_style = if is_selected {
410 theme.popup_selected_bg().patch(theme.popup_item_selected())
411 } else {
412 theme.popup_item()
413 };
414 if is_selected {
415 let padded = format!("{:<width$}", name_text, width = inner_width);
417 lines.push(Line::from(Span::styled(padded, name_style)));
418 } else {
419 lines.push(Line::from(Span::styled(name_text, name_style)));
420 }
421
422 let desc_text = format!("{}{}", state.config.description_indent, cmd.description());
424 let desc_style = if is_selected {
425 theme
426 .popup_selected_bg()
427 .patch(theme.popup_item_desc_selected())
428 } else {
429 theme.popup_item_desc()
430 };
431 if is_selected {
432 let padded = format!("{:<width$}", desc_text, width = inner_width);
434 lines.push(Line::from(Span::styled(padded, desc_style)));
435 } else {
436 lines.push(Line::from(Span::styled(desc_text, desc_style)));
437 }
438
439 if idx < commands.len() - 1 {
441 lines.push(Line::from(""));
442 }
443 }
444
445 if commands.is_empty() {
447 lines.push(Line::from(Span::styled(
448 state.config.no_matches_message.clone(),
449 theme.popup_empty(),
450 )));
451 }
452
453 let block = Block::default()
454 .borders(Borders::ALL)
455 .border_style(theme.popup_border());
456
457 frame.render_widget(Clear, area);
459
460 let popup = Paragraph::new(lines).block(block);
461 frame.render_widget(popup, area);
462}
463
464#[derive(Clone)]
466pub struct SimpleCommand {
467 name: String,
468 description: String,
469}
470
471impl SimpleCommand {
472 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
473 Self {
474 name: name.into(),
475 description: description.into(),
476 }
477 }
478}
479
480impl SlashCommandDisplay for SimpleCommand {
481 fn name(&self) -> &str {
482 &self.name
483 }
484
485 fn description(&self) -> &str {
486 &self.description
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 #[test]
495 fn test_popup_state_navigation() {
496 let mut state = SlashPopupState::new();
497 state.activate();
498 state.set_filtered_count(3);
499
500 assert_eq!(state.selected_index, 0);
501
502 state.select_next();
503 assert_eq!(state.selected_index, 1);
504
505 state.select_next();
506 assert_eq!(state.selected_index, 2);
507
508 state.select_next();
510 assert_eq!(state.selected_index, 0);
511
512 state.select_previous();
514 assert_eq!(state.selected_index, 2);
515 }
516
517 #[test]
518 fn test_popup_state_empty() {
519 let mut state = SlashPopupState::new();
520 state.activate();
521 state.set_filtered_count(0);
522
523 state.select_next();
525 state.select_previous();
526 assert_eq!(state.selected_index, 0);
527 }
528
529 #[test]
530 fn test_simple_command() {
531 let cmd = SimpleCommand::new("help", "Show help message");
532 assert_eq!(cmd.name(), "help");
533 assert_eq!(cmd.description(), "Show help message");
534 }
535}