1use ratatui::Frame;
13use ratatui::layout::Rect;
14use ratatui::style::{Color, Modifier, Style};
15use ratatui::text::{Line, Span};
16use ratatui::widgets::{Block, Borders, Clear, Paragraph};
17
18use crate::search::{FilterField, StructuredFilter};
19
20use super::modal::modal_rect;
21
22pub struct FilterModal {
24 filter: StructuredFilter,
26 focus: FilterField,
28}
29
30impl Default for FilterModal {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36impl FilterModal {
37 pub fn new() -> Self {
38 Self {
39 filter: StructuredFilter::default(),
40 focus: FilterField::Assignee,
41 }
42 }
43
44 pub fn with_filter(filter: StructuredFilter) -> Self {
47 Self {
48 filter,
49 focus: FilterField::Assignee,
50 }
51 }
52
53 pub fn active_filter(&self) -> &StructuredFilter {
55 &self.filter
56 }
57
58 pub fn active_filter_mut(&mut self) -> &mut StructuredFilter {
59 &mut self.filter
60 }
61
62 pub fn apply(&mut self) {}
65
66 pub fn clear(&mut self) {
70 self.filter = StructuredFilter::default();
71 self.focus = FilterField::Assignee;
72 }
73
74 pub fn focus_next(&mut self) {
76 self.focus = self.focus.next();
77 }
78
79 pub fn focus_prev(&mut self) {
81 self.focus = self.focus.prev();
82 }
83
84 pub fn focus(&self) -> FilterField {
86 self.focus
87 }
88
89 pub fn render(&self, f: &mut Frame, area: Rect) {
93 let rect = modal_rect(area);
94 let mut rows: Vec<Line> = Vec::new();
95
96 for field in FilterField::ALL {
97 let (label, value) = (field.label(), field.value(&self.filter));
98 let is_focus = field == self.focus;
99 let value_text = value.unwrap_or("(unset)");
100 let style = if is_focus {
101 Style::default()
102 .fg(Color::Yellow)
103 .add_modifier(Modifier::BOLD)
104 } else {
105 Style::default().fg(Color::White)
106 };
107 let label_style = if is_focus {
108 Style::default()
109 .fg(Color::Cyan)
110 .add_modifier(Modifier::BOLD)
111 } else {
112 Style::default().fg(Color::DarkGray)
113 };
114 let prefix = if is_focus { "> " } else { " " };
115 rows.push(Line::from(vec![
116 ratatui::text::Span::styled(format!("{prefix}{label}:"), label_style),
117 ratatui::text::Span::styled(format!(" {value_text}"), style),
118 ]));
119 }
120
121 rows.push(Line::from(""));
122 rows.push(Line::from(Span::styled(
123 "Tab/\u{2191}\u{2193}: navigate \u{b7} r: reset \u{b7} Enter: apply \u{b7} Esc: cancel",
124 Style::default().fg(Color::DarkGray),
125 )));
126
127 let block = Block::default()
128 .borders(Borders::ALL)
129 .title(" Filter ")
130 .style(Style::default().fg(Color::Cyan));
131 let para = Paragraph::new(rows).block(block).style(Style::default());
132 f.render_widget(Clear, rect);
133 f.render_widget(para, rect);
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use ratatui::Terminal;
141 use ratatui::backend::TestBackend;
142
143 fn smoke(w: u16, h: u16, title_focus: bool) {
144 let backend = TestBackend::new(w, h);
145 let mut terminal = Terminal::new(backend).unwrap();
146 terminal
147 .draw(|f| {
148 let area = f.area();
149 let mut modal = FilterModal::new();
150 if title_focus {
151 modal.focus_next();
152 }
153 modal.render(f, area);
154 })
155 .unwrap();
156 }
157
158 #[test]
159 fn renders_at_120x40() {
160 smoke(120, 40, false);
161 }
162
163 #[test]
164 fn renders_at_80x24() {
165 smoke(80, 24, false);
166 }
167
168 #[test]
169 fn renders_at_tiny_20x5() {
170 smoke(20, 5, true);
171 }
172
173 fn buffer_text(w: u16, h: u16) -> String {
174 let backend = TestBackend::new(w, h);
175 let mut terminal = Terminal::new(backend).unwrap();
176 terminal
177 .draw(|f| {
178 let modal = FilterModal::new();
179 modal.render(f, f.area());
180 })
181 .unwrap();
182 let buffer = terminal.backend().buffer();
183 let mut text = String::new();
184 for y in 0..h {
185 for x in 0..w {
186 for ch in buffer[(x, y)].symbol().chars() {
187 text.push(ch);
188 }
189 }
190 text.push('\n');
191 }
192 text
193 }
194
195 #[test]
196 fn footer_hint_present_at_normal_size() {
197 let text = buffer_text(80, 24);
198 assert!(
199 text.contains("Tab/\u{2191}\u{2193}: navigate"),
200 "footer must advertise arrow navigation, got:\n{text}"
201 );
202 assert!(text.contains("r: reset") && text.contains("Enter: apply"));
203 }
204
205 #[test]
206 fn footer_render_does_not_panic_on_tiny_terminal() {
207 let text = buffer_text(20, 5);
208 assert!(!text.is_empty());
209 }
210
211 #[test]
212 fn focus_cycles_forward() {
213 let mut modal = FilterModal::new();
214 assert_eq!(modal.focus(), FilterField::Assignee);
215 modal.focus_next();
216 assert_eq!(modal.focus(), FilterField::Label);
217 modal.focus_next();
218 modal.focus_next();
219 assert_eq!(modal.focus(), FilterField::Priority);
220 modal.focus_next();
221 assert_eq!(modal.focus(), FilterField::Assignee);
222 }
223
224 #[test]
225 fn focus_cycles_backward() {
226 let mut modal = FilterModal::new();
227 modal.focus_prev();
228 assert_eq!(modal.focus(), FilterField::Priority);
229 modal.focus_prev();
230 assert_eq!(modal.focus(), FilterField::Status);
231 }
232
233 #[test]
234 fn clear_resets_all_criteria_and_focus() {
235 let mut modal = FilterModal::with_filter(StructuredFilter {
236 assignee: Some("alice".to_string()),
237 label: Some("urgent".to_string()),
238 status: Some("todo".to_string()),
239 priority: Some("high".to_string()),
240 });
241 modal.clear();
242 assert_eq!(modal.active_filter(), &StructuredFilter::default());
243 assert_eq!(modal.focus(), FilterField::Assignee);
244 }
245}