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_query_and_criteria_both() {
112        let row = FilterRow {
113            query: Some("55".into()),
114            structured: StructuredFilter {
115                status: Some("todo".to_string()),
116                ..Default::default()
117            },
118        };
119        assert_eq!(
120            row.describe().as_deref(),
121            Some("Filters: #55 (status:todo)")
122        );
123    }
124
125    #[test]
126    fn describe_empty_layers_is_none() {
127        let row = FilterRow {
128            query: None,
129            structured: StructuredFilter::default(),
130        };
131        assert_eq!(row.describe(), None);
132    }
133
134    #[test]
135    fn render_shows_the_described_filters() {
136        let row = FilterRow {
137            query: Some("target".into()),
138            structured: StructuredFilter::default(),
139        };
140        let content = render_row(&row, 80, 1);
141        assert!(content.contains("`target`"), "got {content:?}");
142    }
143
144    #[test]
145    fn render_smoke_tiny_and_tall() {
146        // Tiny frame must not panic.
147        let _ = render_row(
148            &FilterRow {
149                query: Some("55".into()),
150                structured: StructuredFilter::default(),
151            },
152            4,
153            1,
154        );
155        let row = FilterRow::default();
156        let content = render_row(&row, 80, 1);
157        assert!(content.starts_with("Filters:"), "got {content:?}");
158    }
159}