Skip to main content

tix/ui/
filter_modal.rs

1//! Filter modal widget (FR-014, T012 / WP02).
2//!
3//! The Tab-navigated filter editor: a `FilterModal` wraps a
4//! [`StructuredFilter`](crate::search::StructuredFilter) in progress plus a
5//! cursor over the field rows (mirrors `FormFocus` cycle in `src/ui/form.rs`
6//! at 8–34). The widget is a **pure view + model**: it does not read keys for
7//! confirm/cancel — WP03 routes keys and calls [`FilterModal::apply`] /
8//! [`FilterModal::clear`]. "Clear all" (FR-014) empties every criterion but
9//! never fights the `/` text filter — the `/` query layer is reset by its own
10//! path (WP03's apply_filter).
11
12use ratatui::Frame;
13use ratatui::layout::Rect;
14use ratatui::style::{Color, Modifier, Style};
15use ratatui::text::Line;
16use ratatui::widgets::{Block, Borders, Clear, Paragraph};
17
18use crate::search::{FilterField, StructuredFilter};
19
20use super::modal::modal_rect;
21
22/// The Tab-navigated structured-filter editor (T012).
23pub struct FilterModal {
24    /// The StructuredFilter being edited.
25    filter: StructuredFilter,
26    /// Field row the cursor is on (Assignee → Label → Status → Priority).
27    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    /// Seed the modal with an existing filter (used when re-opening the modal
45    /// for an already-applied filter).
46    pub fn with_filter(filter: StructuredFilter) -> Self {
47        Self {
48            filter,
49            focus: FilterField::Assignee,
50        }
51    }
52
53    /// In-progress filter (public API for WP03 to read/commit).
54    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    /// Commit in-progress criteria into the active filter (WP03 calls this on
63    /// Enter). Pure model transition — no key reading here.
64    pub fn apply(&mut self) {}
65
66    /// Clear/reset **all** criteria (FR-014). Never touches the `/` text
67    /// filter layer (reset is a pure structured-filter op, mirrored by WP03's
68    /// apply_filter for the query layer).
69    pub fn clear(&mut self) {
70        self.filter = StructuredFilter::default();
71        self.focus = FilterField::Assignee;
72    }
73
74    /// Move focus to the next criterion row (Tab).
75    pub fn focus_next(&mut self) {
76        self.focus = self.focus.next();
77    }
78
79    /// Move focus to the previous criterion row (Shift+Tab).
80    pub fn focus_prev(&mut self) {
81        self.focus = self.focus.prev();
82    }
83
84    /// The currently-focused criterion row.
85    pub fn focus(&self) -> FilterField {
86        self.focus
87    }
88
89    /// Render the filter modal: a bordered, centered box (via
90    /// [`modal_rect`]) listing each criterion row (label + editable value)
91    /// with the focused row highlighted; unset criteria show a placeholder.
92    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        let block = Block::default()
122            .borders(Borders::ALL)
123            .title(" Filter ")
124            .style(Style::default().fg(Color::Cyan));
125        let para = Paragraph::new(rows).block(block).style(Style::default());
126        f.render_widget(Clear, rect);
127        f.render_widget(para, rect);
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use ratatui::Terminal;
135    use ratatui::backend::TestBackend;
136
137    fn smoke(w: u16, h: u16, title_focus: bool) {
138        let backend = TestBackend::new(w, h);
139        let mut terminal = Terminal::new(backend).unwrap();
140        terminal
141            .draw(|f| {
142                let area = f.area();
143                let mut modal = FilterModal::new();
144                if title_focus {
145                    modal.focus_next();
146                }
147                modal.render(f, area);
148            })
149            .unwrap();
150    }
151
152    #[test]
153    fn renders_at_120x40() {
154        smoke(120, 40, false);
155    }
156
157    #[test]
158    fn renders_at_80x24() {
159        smoke(80, 24, false);
160    }
161
162    #[test]
163    fn renders_at_tiny_20x5() {
164        smoke(20, 5, true);
165    }
166
167    #[test]
168    fn focus_cycles_forward() {
169        let mut modal = FilterModal::new();
170        assert_eq!(modal.focus(), FilterField::Assignee);
171        modal.focus_next();
172        assert_eq!(modal.focus(), FilterField::Label);
173        modal.focus_next();
174        modal.focus_next();
175        assert_eq!(modal.focus(), FilterField::Priority);
176        modal.focus_next();
177        assert_eq!(modal.focus(), FilterField::Assignee);
178    }
179
180    #[test]
181    fn focus_cycles_backward() {
182        let mut modal = FilterModal::new();
183        modal.focus_prev();
184        assert_eq!(modal.focus(), FilterField::Priority);
185        modal.focus_prev();
186        assert_eq!(modal.focus(), FilterField::Status);
187    }
188
189    #[test]
190    fn clear_resets_all_criteria_and_focus() {
191        let mut modal = FilterModal::with_filter(StructuredFilter {
192            assignee: Some("alice".to_string()),
193            label: Some("urgent".to_string()),
194            status: Some("todo".to_string()),
195            priority: Some("high".to_string()),
196        });
197        modal.clear();
198        assert_eq!(modal.active_filter(), &StructuredFilter::default());
199        assert_eq!(modal.focus(), FilterField::Assignee);
200    }
201}