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, Span};
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        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}