use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::Rect,
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::theme::Theme;
#[derive(Debug, Default)]
pub struct FilterInput {
pub active: bool,
pub text: String,
cursor: usize,
}
impl FilterInput {
pub fn new() -> Self {
Self::default()
}
pub fn activate(&mut self) {
self.active = true;
}
pub fn deactivate(&mut self) {
self.active = false;
}
pub fn clear(&mut self) {
self.text.clear();
self.cursor = 0;
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
if !self.active {
return false;
}
match key.code {
KeyCode::Char(c) => {
self.text.insert(self.cursor, c);
self.cursor += 1;
}
KeyCode::Backspace => {
if self.cursor > 0 {
self.cursor -= 1;
self.text.remove(self.cursor);
}
}
KeyCode::Delete => {
if self.cursor < self.text.len() {
self.text.remove(self.cursor);
}
}
KeyCode::Left => {
self.cursor = self.cursor.saturating_sub(1);
}
KeyCode::Right => {
self.cursor = (self.cursor + 1).min(self.text.len());
}
KeyCode::Home => {
self.cursor = 0;
}
KeyCode::End => {
self.cursor = self.text.len();
}
KeyCode::Esc => {
self.deactivate();
}
KeyCode::Enter => {
self.deactivate();
}
_ => return false,
}
true
}
pub fn render(&self, frame: &mut Frame, area: Rect) {
let style = if self.active {
Theme::surface()
} else {
Theme::dim()
};
let prefix = if self.active { "/ " } else { "Filter: " };
let line = Line::from(vec![
Span::styled(prefix, Theme::key_hint()),
Span::styled(&self.text, style),
if self.active {
Span::styled("▏", Theme::key_hint())
} else {
Span::raw("")
},
]);
let block = Block::default().borders(Borders::BOTTOM).border_style(Theme::dim());
let paragraph = Paragraph::new(line).block(block).style(style);
frame.render_widget(paragraph, area);
}
pub fn matches(&self, text: &str) -> bool {
if self.text.is_empty() {
return true;
}
let lower_text = text.to_lowercase();
for term in self.text.split_whitespace() {
if let Some((key, value)) = term.split_once(':') {
let lower_value = value.to_lowercase();
let matched = match key.to_lowercase().as_str() {
"method" => {
text.split_whitespace()
.nth(1)
.is_some_and(|m| m.eq_ignore_ascii_case(value))
}
"status" => {
text.split_whitespace().nth(3).is_some_and(|s| {
if lower_value.ends_with("xx") {
s.starts_with(&lower_value[..1])
} else {
s == value
}
})
}
"path" => {
text.split_whitespace()
.nth(2)
.is_some_and(|p| p.to_lowercase().contains(&lower_value))
}
_ => {
lower_text.contains(&term.to_lowercase())
}
};
if !matched {
return false;
}
} else {
if !lower_text.contains(&term.to_lowercase()) {
return false;
}
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
#[test]
fn new_creates_empty_inactive_filter() {
let f = FilterInput::new();
assert!(!f.active);
assert!(f.text.is_empty());
assert!(f.is_empty());
}
#[test]
fn activate_and_deactivate() {
let mut f = FilterInput::new();
assert!(!f.active);
f.activate();
assert!(f.active);
f.deactivate();
assert!(!f.active);
}
#[test]
fn char_input_appends_text() {
let mut f = FilterInput::new();
f.activate();
f.handle_key(key(KeyCode::Char('h')));
assert_eq!(f.text, "h");
f.handle_key(key(KeyCode::Char('i')));
assert_eq!(f.text, "hi");
}
#[test]
fn backspace_removes_last_char() {
let mut f = FilterInput::new();
f.activate();
f.handle_key(key(KeyCode::Char('a')));
f.handle_key(key(KeyCode::Char('b')));
f.handle_key(key(KeyCode::Char('c')));
assert_eq!(f.text, "abc");
f.handle_key(key(KeyCode::Backspace));
assert_eq!(f.text, "ab");
}
#[test]
fn backspace_at_start_does_nothing() {
let mut f = FilterInput::new();
f.activate();
let consumed = f.handle_key(key(KeyCode::Backspace));
assert!(consumed);
assert!(f.text.is_empty());
}
#[test]
fn delete_removes_char_at_cursor() {
let mut f = FilterInput::new();
f.activate();
f.handle_key(key(KeyCode::Char('a')));
f.handle_key(key(KeyCode::Char('b')));
f.handle_key(key(KeyCode::Char('c')));
f.handle_key(key(KeyCode::Left));
f.handle_key(key(KeyCode::Delete));
assert_eq!(f.text, "ab");
}
#[test]
fn delete_at_end_does_nothing() {
let mut f = FilterInput::new();
f.activate();
f.handle_key(key(KeyCode::Char('a')));
f.handle_key(key(KeyCode::Delete));
assert_eq!(f.text, "a");
}
#[test]
fn left_right_movement() {
let mut f = FilterInput::new();
f.activate();
f.handle_key(key(KeyCode::Char('a')));
f.handle_key(key(KeyCode::Char('b')));
f.handle_key(key(KeyCode::Char('c')));
f.handle_key(key(KeyCode::Left));
f.handle_key(key(KeyCode::Left));
f.handle_key(key(KeyCode::Char('X')));
assert_eq!(f.text, "aXbc");
}
#[test]
fn left_at_start_does_not_underflow() {
let mut f = FilterInput::new();
f.activate();
f.handle_key(key(KeyCode::Left));
f.handle_key(key(KeyCode::Left));
f.handle_key(key(KeyCode::Char('a')));
assert_eq!(f.text, "a");
}
#[test]
fn right_at_end_does_not_overflow() {
let mut f = FilterInput::new();
f.activate();
f.handle_key(key(KeyCode::Char('a')));
f.handle_key(key(KeyCode::Right));
f.handle_key(key(KeyCode::Right));
f.handle_key(key(KeyCode::Char('b')));
assert_eq!(f.text, "ab");
}
#[test]
fn home_and_end_keys() {
let mut f = FilterInput::new();
f.activate();
f.handle_key(key(KeyCode::Char('a')));
f.handle_key(key(KeyCode::Char('b')));
f.handle_key(key(KeyCode::Char('c')));
f.handle_key(key(KeyCode::Home));
f.handle_key(key(KeyCode::Char('X')));
assert_eq!(f.text, "Xabc");
f.handle_key(key(KeyCode::End));
f.handle_key(key(KeyCode::Char('Y')));
assert_eq!(f.text, "XabcY");
}
#[test]
fn esc_deactivates_filter() {
let mut f = FilterInput::new();
f.activate();
f.handle_key(key(KeyCode::Char('a')));
let consumed = f.handle_key(key(KeyCode::Esc));
assert!(consumed);
assert!(!f.active);
assert_eq!(f.text, "a");
}
#[test]
fn enter_deactivates_filter() {
let mut f = FilterInput::new();
f.activate();
f.handle_key(key(KeyCode::Char('x')));
let consumed = f.handle_key(key(KeyCode::Enter));
assert!(consumed);
assert!(!f.active);
assert_eq!(f.text, "x");
}
#[test]
fn inactive_filter_does_not_consume_keys() {
let mut f = FilterInput::new();
let consumed = f.handle_key(key(KeyCode::Char('a')));
assert!(!consumed);
assert!(f.text.is_empty());
}
#[test]
fn matches_empty_filter_matches_everything() {
let f = FilterInput::new();
assert!(f.matches("anything"));
assert!(f.matches(""));
assert!(f.matches("Hello World"));
}
#[test]
fn matches_case_insensitive() {
let mut f = FilterInput::new();
f.activate();
f.handle_key(key(KeyCode::Char('h')));
f.handle_key(key(KeyCode::Char('e')));
f.handle_key(key(KeyCode::Char('l')));
f.handle_key(key(KeyCode::Char('l')));
f.handle_key(key(KeyCode::Char('o')));
assert!(f.matches("Hello World"));
assert!(f.matches("HELLO"));
assert!(f.matches("say hello"));
assert!(!f.matches("world"));
}
#[test]
fn matches_partial_substring() {
let mut f = FilterInput::new();
f.text = "api".to_string();
assert!(f.matches("/api/v1/users"));
assert!(f.matches("API_KEY"));
assert!(!f.matches("application"));
}
#[test]
fn structured_filter_method() {
let mut f = FilterInput::new();
f.text = "method:GET".to_string();
let log_line = "14:23:01 GET /api/users 200 12ms";
assert!(f.matches(log_line));
let post_line = "14:23:02 POST /api/items 201 34ms";
assert!(!f.matches(post_line));
}
#[test]
fn structured_filter_status_class() {
let mut f = FilterInput::new();
f.text = "status:4xx".to_string();
let ok = "14:23:01 GET /api/users 200 12ms";
assert!(!f.matches(ok));
let not_found = "14:23:03 GET /api/users/5 404 8ms";
assert!(f.matches(not_found));
}
#[test]
fn structured_filter_path() {
let mut f = FilterInput::new();
f.text = "path:/api".to_string();
let api_line = "14:23:01 GET /api/users 200 12ms";
assert!(f.matches(api_line));
let health_line = "14:23:03 GET /health 200 2ms";
assert!(!f.matches(health_line));
}
#[test]
fn structured_filter_multiple_terms() {
let mut f = FilterInput::new();
f.text = "method:GET status:2xx".to_string();
let get200 = "14:23:01 GET /api/users 200 12ms";
assert!(f.matches(get200));
let post201 = "14:23:02 POST /api/items 201 34ms";
assert!(!f.matches(post201));
}
#[test]
fn clear_resets_text_and_cursor() {
let mut f = FilterInput::new();
f.activate();
f.handle_key(key(KeyCode::Char('t')));
f.handle_key(key(KeyCode::Char('e')));
f.handle_key(key(KeyCode::Char('s')));
f.handle_key(key(KeyCode::Char('t')));
f.clear();
assert!(f.text.is_empty());
assert!(f.is_empty());
f.handle_key(key(KeyCode::Char('a')));
assert_eq!(f.text, "a");
}
}