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_field(&mut self, field: FilterField) {
87 if FilterField::ALL.contains(&field) {
88 self.focus = field;
89 }
90 }
91
92 pub fn focus(&self) -> FilterField {
94 self.focus
95 }
96
97 pub fn is_date_focus(&self) -> bool {
99 self.focus.is_date()
100 }
101
102 pub fn focused_value(&self) -> String {
104 self.focus.value(&self.filter).unwrap_or("").to_string()
105 }
106
107 pub fn set_focused_date(&mut self, value: &str) {
110 if !self.focus.is_date() {
111 return;
112 }
113 if value.is_empty() {
114 self.focus.unset(&mut self.filter);
115 } else {
116 *self.focus.value_mut(&mut self.filter) = value.to_string();
117 }
118 }
119
120 pub fn focused_date_is_complete(&self) -> bool {
122 self.focus.is_date() && crate::ui::date_picker::is_complete_date(&self.focused_value())
123 }
124
125 pub fn type_char(&mut self, c: char) {
128 if self.focus.is_date() {
129 if let Some(next) = crate::ui::date_picker::type_date(&self.focused_value(), c) {
130 *self.focus.value_mut(&mut self.filter) = next;
131 }
132 } else {
133 self.focus.value_mut(&mut self.filter).push(c);
134 }
135 }
136
137 pub fn backspace(&mut self) {
139 if self.focus.is_date() {
140 let next = crate::ui::date_picker::backspace_date(&self.focused_value());
141 if next.is_empty() {
142 self.focus.unset(&mut self.filter);
143 } else {
144 *self.focus.value_mut(&mut self.filter) = next;
145 }
146 } else {
147 self.focus.value_mut(&mut self.filter).pop();
148 }
149 }
150
151 pub fn render(&self, f: &mut Frame, area: Rect) {
155 let bindings =
156 crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
157 .expect("built-in bindings are valid");
158 self.render_with_bindings(
159 f,
160 area,
161 &bindings,
162 crate::keybindings::InputContext::FilterModal,
163 );
164 }
165
166 pub fn render_with_bindings(
167 &self,
168 f: &mut Frame,
169 area: Rect,
170 bindings: &crate::keybindings::EffectiveBindings,
171 context: crate::keybindings::InputContext,
172 ) {
173 let rect = modal_rect(area);
174 let mut rows: Vec<Line> = Vec::new();
175
176 for field in FilterField::ALL {
177 let (label, value) = (field.label(), field.value(&self.filter));
178 let is_focus = field == self.focus;
179 let value_text = value.unwrap_or("(unset)");
180 let style = if is_focus {
181 Style::default()
182 .fg(Color::Yellow)
183 .add_modifier(Modifier::BOLD)
184 } else {
185 Style::default().fg(Color::White)
186 };
187 let label_style = if is_focus {
188 Style::default()
189 .fg(Color::Cyan)
190 .add_modifier(Modifier::BOLD)
191 } else {
192 Style::default().fg(Color::DarkGray)
193 };
194 let prefix = if is_focus { "> " } else { " " };
195 rows.push(Line::from(vec![
196 ratatui::text::Span::styled(format!("{prefix}{label}:"), label_style),
197 ratatui::text::Span::styled(format!(" {value_text}"), style),
198 ]));
199 }
200
201 rows.push(Line::from(""));
202 rows.push(Line::from(Span::styled(
203 format!(
204 "{}/{}: navigate",
205 bindings.label(context, crate::keybindings::TuiAction::Next),
206 bindings.label(context, crate::keybindings::TuiAction::Previous),
207 ),
208 Style::default().fg(Color::DarkGray),
209 )));
210 rows.push(Line::from(Span::styled(
211 format!(
212 "{}: reset \u{b7} {}: date \u{b7} {}: apply \u{b7} {}: cancel",
213 bindings.label(context, crate::keybindings::TuiAction::Reset),
214 bindings.label(context, crate::keybindings::TuiAction::OpenPicker),
215 bindings.label(context, crate::keybindings::TuiAction::Submit),
216 bindings.label(context, crate::keybindings::TuiAction::Cancel),
217 ),
218 Style::default().fg(Color::DarkGray),
219 )));
220
221 let block = Block::default()
222 .borders(Borders::ALL)
223 .title(" Filter ")
224 .style(Style::default().fg(Color::Cyan));
225 let para = Paragraph::new(rows).block(block).style(Style::default());
226 f.render_widget(Clear, rect);
227 f.render_widget(para, rect);
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use ratatui::Terminal;
235 use ratatui::backend::TestBackend;
236
237 fn smoke(w: u16, h: u16, title_focus: bool) {
238 let backend = TestBackend::new(w, h);
239 let mut terminal = Terminal::new(backend).unwrap();
240 terminal
241 .draw(|f| {
242 let area = f.area();
243 let mut modal = FilterModal::new();
244 if title_focus {
245 modal.focus_next();
246 }
247 modal.render(f, area);
248 })
249 .unwrap();
250 }
251
252 #[test]
253 fn renders_at_120x40() {
254 smoke(120, 40, false);
255 }
256
257 #[test]
258 fn renders_at_80x24() {
259 smoke(80, 24, false);
260 }
261
262 #[test]
263 fn renders_at_tiny_20x5() {
264 smoke(20, 5, true);
265 }
266
267 fn buffer_text(w: u16, h: u16) -> String {
268 let backend = TestBackend::new(w, h);
269 let mut terminal = Terminal::new(backend).unwrap();
270 terminal
271 .draw(|f| {
272 let modal = FilterModal::new();
273 modal.render(f, f.area());
274 })
275 .unwrap();
276 let buffer = terminal.backend().buffer();
277 let mut text = String::new();
278 for y in 0..h {
279 for x in 0..w {
280 for ch in buffer[(x, y)].symbol().chars() {
281 text.push(ch);
282 }
283 }
284 text.push('\n');
285 }
286 text
287 }
288
289 #[test]
290 fn footer_hint_present_at_normal_size() {
291 let text = buffer_text(80, 24);
292 assert!(
293 text.contains("Tab / Down/Shift+Tab / Up: navigate"),
294 "footer must advertise field navigation, got:\n{text}"
295 );
296 assert!(
297 text.contains("r / Shift+R: reset") && text.contains("Enter: apply"),
298 "got:\n{text}"
299 );
300 }
301
302 #[test]
303 fn footer_render_does_not_panic_on_tiny_terminal() {
304 let text = buffer_text(20, 5);
305 assert!(!text.is_empty());
306 }
307
308 #[test]
309 fn focus_cycles_forward() {
310 let mut modal = FilterModal::new();
311 assert_eq!(modal.focus(), FilterField::Assignee);
312 modal.focus_next();
313 assert_eq!(modal.focus(), FilterField::Label);
314 modal.focus_next();
315 modal.focus_next();
316 assert_eq!(modal.focus(), FilterField::Priority);
317 modal.focus_next();
318 assert_eq!(modal.focus(), FilterField::Cycle);
319 }
320
321 #[test]
322 fn focus_cycles_backward() {
323 let mut modal = FilterModal::new();
324 modal.focus_prev();
331 assert_eq!(modal.focus(), FilterField::Category);
332 modal.focus_prev();
333 assert_eq!(modal.focus(), FilterField::Parent);
334 modal.focus_prev();
335 assert_eq!(modal.focus(), FilterField::DueDate);
336 modal.focus_prev();
337 assert_eq!(modal.focus(), FilterField::StartDate);
338 }
339
340 #[test]
341 fn clear_resets_all_criteria_and_focus() {
342 let mut modal = FilterModal::with_filter(StructuredFilter {
343 assignee: Some("alice".to_string()),
344 label: Some("urgent".to_string()),
345 status: Some("todo".to_string()),
346 priority: Some("high".to_string()),
347 cycle: None,
348 milestone: None,
349 reviewer: None,
350 start_date: None,
351 due_date: None,
352 overdue: None,
353 parent: None,
354 category: None,
355 scope: None,
356 archived: None,
357 });
358 modal.clear();
359 assert_eq!(modal.active_filter(), &StructuredFilter::default());
360 assert_eq!(modal.focus(), FilterField::Assignee);
361 }
362}