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};
#[derive(Debug, Clone, Default)]
pub struct FilterRow {
pub query: Option<String>,
pub structured: StructuredFilter,
}
impl FilterRow {
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 {
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() {
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:?}");
}
}