1use ratatui::Frame;
14use ratatui::layout::Rect;
15use ratatui::style::{Color, Modifier, Style};
16use ratatui::text::{Line, Span};
17use ratatui::widgets::{Block, Borders, Clear, Paragraph};
18
19use crate::search::{FilterField, StructuredFilter};
20use crate::ui::pickers::DropdownState;
21use crate::views::divergence::ActiveViewMarker;
22use crate::views::plan::ViewPlan;
23use crate::views::registry::ViewId;
24
25use super::modal::modal_rect;
26use super::views::ViewFeedback;
27
28pub fn picker_layout(area: Rect, state: &DropdownState) -> Option<(Rect, Vec<Rect>)> {
35 let visible = state.visible_indices();
36 if visible.is_empty() {
37 return None;
38 }
39 let inner = Block::default().borders(Borders::ALL).inner(area);
40 let widest = visible
41 .iter()
42 .map(|index| state.options[*index].label.chars().count() as u16 + 2)
43 .max()
44 .unwrap_or(0);
45 let width = (widest + 4).clamp(6, inner.width.max(6));
46 let height = (visible.len() as u16 + 2).min(inner.height.max(3));
47 if height < 3 || inner.width == 0 {
48 return None;
49 }
50 let rect = Rect::new(inner.x, inner.y, width, height);
51 let rows = (0..visible.len() as u16)
52 .take(height as usize - 2)
53 .map(|row| Rect::new(rect.x, rect.y + 1 + row, rect.width, 1))
54 .collect();
55 Some((rect, rows))
56}
57
58pub struct FilterModal {
67 filter: StructuredFilter,
69 focus: FilterField,
71 active: Option<ActiveViewMarker>,
73 feedback: Option<ViewFeedback>,
76}
77
78impl Default for FilterModal {
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84impl FilterModal {
85 pub fn new() -> Self {
86 Self {
87 filter: StructuredFilter::default(),
88 focus: FilterField::Assignee,
89 active: None,
90 feedback: None,
91 }
92 }
93
94 pub fn with_filter(filter: StructuredFilter) -> Self {
97 Self {
98 filter,
99 focus: FilterField::Assignee,
100 active: None,
101 feedback: None,
102 }
103 }
104
105 pub fn set_active(&mut self, active: Option<ActiveViewMarker>, feedback: Option<ViewFeedback>) {
112 self.active = active;
113 self.feedback = feedback;
114 }
115
116 pub fn active(&self) -> Option<&ActiveViewMarker> {
118 self.active.as_ref()
119 }
120
121 pub fn view_id(&self) -> Option<&ViewId> {
123 self.active.as_ref().map(ActiveViewMarker::view_id)
124 }
125
126 pub fn applied_plan(&self) -> Option<&ViewPlan> {
128 self.active.as_ref().map(ActiveViewMarker::applied_plan)
129 }
130
131 pub fn set_feedback(&mut self, feedback: Option<ViewFeedback>) {
134 self.feedback = feedback;
135 }
136
137 pub fn feedback(&self) -> Option<&ViewFeedback> {
139 self.feedback.as_ref()
140 }
141
142 pub fn indicator_line(&self) -> Option<String> {
144 self.feedback
145 .as_ref()
146 .map(|feedback| feedback.indicator.clone())
147 }
148
149 pub fn header_height(&self) -> u16 {
156 match &self.feedback {
157 Some(feedback) => u16::try_from(feedback.lines.len())
158 .unwrap_or(u16::MAX)
159 .saturating_add(2),
160 None => 0,
161 }
162 }
163
164 pub fn active_filter(&self) -> &StructuredFilter {
167 &self.filter
168 }
169
170 pub fn active_filter_mut(&mut self) -> &mut StructuredFilter {
171 &mut self.filter
172 }
173
174 pub fn clear(&mut self) {
183 self.filter = StructuredFilter::default();
184 self.focus = FilterField::Assignee;
185 }
186
187 pub fn focus_next(&mut self) {
189 self.focus = self.focus.next();
190 }
191
192 pub fn focus_prev(&mut self) {
194 self.focus = self.focus.prev();
195 }
196
197 pub fn focus_field(&mut self, field: FilterField) {
200 if FilterField::ALL.contains(&field) {
201 self.focus = field;
202 }
203 }
204
205 pub fn focus(&self) -> FilterField {
207 self.focus
208 }
209
210 pub fn is_date_focus(&self) -> bool {
212 self.focus.is_date()
213 }
214
215 pub fn focused_value(&self) -> String {
217 self.focus.value(&self.filter).unwrap_or("").to_string()
218 }
219
220 pub fn set_focused_date(&mut self, value: &str) {
223 if !self.focus.is_date() {
224 return;
225 }
226 if value.is_empty() {
227 self.focus.unset(&mut self.filter);
228 } else {
229 *self.focus.value_mut(&mut self.filter) = value.to_string();
230 }
231 }
232
233 pub fn render(&self, f: &mut Frame, area: Rect) {
237 let bindings =
238 crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
239 .expect("built-in bindings are valid");
240 self.render_with_bindings(
241 f,
242 area,
243 &bindings,
244 crate::keybindings::InputContext::FilterModal,
245 );
246 }
247
248 pub fn render_with_bindings(
249 &self,
250 f: &mut Frame,
251 area: Rect,
252 bindings: &crate::keybindings::EffectiveBindings,
253 context: crate::keybindings::InputContext,
254 ) {
255 let rect = modal_rect(area);
256 let mut rows: Vec<Line> = Vec::new();
257
258 if let Some(feedback) = &self.feedback {
263 rows.push(Line::from(Span::styled(
264 feedback.indicator.clone(),
265 Style::default()
266 .fg(Color::Green)
267 .add_modifier(Modifier::BOLD),
268 )));
269 for line in &feedback.lines {
270 rows.push(Line::from(Span::styled(
271 line.clone(),
272 Style::default().fg(Color::DarkGray),
273 )));
274 }
275 rows.push(Line::from(""));
276 }
277
278 for field in FilterField::ALL {
279 let (label, value) = (field.label(), field.value(&self.filter));
280 let is_focus = field == self.focus;
281 let value_text = value.unwrap_or("(unset)");
282 let style = if is_focus {
283 Style::default()
284 .fg(Color::Yellow)
285 .add_modifier(Modifier::BOLD)
286 } else {
287 Style::default().fg(Color::White)
288 };
289 let label_style = if is_focus {
290 Style::default()
291 .fg(Color::Cyan)
292 .add_modifier(Modifier::BOLD)
293 } else {
294 Style::default().fg(Color::DarkGray)
295 };
296 let prefix = if is_focus { "> " } else { " " };
297 rows.push(Line::from(vec![
298 ratatui::text::Span::styled(format!("{prefix}{label}:"), label_style),
299 ratatui::text::Span::styled(format!(" {value_text}"), style),
300 ]));
301 }
302
303 let inner_h = rect.height.saturating_sub(2) as usize;
308 if rows.len() + 3 < inner_h {
309 rows.push(Line::from(""));
310 }
311 rows.push(Line::from(Span::styled(
312 format!(
313 "{}/{}: navigate",
314 bindings.label(context, crate::keybindings::TuiAction::Next),
315 bindings.label(context, crate::keybindings::TuiAction::Previous),
316 ),
317 Style::default().fg(Color::DarkGray),
318 )));
319 rows.push(Line::from(Span::styled(
323 format!(
324 "{}: open \u{b7} {}: reset",
325 bindings.label(context, crate::keybindings::TuiAction::OpenPicker),
326 bindings.label(context, crate::keybindings::TuiAction::Reset),
327 ),
328 Style::default().fg(Color::DarkGray),
329 )));
330 rows.push(Line::from(Span::styled(
333 format!(
334 "{} / {}: apply & close",
335 bindings.label(context, crate::keybindings::TuiAction::Submit),
336 bindings.label(context, crate::keybindings::TuiAction::Cancel),
337 ),
338 Style::default().fg(Color::DarkGray),
339 )));
340
341 let block = Block::default()
342 .borders(Borders::ALL)
343 .title(" Filter ")
344 .style(Style::default().fg(Color::Cyan));
345 let para = Paragraph::new(rows).block(block).style(Style::default());
346 f.render_widget(Clear, rect);
347 f.render_widget(para, rect);
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 #[allow(clippy::wildcard_imports)]
354 use super::*;
355 use ratatui::Terminal;
356 use ratatui::backend::TestBackend;
357
358 fn smoke(w: u16, h: u16, title_focus: bool) {
359 let backend = TestBackend::new(w, h);
360 let mut terminal = Terminal::new(backend).unwrap();
361 terminal
362 .draw(|f| {
363 let area = f.area();
364 let mut modal = FilterModal::new();
365 if title_focus {
366 modal.focus_next();
367 }
368 modal.render(f, area);
369 })
370 .unwrap();
371 }
372
373 #[test]
374 fn renders_at_120x40() {
375 smoke(120, 40, false);
376 }
377
378 #[test]
379 fn renders_at_80x24() {
380 smoke(80, 24, false);
381 }
382
383 #[test]
384 fn renders_at_tiny_20x5() {
385 smoke(20, 5, true);
386 }
387
388 fn buffer_text(w: u16, h: u16) -> String {
389 let backend = TestBackend::new(w, h);
390 let mut terminal = Terminal::new(backend).unwrap();
391 terminal
392 .draw(|f| {
393 let modal = FilterModal::new();
394 modal.render(f, f.area());
395 })
396 .unwrap();
397 let buffer = terminal.backend().buffer();
398 let mut text = String::new();
399 for y in 0..h {
400 for x in 0..w {
401 for ch in buffer[(x, y)].symbol().chars() {
402 text.push(ch);
403 }
404 }
405 text.push('\n');
406 }
407 text
408 }
409
410 #[test]
411 fn footer_hint_present_at_normal_size() {
412 let text = buffer_text(80, 24);
413 assert!(
414 text.contains("Tab / Down/Shift+Tab / Up: navigate"),
415 "footer must advertise field navigation, got:\n{text}"
416 );
417 assert!(
418 text.contains("F2 / Enter / l / Right: open"),
419 "footer must advertise the row picker on enter/l/right, got:\n{text}"
420 );
421 assert!(
422 text.contains("r / Shift+R: reset"),
423 "footer must advertise the reset key, got:\n{text}"
424 );
425 assert!(
426 text.contains("a / Esc: apply & close"),
427 "footer must advertise that closing applies the filter, got:\n{text}"
428 );
429 }
430
431 #[test]
432 fn footer_render_does_not_panic_on_tiny_terminal() {
433 let text = buffer_text(20, 5);
434 assert_ne!(text, "");
435 }
436
437 #[test]
438 fn focus_cycles_forward() {
439 let mut modal = FilterModal::new();
440 assert_eq!(modal.focus(), FilterField::Assignee);
441 modal.focus_next();
442 assert_eq!(modal.focus(), FilterField::Label);
443 modal.focus_next();
444 modal.focus_next();
445 assert_eq!(modal.focus(), FilterField::Priority);
446 modal.focus_next();
447 assert_eq!(modal.focus(), FilterField::Cycle);
448 }
449
450 #[test]
451 fn focus_cycles_backward() {
452 let mut modal = FilterModal::new();
453 modal.focus_prev();
461 assert_eq!(modal.focus(), FilterField::Epic);
462 modal.focus_prev();
463 assert_eq!(modal.focus(), FilterField::Project);
464 modal.focus_prev();
465 assert_eq!(modal.focus(), FilterField::Category);
466 modal.focus_prev();
467 assert_eq!(modal.focus(), FilterField::Parent);
468 modal.focus_prev();
469 assert_eq!(modal.focus(), FilterField::DueDate);
470 modal.focus_prev();
471 assert_eq!(modal.focus(), FilterField::StartDate);
472 }
473
474 #[test]
475 fn clear_resets_all_criteria_and_focus() {
476 let mut modal = FilterModal::with_filter(StructuredFilter {
477 assignee: Some("alice".to_string()),
478 label: Some("urgent".to_string()),
479 status: Some("todo".to_string()),
480 priority: Some("high".to_string()),
481 cycle: None,
482 milestone: None,
483 reviewer: None,
484 start_date: None,
485 due_date: None,
486 overdue: None,
487 parent: None,
488 category: None,
489 scope: None,
490 archived: None,
491 project: None,
492 epic: None,
493 });
494 modal.clear();
495 assert_eq!(modal.active_filter(), &StructuredFilter::default());
496 assert_eq!(modal.focus(), FilterField::Assignee);
497 }
498}