use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
prelude::Stylize,
style::{Color, Style},
symbols::border,
text::Line,
widgets::{Block, Borders, Paragraph},
};
use super::Component;
use crate::tui::actions::Action;
#[derive(Default)]
pub struct SearchModal {
query: String,
last_query: Option<String>,
live_match_count: Option<usize>,
}
impl SearchModal {
pub fn new(last_query: Option<String>) -> Self {
Self {
last_query,
..Self::default()
}
}
pub fn set_live_match_count(&mut self, count: usize) {
self.live_match_count = Some(count);
}
#[cfg(test)]
pub(crate) fn query(&self) -> &str {
&self.query
}
pub fn popup_area(&self, area: Rect) -> Rect {
let width = 50.min(area.width);
let height = 4.min(area.height);
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, width, height)
}
pub fn cursor_screen_position(&self, area: Rect) -> (u16, u16) {
let col = area.x + 2 + self.query.chars().count() as u16;
(col, area.y + 1)
}
}
impl Component for SearchModal {
fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
match key.code {
KeyCode::Char(c) => {
self.query.push(c);
Ok(Some(Action::SearchQueryChanged(self.query.clone())))
}
KeyCode::Backspace => {
self.query.pop();
Ok(Some(Action::SearchQueryChanged(self.query.clone())))
}
KeyCode::Enter => Ok(Some(Action::SearchSubmitted(self.query.clone()))),
KeyCode::Esc => Ok(Some(Action::DialogCancelled)),
_ => Ok(None),
}
}
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(area);
let title = match self.live_match_count {
Some(1) => " Search - 1 match ".to_string(),
Some(n) => format!(" Search - {n} matches "),
None => " Search ".to_string(),
};
let block = Block::default()
.title(title.bold().fg(Color::Cyan))
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::new().fg(Color::Cyan));
frame.render_widget(
Paragraph::new(format!("/{}", self.query)).block(block),
layout[0],
);
let hint = match &self.last_query {
Some(last) if self.query.is_empty() => {
format!(" Enter: repeat '{last}' | Esc: cancel ")
}
_ => " Enter: jump to match | Esc: cancel ".to_string(),
};
frame.render_widget(Line::from(hint).dim(), layout[1]);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyModifiers;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn typing_characters_builds_up_the_query() {
let mut modal = SearchModal::new(None);
modal.handle_key_event(key(KeyCode::Char('f'))).unwrap();
modal.handle_key_event(key(KeyCode::Char('o'))).unwrap();
modal.handle_key_event(key(KeyCode::Char('o'))).unwrap();
assert_eq!(modal.query, "foo");
}
#[test]
fn backspace_removes_the_last_character() {
let mut modal = SearchModal::new(None);
modal.handle_key_event(key(KeyCode::Char('f'))).unwrap();
modal.handle_key_event(key(KeyCode::Char('o'))).unwrap();
modal.handle_key_event(key(KeyCode::Backspace)).unwrap();
assert_eq!(modal.query, "f");
}
#[test]
fn backspace_on_an_empty_query_is_a_no_op() {
let mut modal = SearchModal::new(None);
modal.handle_key_event(key(KeyCode::Backspace)).unwrap();
assert_eq!(modal.query, "");
}
#[test]
fn enter_submits_the_typed_query() {
let mut modal = SearchModal::new(None);
modal.handle_key_event(key(KeyCode::Char('x'))).unwrap();
let action = modal.handle_key_event(key(KeyCode::Enter)).unwrap();
assert_eq!(action, Some(Action::SearchSubmitted("x".to_string())));
}
#[test]
fn esc_cancels_without_submitting() {
let mut modal = SearchModal::new(None);
let action = modal.handle_key_event(key(KeyCode::Esc)).unwrap();
assert_eq!(action, Some(Action::DialogCancelled));
}
#[test]
fn cursor_screen_position_follows_the_typed_query() {
let modal = SearchModal::new(None);
let area = Rect::new(10, 5, 50, 4);
assert_eq!(modal.cursor_screen_position(area), (12, 6));
let mut modal = SearchModal::new(None);
modal.handle_key_event(key(KeyCode::Char('a'))).unwrap();
modal.handle_key_event(key(KeyCode::Char('b'))).unwrap();
assert_eq!(modal.cursor_screen_position(area), (14, 6));
}
}