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    /// Jump focus directly to a specific criterion row (a row click, D6) —
85    /// the same focus state the Tab/arrow cycling produces, chosen directly.
86    pub fn focus_field(&mut self, field: FilterField) {
87        if FilterField::ALL.contains(&field) {
88            self.focus = field;
89        }
90    }
91
92    /// The currently-focused criterion row.
93    pub fn focus(&self) -> FilterField {
94        self.focus
95    }
96
97    /// Whether the focused criterion is a picker-only date field.
98    pub fn is_date_focus(&self) -> bool {
99        self.focus.is_date()
100    }
101
102    /// The focused criterion's value, or `""` when unset.
103    pub fn focused_value(&self) -> String {
104        self.focus.value(&self.filter).unwrap_or("").to_string()
105    }
106
107    /// Overwrite the focused date criterion (no-op when it isn't a date field).
108    /// An empty value clears the criterion.
109    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    /// Whether the focused date criterion already holds a complete valid date.
121    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    /// Type one character into the focused criterion. Date criteria apply the
126    /// masked `YYYY-MM-DD` edit; others append directly.
127    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    /// Delete the last character of the focused criterion (masked for dates).
138    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    /// Render the filter modal: a bordered, centered box (via
152    /// [`modal_rect`]) listing each criterion row (label + editable value)
153    /// with the focused row highlighted; unset criteria show a placeholder.
154    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        // The backward wrap lands on the **last** row, whatever that is — and the
325        // last row has changed twice. `Parent` was appended as the tenth (T024
326        // step 2) and `Category` as the eleventh (WP08 T034 step 2), both appended
327        // rather than interleaved. This is the one place the appending decision is
328        // visible, and it is visible on purpose: a user pressing Up from the first
329        // row should reach the last row, not a row chosen by history.
330        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}