use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::search::{FilterField, StructuredFilter};
use crate::ui::pickers::DropdownState;
use crate::views::divergence::ActiveViewMarker;
use crate::views::plan::ViewPlan;
use crate::views::registry::ViewId;
use super::modal::modal_rect;
use super::views::ViewFeedback;
pub fn picker_layout(area: Rect, state: &DropdownState) -> Option<(Rect, Vec<Rect>)> {
let visible = state.visible_indices();
if visible.is_empty() {
return None;
}
let inner = Block::default().borders(Borders::ALL).inner(area);
let widest = visible
.iter()
.map(|index| state.options[*index].label.chars().count() as u16 + 2)
.max()
.unwrap_or(0);
let width = (widest + 4).clamp(6, inner.width.max(6));
let height = (visible.len() as u16 + 2).min(inner.height.max(3));
if height < 3 || inner.width == 0 {
return None;
}
let rect = Rect::new(inner.x, inner.y, width, height);
let rows = (0..visible.len() as u16)
.take(height as usize - 2)
.map(|row| Rect::new(rect.x, rect.y + 1 + row, rect.width, 1))
.collect();
Some((rect, rows))
}
pub struct FilterModal {
filter: StructuredFilter,
focus: FilterField,
active: Option<ActiveViewMarker>,
feedback: Option<ViewFeedback>,
}
impl Default for FilterModal {
fn default() -> Self {
Self::new()
}
}
impl FilterModal {
pub fn new() -> Self {
Self {
filter: StructuredFilter::default(),
focus: FilterField::Assignee,
active: None,
feedback: None,
}
}
pub fn with_filter(filter: StructuredFilter) -> Self {
Self {
filter,
focus: FilterField::Assignee,
active: None,
feedback: None,
}
}
pub fn set_active(&mut self, active: Option<ActiveViewMarker>, feedback: Option<ViewFeedback>) {
self.active = active;
self.feedback = feedback;
}
pub fn active(&self) -> Option<&ActiveViewMarker> {
self.active.as_ref()
}
pub fn view_id(&self) -> Option<&ViewId> {
self.active.as_ref().map(ActiveViewMarker::view_id)
}
pub fn applied_plan(&self) -> Option<&ViewPlan> {
self.active.as_ref().map(ActiveViewMarker::applied_plan)
}
pub fn set_feedback(&mut self, feedback: Option<ViewFeedback>) {
self.feedback = feedback;
}
pub fn feedback(&self) -> Option<&ViewFeedback> {
self.feedback.as_ref()
}
pub fn indicator_line(&self) -> Option<String> {
self.feedback
.as_ref()
.map(|feedback| feedback.indicator.clone())
}
pub fn header_height(&self) -> u16 {
match &self.feedback {
Some(feedback) => u16::try_from(feedback.lines.len())
.unwrap_or(u16::MAX)
.saturating_add(2),
None => 0,
}
}
pub fn active_filter(&self) -> &StructuredFilter {
&self.filter
}
pub fn active_filter_mut(&mut self) -> &mut StructuredFilter {
&mut self.filter
}
pub fn clear(&mut self) {
self.filter = StructuredFilter::default();
self.focus = FilterField::Assignee;
}
pub fn focus_next(&mut self) {
self.focus = self.focus.next();
}
pub fn focus_prev(&mut self) {
self.focus = self.focus.prev();
}
pub fn focus_field(&mut self, field: FilterField) {
if FilterField::ALL.contains(&field) {
self.focus = field;
}
}
pub fn focus(&self) -> FilterField {
self.focus
}
pub fn is_date_focus(&self) -> bool {
self.focus.is_date()
}
pub fn focused_value(&self) -> String {
self.focus.value(&self.filter).unwrap_or("").to_string()
}
pub fn set_focused_date(&mut self, value: &str) {
if !self.focus.is_date() {
return;
}
if value.is_empty() {
self.focus.unset(&mut self.filter);
} else {
*self.focus.value_mut(&mut self.filter) = value.to_string();
}
}
pub fn render(&self, f: &mut Frame, area: Rect) {
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.expect("built-in bindings are valid");
self.render_with_bindings(
f,
area,
&bindings,
crate::keybindings::InputContext::FilterModal,
);
}
pub fn render_with_bindings(
&self,
f: &mut Frame,
area: Rect,
bindings: &crate::keybindings::EffectiveBindings,
context: crate::keybindings::InputContext,
) {
let rect = modal_rect(area);
let mut rows: Vec<Line> = Vec::new();
if let Some(feedback) = &self.feedback {
rows.push(Line::from(Span::styled(
feedback.indicator.clone(),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)));
for line in &feedback.lines {
rows.push(Line::from(Span::styled(
line.clone(),
Style::default().fg(Color::DarkGray),
)));
}
rows.push(Line::from(""));
}
for field in FilterField::ALL {
let (label, value) = (field.label(), field.value(&self.filter));
let is_focus = field == self.focus;
let value_text = value.unwrap_or("(unset)");
let style = if is_focus {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let label_style = if is_focus {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let prefix = if is_focus { "> " } else { " " };
rows.push(Line::from(vec![
ratatui::text::Span::styled(format!("{prefix}{label}:"), label_style),
ratatui::text::Span::styled(format!(" {value_text}"), style),
]));
}
let inner_h = rect.height.saturating_sub(2) as usize;
if rows.len() + 3 < inner_h {
rows.push(Line::from(""));
}
rows.push(Line::from(Span::styled(
format!(
"{}/{}: navigate",
bindings.label(context, crate::keybindings::TuiAction::Next),
bindings.label(context, crate::keybindings::TuiAction::Previous),
),
Style::default().fg(Color::DarkGray),
)));
rows.push(Line::from(Span::styled(
format!(
"{}: open \u{b7} {}: reset",
bindings.label(context, crate::keybindings::TuiAction::OpenPicker),
bindings.label(context, crate::keybindings::TuiAction::Reset),
),
Style::default().fg(Color::DarkGray),
)));
rows.push(Line::from(Span::styled(
format!(
"{} / {}: apply & close",
bindings.label(context, crate::keybindings::TuiAction::Submit),
bindings.label(context, crate::keybindings::TuiAction::Cancel),
),
Style::default().fg(Color::DarkGray),
)));
let block = Block::default()
.borders(Borders::ALL)
.title(" Filter ")
.style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(rows).block(block).style(Style::default());
f.render_widget(Clear, rect);
f.render_widget(para, rect);
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn smoke(w: u16, h: u16, title_focus: bool) {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = f.area();
let mut modal = FilterModal::new();
if title_focus {
modal.focus_next();
}
modal.render(f, area);
})
.unwrap();
}
#[test]
fn renders_at_120x40() {
smoke(120, 40, false);
}
#[test]
fn renders_at_80x24() {
smoke(80, 24, false);
}
#[test]
fn renders_at_tiny_20x5() {
smoke(20, 5, true);
}
fn buffer_text(w: u16, h: u16) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let modal = FilterModal::new();
modal.render(f, f.area());
})
.unwrap();
let buffer = terminal.backend().buffer();
let mut text = String::new();
for y in 0..h {
for x in 0..w {
for ch in buffer[(x, y)].symbol().chars() {
text.push(ch);
}
}
text.push('\n');
}
text
}
#[test]
fn footer_hint_present_at_normal_size() {
let text = buffer_text(80, 24);
assert!(
text.contains("Tab / Down/Shift+Tab / Up: navigate"),
"footer must advertise field navigation, got:\n{text}"
);
assert!(
text.contains("F2 / Enter / l / Right: open"),
"footer must advertise the row picker on enter/l/right, got:\n{text}"
);
assert!(
text.contains("r / Shift+R: reset"),
"footer must advertise the reset key, got:\n{text}"
);
assert!(
text.contains("a / Esc: apply & close"),
"footer must advertise that closing applies the filter, got:\n{text}"
);
}
#[test]
fn footer_render_does_not_panic_on_tiny_terminal() {
let text = buffer_text(20, 5);
assert_ne!(text, "");
}
#[test]
fn focus_cycles_forward() {
let mut modal = FilterModal::new();
assert_eq!(modal.focus(), FilterField::Assignee);
modal.focus_next();
assert_eq!(modal.focus(), FilterField::Label);
modal.focus_next();
modal.focus_next();
assert_eq!(modal.focus(), FilterField::Priority);
modal.focus_next();
assert_eq!(modal.focus(), FilterField::Cycle);
}
#[test]
fn focus_cycles_backward() {
let mut modal = FilterModal::new();
modal.focus_prev();
assert_eq!(modal.focus(), FilterField::Epic);
modal.focus_prev();
assert_eq!(modal.focus(), FilterField::Project);
modal.focus_prev();
assert_eq!(modal.focus(), FilterField::Category);
modal.focus_prev();
assert_eq!(modal.focus(), FilterField::Parent);
modal.focus_prev();
assert_eq!(modal.focus(), FilterField::DueDate);
modal.focus_prev();
assert_eq!(modal.focus(), FilterField::StartDate);
}
#[test]
fn clear_resets_all_criteria_and_focus() {
let mut modal = FilterModal::with_filter(StructuredFilter {
assignee: Some("alice".to_string()),
label: Some("urgent".to_string()),
status: Some("todo".to_string()),
priority: Some("high".to_string()),
cycle: None,
milestone: None,
reviewer: None,
start_date: None,
due_date: None,
overdue: None,
parent: None,
category: None,
scope: None,
archived: None,
project: None,
epic: None,
});
modal.clear();
assert_eq!(modal.active_filter(), &StructuredFilter::default());
assert_eq!(modal.focus(), FilterField::Assignee);
}
}