Skip to main content

tix/ui/
filter_row.rs

1//! Filter indicator row beneath the tab bar (issue #55, board-filtering).
2//!
3//! A single passive row rendering every active filter: the `/` text query
4//! (shown as `#<number>` when it is a bare number) and the active
5//! [`StructuredFilter`] criteria via its `Display` impl. It is wired up by
6//! `App`'s `render_board`/`render_board_input`; this widget only formats and
7//! draws the line (D-04). An all-inactive filter renders nothing — the caller
8//! decides whether to reserve a row.
9
10use ratatui::Frame;
11use ratatui::layout::Rect;
12use ratatui::style::{Color, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::Paragraph;
15
16use crate::search::{StructuredFilter, number_query};
17
18/// The active-filter indicator row rendered under the tab bar.
19#[derive(Debug, Clone, Default)]
20pub struct FilterRow {
21    /// The active `/` text query (None when no text query is applied).
22    pub query: Option<String>,
23    /// The committed structured criteria (possibly all inactive).
24    pub structured: StructuredFilter,
25}
26
27impl FilterRow {
28    /// One-line description of the active filters: query first (`#<n>` for a
29    /// bare number), then the structured criteria — or `None` when nothing is
30    /// active. Display and matching both derive from [`number_query`] so they
31    /// cannot drift (D-02).
32    pub fn describe(&self) -> Option<String> {
33        let mut segments = Vec::new();
34        if let Some(q) = self.query.as_deref().filter(|q| !q.is_empty()) {
35            segments.push(match number_query(q) {
36                Some(n) => format!("#{n}"),
37                None => format!("`{q}`"),
38            });
39        }
40        let criteria = self.structured.to_string();
41        if criteria != "(no active criteria)" {
42            segments.push(format!("({criteria})"));
43        }
44        if segments.is_empty() {
45            None
46        } else {
47            Some(format!("Filters: {}", segments.join(" ")))
48        }
49    }
50
51    pub fn render(&self, f: &mut Frame, area: Rect) {
52        if area.height == 0 || area.width == 0 {
53            return;
54        }
55        let text = self
56            .describe()
57            .unwrap_or_else(|| "Filters: (none)".to_string());
58        let para = Paragraph::new(Line::from(Span::styled(
59            text,
60            Style::default().fg(Color::Cyan),
61        )));
62        f.render_widget(para, area);
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use ratatui::Terminal;
70    use ratatui::backend::TestBackend;
71
72    fn render_row(row: &FilterRow, w: u16, h: u16) -> String {
73        let backend = TestBackend::new(w, h);
74        let mut terminal = Terminal::new(backend).unwrap();
75        terminal.draw(|f| row.render(f, f.area())).unwrap();
76        let buffer = terminal.backend().buffer();
77        buffer.content.iter().map(|c| c.symbol()).collect()
78    }
79
80    #[test]
81    fn describe_query_only() {
82        let row = FilterRow {
83            query: Some("target".into()),
84            structured: StructuredFilter::default(),
85        };
86        assert_eq!(row.describe().as_deref(), Some("Filters: `target`"));
87    }
88
89    #[test]
90    fn describe_numeric_query_renders_issue_reference() {
91        let row = FilterRow {
92            query: Some("55".into()),
93            structured: StructuredFilter::default(),
94        };
95        assert_eq!(row.describe().as_deref(), Some("Filters: #55"));
96    }
97
98    #[test]
99    fn describe_criteria_only_shows_criteria() {
100        let row = FilterRow {
101            query: None,
102            structured: StructuredFilter {
103                status: Some("todo".to_string()),
104                ..Default::default()
105            },
106        };
107        assert_eq!(row.describe().as_deref(), Some("Filters: (status:todo)"));
108    }
109
110    #[test]
111    fn describe_new_fields_in_display_order() {
112        let row = FilterRow {
113            query: None,
114            structured: StructuredFilter {
115                cycle: Some("Cycle 1".to_string()),
116                milestone: Some("Backlog".to_string()),
117                reviewer: Some("alice".to_string()),
118                start_date: Some("2026-09-01".to_string()),
119                due_date: Some("2026-09-10".to_string()),
120                overdue: Some(true),
121                ..Default::default()
122            },
123        };
124        assert_eq!(
125            row.describe().as_deref(),
126            Some(
127                "Filters: (cycle:Cycle 1, milestone:Backlog, reviewer:alice, start_date:2026-09-01, due_date:2026-09-10, overdue:true)"
128            )
129        );
130    }
131
132    #[test]
133    fn describe_query_and_criteria_both() {
134        let row = FilterRow {
135            query: Some("55".into()),
136            structured: StructuredFilter {
137                status: Some("todo".to_string()),
138                ..Default::default()
139            },
140        };
141        assert_eq!(
142            row.describe().as_deref(),
143            Some("Filters: #55 (status:todo)")
144        );
145    }
146
147    #[test]
148    fn describe_empty_layers_is_none() {
149        let row = FilterRow {
150            query: None,
151            structured: StructuredFilter::default(),
152        };
153        assert_eq!(row.describe(), None);
154    }
155
156    #[test]
157    fn render_shows_the_described_filters() {
158        let row = FilterRow {
159            query: Some("target".into()),
160            structured: StructuredFilter::default(),
161        };
162        let content = render_row(&row, 80, 1);
163        assert!(content.contains("`target`"), "got {content:?}");
164    }
165
166    #[test]
167    fn render_smoke_tiny_and_tall() {
168        // Tiny frame must not panic.
169        let _ = render_row(
170            &FilterRow {
171                query: Some("55".into()),
172                structured: StructuredFilter::default(),
173            },
174            4,
175            1,
176        );
177        let row = FilterRow::default();
178        let content = render_row(&row, 80, 1);
179        assert!(content.starts_with("Filters:"), "got {content:?}");
180    }
181}