use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::search::{FilterField, StructuredFilter};
use super::modal::modal_rect;
pub struct FilterModal {
filter: StructuredFilter,
focus: FilterField,
}
impl Default for FilterModal {
fn default() -> Self {
Self::new()
}
}
impl FilterModal {
pub fn new() -> Self {
Self {
filter: StructuredFilter::default(),
focus: FilterField::Assignee,
}
}
pub fn with_filter(filter: StructuredFilter) -> Self {
Self {
filter,
focus: FilterField::Assignee,
}
}
pub fn active_filter(&self) -> &StructuredFilter {
&self.filter
}
pub fn active_filter_mut(&mut self) -> &mut StructuredFilter {
&mut self.filter
}
pub fn apply(&mut self) {}
pub fn clear(&mut self) {
self.filter = StructuredFilter::default();
self.focus = FilterField::Assignee;
}
pub fn focus_next(&mut self) {
self.focus = self.focus.next();
}
pub fn focus_prev(&mut self) {
self.focus = self.focus.prev();
}
pub fn focus(&self) -> FilterField {
self.focus
}
pub fn render(&self, f: &mut Frame, area: Rect) {
let rect = modal_rect(area);
let mut rows: Vec<Line> = Vec::new();
for field in FilterField::ALL {
let (label, value) = (field.label(), field.value(&self.filter));
let is_focus = field == self.focus;
let value_text = value.unwrap_or("(unset)");
let style = if is_focus {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let label_style = if is_focus {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let prefix = if is_focus { "> " } else { " " };
rows.push(Line::from(vec![
ratatui::text::Span::styled(format!("{prefix}{label}:"), label_style),
ratatui::text::Span::styled(format!(" {value_text}"), style),
]));
}
rows.push(Line::from(""));
rows.push(Line::from(Span::styled(
"Tab/\u{2191}\u{2193}: navigate \u{b7} r: reset \u{b7} Enter: apply \u{b7} Esc: cancel",
Style::default().fg(Color::DarkGray),
)));
let block = Block::default()
.borders(Borders::ALL)
.title(" Filter ")
.style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(rows).block(block).style(Style::default());
f.render_widget(Clear, rect);
f.render_widget(para, rect);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn smoke(w: u16, h: u16, title_focus: bool) {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = f.area();
let mut modal = FilterModal::new();
if title_focus {
modal.focus_next();
}
modal.render(f, area);
})
.unwrap();
}
#[test]
fn renders_at_120x40() {
smoke(120, 40, false);
}
#[test]
fn renders_at_80x24() {
smoke(80, 24, false);
}
#[test]
fn renders_at_tiny_20x5() {
smoke(20, 5, true);
}
fn buffer_text(w: u16, h: u16) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let modal = FilterModal::new();
modal.render(f, f.area());
})
.unwrap();
let buffer = terminal.backend().buffer();
let mut text = String::new();
for y in 0..h {
for x in 0..w {
for ch in buffer[(x, y)].symbol().chars() {
text.push(ch);
}
}
text.push('\n');
}
text
}
#[test]
fn footer_hint_present_at_normal_size() {
let text = buffer_text(80, 24);
assert!(
text.contains("Tab/\u{2191}\u{2193}: navigate"),
"footer must advertise arrow navigation, got:\n{text}"
);
assert!(text.contains("r: reset") && text.contains("Enter: apply"));
}
#[test]
fn footer_render_does_not_panic_on_tiny_terminal() {
let text = buffer_text(20, 5);
assert!(!text.is_empty());
}
#[test]
fn focus_cycles_forward() {
let mut modal = FilterModal::new();
assert_eq!(modal.focus(), FilterField::Assignee);
modal.focus_next();
assert_eq!(modal.focus(), FilterField::Label);
modal.focus_next();
modal.focus_next();
assert_eq!(modal.focus(), FilterField::Priority);
modal.focus_next();
assert_eq!(modal.focus(), FilterField::Assignee);
}
#[test]
fn focus_cycles_backward() {
let mut modal = FilterModal::new();
modal.focus_prev();
assert_eq!(modal.focus(), FilterField::Priority);
modal.focus_prev();
assert_eq!(modal.focus(), FilterField::Status);
}
#[test]
fn clear_resets_all_criteria_and_focus() {
let mut modal = FilterModal::with_filter(StructuredFilter {
assignee: Some("alice".to_string()),
label: Some("urgent".to_string()),
status: Some("todo".to_string()),
priority: Some("high".to_string()),
});
modal.clear();
assert_eq!(modal.active_filter(), &StructuredFilter::default());
assert_eq!(modal.focus(), FilterField::Assignee);
}
}