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 at all.
7//! [`App`](crate::app::App) routes them — `enter`/`l`/`right` open the focused row's
8//! value picker (or the calendar on a date row), `ctrl+enter` commits, `esc` reverts
9//! — and owns the transient picker. Criterion rows hold no typed text; a row's value
10//! always comes from its picker. "Clear all" (FR-014) empties every criterion but
11//! never fights the `/` text filter — the `/` query layer is reset by its own path.
12
13use 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
28/// Where an open criterion picker draws inside `area` (the filter modal's own rect)
29/// and the rect of each visible option row, or `None` when there is nothing to show.
30///
31/// Mirrors [`crate::ui::triage::dropdown_layout`]: one clamp rule, so the list never
32/// overflows the surface that owns it — a box too short or too narrow to hold a
33/// title and one row simply does not draw.
34pub 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
58/// The Tab-navigated structured-filter editor (T012).
59///
60/// WP05 adds one thing to it: the **active saved view** (T024). The modal holds
61/// the marker — the stable id and the normalized plan that was applied — plus the
62/// already-rendered indicator/feedback the app derived (T026). It is deliberately
63/// *not* live filter state: `clear()` empties the criteria and leaves the marker
64/// alone, because "the view the board is associated with" and "the criteria in
65/// force" are two different facts (C-006), and only the second is editable here.
66pub struct FilterModal {
67    /// The StructuredFilter being edited.
68    filter: StructuredFilter,
69    /// Field row the cursor is on (Assignee → Label → Status → Priority).
70    focus: FilterField,
71    /// The saved view the live board is associated with, if any (T024).
72    active: Option<ActiveViewMarker>,
73    /// The rendered indicator and detail lines for `active`, refreshed by the app
74    /// (T026). The widget owns no comparison and no diagnostics text.
75    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    /// Seed the modal with an existing filter (used when re-opening the modal
95    /// for an already-applied filter).
96    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    /// Install the active saved-view marker and its rendered feedback (T024).
106    ///
107    /// Informational: this changes what the modal *shows*, never the criteria it
108    /// edits. The app calls it when the modal opens and after every live change
109    /// while it is open, so the indicator cannot describe a state the board is no
110    /// longer in.
111    pub fn set_active(&mut self, active: Option<ActiveViewMarker>, feedback: Option<ViewFeedback>) {
112        self.active = active;
113        self.feedback = feedback;
114    }
115
116    /// The active saved-view marker, when the board is associated with one.
117    pub fn active(&self) -> Option<&ActiveViewMarker> {
118        self.active.as_ref()
119    }
120
121    /// The stable id of the active view — the identity, never the name (C-002).
122    pub fn view_id(&self) -> Option<&ViewId> {
123        self.active.as_ref().map(ActiveViewMarker::view_id)
124    }
125
126    /// The normalized plan that was applied when the marker was set.
127    pub fn applied_plan(&self) -> Option<&ViewPlan> {
128        self.active.as_ref().map(ActiveViewMarker::applied_plan)
129    }
130
131    /// Replace the rendered feedback (T026), which the app recomputes on every
132    /// live change.
133    pub fn set_feedback(&mut self, feedback: Option<ViewFeedback>) {
134        self.feedback = feedback;
135    }
136
137    /// The indicator and detail lines currently rendered, when there are any.
138    pub fn feedback(&self) -> Option<&ViewFeedback> {
139        self.feedback.as_ref()
140    }
141
142    /// The one-line indicator, when the board is associated with a saved view.
143    pub fn indicator_line(&self) -> Option<String> {
144        self.feedback
145            .as_ref()
146            .map(|feedback| feedback.indicator.clone())
147    }
148
149    /// The rows the active-view feedback occupies above the criteria — one for
150    /// the indicator, one blank, one per detail line (0 without a view).
151    ///
152    /// Public because the app places the criterion rows' click targets from the
153    /// same arithmetic the widget draws with (D6): a hit region that ignored the
154    /// feedback block would jump focus to the wrong criterion.
155    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    /// Read the in-progress criteria for editing (App writes a picker's choice
165    /// into it) and for the commit that closes the modal.
166    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    /// Clear/reset **all** criteria (FR-014). Never touches the `/` text
175    /// filter layer (reset is a pure structured-filter op, mirrored by the
176    /// query layer's own reset).
177    ///
178    /// The active-view marker is untouched: emptying the criteria is a live
179    /// change, and a live change is exactly what the marker's divergence report
180    /// exists to name (C-006). Clearing the marker here would hide the divergence
181    /// the user just created.
182    pub fn clear(&mut self) {
183        self.filter = StructuredFilter::default();
184        self.focus = FilterField::Assignee;
185    }
186
187    /// Move focus to the next criterion row (Tab).
188    pub fn focus_next(&mut self) {
189        self.focus = self.focus.next();
190    }
191
192    /// Move focus to the previous criterion row (Shift+Tab).
193    pub fn focus_prev(&mut self) {
194        self.focus = self.focus.prev();
195    }
196
197    /// Jump focus directly to a specific criterion row (a row click, D6) —
198    /// the same focus state the Tab/arrow cycling produces, chosen directly.
199    pub fn focus_field(&mut self, field: FilterField) {
200        if FilterField::ALL.contains(&field) {
201            self.focus = field;
202        }
203    }
204
205    /// The currently-focused criterion row.
206    pub fn focus(&self) -> FilterField {
207        self.focus
208    }
209
210    /// Whether the focused criterion is a picker-only date field.
211    pub fn is_date_focus(&self) -> bool {
212        self.focus.is_date()
213    }
214
215    /// The focused criterion's value, or `""` when unset.
216    pub fn focused_value(&self) -> String {
217        self.focus.value(&self.filter).unwrap_or("").to_string()
218    }
219
220    /// Overwrite the focused date criterion (no-op when it isn't a date field).
221    /// An empty value clears the criterion.
222    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    /// Render the filter modal: a bordered, centered box (via
234    /// [`modal_rect`]) listing each criterion row (label + value)
235    /// with the focused row highlighted; unset criteria show a placeholder.
236    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        // The active saved view renders **above** the criteria (T024/T026): the
259        // indicator is the same line the board's own row shows, and the lines
260        // under it are the divergence/diagnostics the app computed. Nothing here
261        // is edited by this modal.
262        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        // The separator is cosmetic: keep it only while the three hint lines below
304        // still fit, so a criterion list that grew (WP06's `Epic` row) cannot push
305        // the last hint — the one that says how to apply and close — out of the
306        // modal's fixed 75%-height rect.
307        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        // Two lines rather than one: the modal is 75% of the terminal, so at 80
320        // columns the interior is 58 wide and a single hint line carrying four
321        // bindings would be silently truncated.
322        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        // One wording for both exits: the modal applies ad-hoc, so closing keeps the
331        // criteria and neither key is a discard.
332        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        // The backward wrap lands on the **last** row, whatever that is — and the
454        // last row has changed four times. `Parent` was appended as the tenth (T024
455        // step 2), `Category` as the eleventh (WP08 T034 step 2), `Project` as the
456        // twelfth (WP06 T026) and `Epic` as the thirteenth (WP06 T028), all appended
457        // rather than interleaved. This is the one place the appending decision is
458        // visible, and it is visible on purpose: a user pressing Up from the first
459        // row should reach the last row, not a row chosen by history.
460        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}