Skip to main content

tix/ui/
filter_row.rs

1//! Search indicator row beneath the tab bar (issue #55, board-filtering).
2//!
3//! A single passive row rendering the active `/` search and any structured
4//! [`StructuredFilter`] criteria: the text query (shown as `#<number>` when it
5//! is a bare number) via its own formatting, the criteria via its `Display`
6//! impl. It is wired up by `App`'s `render_board`/`render_board_input`; this
7//! widget only formats and draws the line (D-04). An all-inactive search
8//! renders nothing — the caller 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!("Search: {}", 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(|| "Search: (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    #[allow(clippy::wildcard_imports)]
69    use super::*;
70    use ratatui::Terminal;
71    use ratatui::backend::TestBackend;
72
73    fn render_row(row: &FilterRow, w: u16, h: u16) -> String {
74        let backend = TestBackend::new(w, h);
75        let mut terminal = Terminal::new(backend).unwrap();
76        terminal.draw(|f| row.render(f, f.area())).unwrap();
77        let buffer = terminal.backend().buffer();
78        buffer.content.iter().map(|c| c.symbol()).collect()
79    }
80
81    #[test]
82    fn describe_query_only() {
83        let row = FilterRow {
84            query: Some("target".into()),
85            structured: StructuredFilter::default(),
86        };
87        assert_eq!(row.describe().as_deref(), Some("Search: `target`"));
88    }
89
90    #[test]
91    fn describe_numeric_query_renders_issue_reference() {
92        let row = FilterRow {
93            query: Some("55".into()),
94            structured: StructuredFilter::default(),
95        };
96        assert_eq!(row.describe().as_deref(), Some("Search: #55"));
97    }
98
99    #[test]
100    fn describe_criteria_only_shows_criteria() {
101        let row = FilterRow {
102            query: None,
103            structured: StructuredFilter {
104                status: Some("todo".to_string()),
105                ..Default::default()
106            },
107        };
108        assert_eq!(row.describe().as_deref(), Some("Search: (status:todo)"));
109    }
110
111    #[test]
112    fn describe_new_fields_in_display_order() {
113        let row = FilterRow {
114            query: None,
115            structured: StructuredFilter {
116                cycle: Some("Cycle 1".to_string()),
117                milestone: Some("Backlog".to_string()),
118                reviewer: Some("alice".to_string()),
119                start_date: Some("2026-09-01".to_string()),
120                due_date: Some("2026-09-10".to_string()),
121                overdue: Some(true),
122                ..Default::default()
123            },
124        };
125        assert_eq!(
126            row.describe().as_deref(),
127            Some(
128                "Search: (cycle:Cycle 1, milestone:Backlog, reviewer:alice, start_date:2026-09-01, due_date:2026-09-10, overdue:true)"
129            )
130        );
131    }
132
133    #[test]
134    fn describe_query_and_criteria_both() {
135        let row = FilterRow {
136            query: Some("55".into()),
137            structured: StructuredFilter {
138                status: Some("todo".to_string()),
139                ..Default::default()
140            },
141        };
142        assert_eq!(row.describe().as_deref(), Some("Search: #55 (status:todo)"));
143    }
144
145    #[test]
146    fn describe_empty_layers_is_none() {
147        let row = FilterRow {
148            query: None,
149            structured: StructuredFilter::default(),
150        };
151        assert_eq!(row.describe(), None);
152    }
153
154    #[test]
155    fn render_shows_the_described_filters() {
156        let row = FilterRow {
157            query: Some("target".into()),
158            structured: StructuredFilter::default(),
159        };
160        let content = render_row(&row, 80, 1);
161        assert!(content.contains("`target`"), "got {content:?}");
162    }
163
164    #[test]
165    fn render_smoke_tiny_and_tall() {
166        // Tiny frame must not panic.
167        let _ = render_row(
168            &FilterRow {
169                query: Some("55".into()),
170                structured: StructuredFilter::default(),
171            },
172            4,
173            1,
174        );
175        let row = FilterRow::default();
176        let content = render_row(&row, 80, 1);
177        assert!(content.starts_with("Search:"), "got {content:?}");
178    }
179}