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