tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Search indicator row beneath the tab bar (issue #55, board-filtering).
//!
//! A single passive row rendering the active `/` search and any structured
//! [`StructuredFilter`] criteria: the text query (shown as `#<number>` when it
//! is a bare number) via its own formatting, the criteria via its `Display`
//! impl. It is wired up by `App`'s `render_board`/`render_board_input`; this
//! widget only formats and draws the line (D-04). An all-inactive search
//! renders nothing — the caller decides whether to reserve a row.

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;

use crate::search::{StructuredFilter, number_query};

/// The active-filter indicator row rendered under the tab bar.
#[derive(Debug, Clone, Default)]
pub struct FilterRow {
    /// The active `/` text query (None when no text query is applied).
    pub query: Option<String>,
    /// The committed structured criteria (possibly all inactive).
    pub structured: StructuredFilter,
}

impl FilterRow {
    /// One-line description of the active filters: query first (`#<n>` for a
    /// bare number), then the structured criteria — or `None` when nothing is
    /// active. Display and matching both derive from [`number_query`] so they
    /// cannot drift (D-02).
    pub fn describe(&self) -> Option<String> {
        let mut segments = Vec::new();
        if let Some(q) = self.query.as_deref().filter(|q| !q.is_empty()) {
            segments.push(match number_query(q) {
                Some(n) => format!("#{n}"),
                None => format!("`{q}`"),
            });
        }
        let criteria = self.structured.to_string();
        if criteria != "(no active criteria)" {
            segments.push(format!("({criteria})"));
        }
        if segments.is_empty() {
            None
        } else {
            Some(format!("Search: {}", segments.join(" ")))
        }
    }

    pub fn render(&self, f: &mut Frame, area: Rect) {
        if area.height == 0 || area.width == 0 {
            return;
        }
        let text = self
            .describe()
            .unwrap_or_else(|| "Search: (none)".to_string());
        let para = Paragraph::new(Line::from(Span::styled(
            text,
            Style::default().fg(Color::Cyan),
        )));
        f.render_widget(para, area);
    }
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    fn render_row(row: &FilterRow, w: u16, h: u16) -> String {
        let backend = TestBackend::new(w, h);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|f| row.render(f, f.area())).unwrap();
        let buffer = terminal.backend().buffer();
        buffer.content.iter().map(|c| c.symbol()).collect()
    }

    #[test]
    fn describe_query_only() {
        let row = FilterRow {
            query: Some("target".into()),
            structured: StructuredFilter::default(),
        };
        assert_eq!(row.describe().as_deref(), Some("Search: `target`"));
    }

    #[test]
    fn describe_numeric_query_renders_issue_reference() {
        let row = FilterRow {
            query: Some("55".into()),
            structured: StructuredFilter::default(),
        };
        assert_eq!(row.describe().as_deref(), Some("Search: #55"));
    }

    #[test]
    fn describe_criteria_only_shows_criteria() {
        let row = FilterRow {
            query: None,
            structured: StructuredFilter {
                status: Some("todo".to_string()),
                ..Default::default()
            },
        };
        assert_eq!(row.describe().as_deref(), Some("Search: (status:todo)"));
    }

    #[test]
    fn describe_new_fields_in_display_order() {
        let row = FilterRow {
            query: None,
            structured: StructuredFilter {
                cycle: Some("Cycle 1".to_string()),
                milestone: Some("Backlog".to_string()),
                reviewer: Some("alice".to_string()),
                start_date: Some("2026-09-01".to_string()),
                due_date: Some("2026-09-10".to_string()),
                overdue: Some(true),
                ..Default::default()
            },
        };
        assert_eq!(
            row.describe().as_deref(),
            Some(
                "Search: (cycle:Cycle 1, milestone:Backlog, reviewer:alice, start_date:2026-09-01, due_date:2026-09-10, overdue:true)"
            )
        );
    }

    #[test]
    fn describe_query_and_criteria_both() {
        let row = FilterRow {
            query: Some("55".into()),
            structured: StructuredFilter {
                status: Some("todo".to_string()),
                ..Default::default()
            },
        };
        assert_eq!(row.describe().as_deref(), Some("Search: #55 (status:todo)"));
    }

    #[test]
    fn describe_empty_layers_is_none() {
        let row = FilterRow {
            query: None,
            structured: StructuredFilter::default(),
        };
        assert_eq!(row.describe(), None);
    }

    #[test]
    fn render_shows_the_described_filters() {
        let row = FilterRow {
            query: Some("target".into()),
            structured: StructuredFilter::default(),
        };
        let content = render_row(&row, 80, 1);
        assert!(content.contains("`target`"), "got {content:?}");
    }

    #[test]
    fn render_smoke_tiny_and_tall() {
        // Tiny frame must not panic.
        let _ = render_row(
            &FilterRow {
                query: Some("55".into()),
                structured: StructuredFilter::default(),
            },
            4,
            1,
        );
        let row = FilterRow::default();
        let content = render_row(&row, 80, 1);
        assert!(content.starts_with("Search:"), "got {content:?}");
    }
}