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_field(&mut self, field: FilterField) {
if FilterField::ALL.contains(&field) {
self.focus = field;
}
}
pub fn focus(&self) -> FilterField {
self.focus
}
pub fn is_date_focus(&self) -> bool {
self.focus.is_date()
}
pub fn focused_value(&self) -> String {
self.focus.value(&self.filter).unwrap_or("").to_string()
}
pub fn set_focused_date(&mut self, value: &str) {
if !self.focus.is_date() {
return;
}
if value.is_empty() {
self.focus.unset(&mut self.filter);
} else {
*self.focus.value_mut(&mut self.filter) = value.to_string();
}
}
pub fn focused_date_is_complete(&self) -> bool {
self.focus.is_date() && crate::ui::date_picker::is_complete_date(&self.focused_value())
}
pub fn type_char(&mut self, c: char) {
if self.focus.is_date() {
if let Some(next) = crate::ui::date_picker::type_date(&self.focused_value(), c) {
*self.focus.value_mut(&mut self.filter) = next;
}
} else {
self.focus.value_mut(&mut self.filter).push(c);
}
}
pub fn backspace(&mut self) {
if self.focus.is_date() {
let next = crate::ui::date_picker::backspace_date(&self.focused_value());
if next.is_empty() {
self.focus.unset(&mut self.filter);
} else {
*self.focus.value_mut(&mut self.filter) = next;
}
} else {
self.focus.value_mut(&mut self.filter).pop();
}
}
pub fn render(&self, f: &mut Frame, area: Rect) {
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.expect("built-in bindings are valid");
self.render_with_bindings(
f,
area,
&bindings,
crate::keybindings::InputContext::FilterModal,
);
}
pub fn render_with_bindings(
&self,
f: &mut Frame,
area: Rect,
bindings: &crate::keybindings::EffectiveBindings,
context: crate::keybindings::InputContext,
) {
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(
format!(
"{}/{}: navigate",
bindings.label(context, crate::keybindings::TuiAction::Next),
bindings.label(context, crate::keybindings::TuiAction::Previous),
),
Style::default().fg(Color::DarkGray),
)));
rows.push(Line::from(Span::styled(
format!(
"{}: reset \u{b7} {}: date \u{b7} {}: apply \u{b7} {}: cancel",
bindings.label(context, crate::keybindings::TuiAction::Reset),
bindings.label(context, crate::keybindings::TuiAction::OpenPicker),
bindings.label(context, crate::keybindings::TuiAction::Submit),
bindings.label(context, crate::keybindings::TuiAction::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 / Down/Shift+Tab / Up: navigate"),
"footer must advertise field navigation, got:\n{text}"
);
assert!(
text.contains("r / Shift+R: reset") && text.contains("Enter: apply"),
"got:\n{text}"
);
}
#[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::Cycle);
}
#[test]
fn focus_cycles_backward() {
let mut modal = FilterModal::new();
modal.focus_prev();
assert_eq!(modal.focus(), FilterField::Category);
modal.focus_prev();
assert_eq!(modal.focus(), FilterField::Parent);
modal.focus_prev();
assert_eq!(modal.focus(), FilterField::DueDate);
modal.focus_prev();
assert_eq!(modal.focus(), FilterField::StartDate);
}
#[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()),
cycle: None,
milestone: None,
reviewer: None,
start_date: None,
due_date: None,
overdue: None,
parent: None,
category: None,
scope: None,
archived: None,
});
modal.clear();
assert_eq!(modal.active_filter(), &StructuredFilter::default());
assert_eq!(modal.focus(), FilterField::Assignee);
}
}