use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Paragraph};
pub const FIELD_HEIGHT: u16 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Handled {
No,
Yes,
Changed,
Leave,
}
#[derive(Debug, Default, Clone)]
pub struct Filter {
query: String,
restore: Option<String>,
typing: bool,
}
impl Filter {
pub fn is_typing(&self) -> bool {
self.typing
}
pub fn is_active(&self) -> bool {
!self.query.is_empty()
}
pub fn query(&self) -> &str {
&self.query
}
pub fn matches(&self, text: &str) -> bool {
if self.query.is_empty() {
return true;
}
text.to_lowercase().contains(&self.query.to_lowercase())
}
pub fn matches_any<'a>(&self, fields: impl IntoIterator<Item = &'a str>) -> bool {
if self.query.is_empty() {
return true;
}
fields.into_iter().any(|field| self.matches(field))
}
pub fn clear(&mut self) {
self.query.clear();
self.typing = false;
self.restore = None;
}
pub fn handle_key(&mut self, key: KeyEvent) -> Handled {
let modified = key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT);
if !self.typing {
if key.code == KeyCode::Char('/') && !modified {
self.restore = Some(self.query.clone());
self.typing = true;
return Handled::Yes;
}
if key.code == KeyCode::Esc && self.is_active() {
self.clear();
return Handled::Changed;
}
return Handled::No;
}
if modified {
if key.code == KeyCode::Char('c') {
return self.cancel();
}
return Handled::Yes;
}
match key.code {
KeyCode::Esc => self.cancel(),
KeyCode::Enter => {
self.typing = false;
self.restore = None;
Handled::Yes
}
KeyCode::Char(c) => {
self.query.push(c);
Handled::Changed
}
KeyCode::Backspace => {
self.query.pop();
Handled::Changed
}
_ => Handled::Yes,
}
}
fn cancel(&mut self) -> Handled {
let restored = self.restore.take().unwrap_or_default();
let changed = restored != self.query;
self.query = restored;
self.typing = false;
if changed {
Handled::Changed
} else {
Handled::Yes
}
}
pub fn height(&self) -> u16 {
if self.typing { FIELD_HEIGHT } else { 0 }
}
pub fn label(&self) -> String {
if self.is_active() {
format!(" · /{}", self.query)
} else {
String::new()
}
}
pub fn render(&self, frame: &mut Frame, area: Rect) {
if !self.typing || area.height == 0 {
return;
}
frame.render_widget(
Paragraph::new(format!("{}▏", self.query))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.title(" / — ↵ to keep, esc to drop "),
)
.style(Style::default().add_modifier(Modifier::BOLD)),
area,
);
}
pub fn count_label(shown: usize, total: usize) -> String {
if shown == total {
total.to_string()
} else {
format!("{shown} of {total}")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn typed(filter: &mut Filter, text: &str) {
for c in text.chars() {
filter.handle_key(key(KeyCode::Char(c)));
}
}
#[test]
fn slash_opens_the_field_and_typing_narrows_as_it_goes() {
let mut filter = Filter::default();
assert_eq!(filter.handle_key(key(KeyCode::Char('/'))), Handled::Yes);
assert!(filter.is_typing());
assert_eq!(filter.handle_key(key(KeyCode::Char('a'))), Handled::Changed);
typed(&mut filter, "uth");
assert_eq!(filter.query(), "auth");
assert!(filter.matches("Fix the AUTH flow"), "case-insensitive");
assert!(!filter.matches("something else"));
}
#[test]
fn an_empty_filter_passes_everything() {
let filter = Filter::default();
assert!(filter.matches("anything"));
assert!(filter.matches(""));
assert!(!filter.is_active());
}
#[test]
fn enter_keeps_the_filter_and_gives_the_keys_back() {
let mut filter = Filter::default();
filter.handle_key(key(KeyCode::Char('/')));
typed(&mut filter, "api");
filter.handle_key(key(KeyCode::Enter));
assert!(!filter.is_typing(), "the field is closed");
assert!(filter.is_active(), "and the rows stay narrowed");
assert_eq!(filter.query(), "api");
}
#[test]
fn esc_backs_out_one_layer_at_a_time() {
let mut filter = Filter::default();
filter.handle_key(key(KeyCode::Char('/')));
typed(&mut filter, "api");
filter.handle_key(key(KeyCode::Enter));
filter.handle_key(key(KeyCode::Char('/')));
typed(&mut filter, "-x");
assert_eq!(filter.query(), "api-x");
assert_eq!(filter.handle_key(key(KeyCode::Esc)), Handled::Changed);
assert_eq!(filter.query(), "api", "back to what it was");
assert_eq!(filter.handle_key(key(KeyCode::Esc)), Handled::Changed);
assert!(!filter.is_active());
assert_eq!(filter.handle_key(key(KeyCode::Esc)), Handled::No);
}
#[test]
fn ctrl_c_drops_the_field_like_every_other_field() {
let mut filter = Filter::default();
filter.handle_key(key(KeyCode::Char('/')));
typed(&mut filter, "half");
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
assert_eq!(filter.handle_key(ctrl_c), Handled::Changed);
assert!(!filter.is_typing());
assert!(!filter.is_active());
}
#[test]
fn the_field_swallows_the_keys_that_are_bindings_outside_it() {
let mut filter = Filter::default();
filter.handle_key(key(KeyCode::Char('/')));
typed(&mut filter, "next");
assert_eq!(filter.query(), "next");
}
#[test]
fn a_slash_inside_a_query_is_typed_rather_than_reopening() {
let mut filter = Filter::default();
filter.handle_key(key(KeyCode::Char('/')));
typed(&mut filter, "marver/4");
assert_eq!(filter.query(), "marver/4");
}
#[test]
fn any_of_several_fields_can_match() {
let mut filter = Filter::default();
filter.handle_key(key(KeyCode::Char('/')));
typed(&mut filter, "web");
assert!(filter.matches_any(["Fix auth", "web", "running"]));
assert!(!filter.matches_any(["Fix auth", "api", "running"]));
}
#[test]
fn the_title_says_what_is_being_filtered_and_the_count_says_how_much() {
let mut filter = Filter::default();
assert_eq!(filter.label(), "");
assert_eq!(Filter::count_label(9, 9), "9");
filter.handle_key(key(KeyCode::Char('/')));
typed(&mut filter, "auth");
assert_eq!(filter.label(), " · /auth");
assert_eq!(Filter::count_label(2, 9), "2 of 9");
}
}