tix-tracker 0.2.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Filter modal widget (FR-014, T012 / WP02).
//!
//! The Tab-navigated filter editor: a `FilterModal` wraps a
//! [`StructuredFilter`](crate::search::StructuredFilter) in progress plus a
//! cursor over the field rows (mirrors `FormFocus` cycle in `src/ui/form.rs`
//! at 8–34). The widget is a **pure view + model**: it does not read keys for
//! confirm/cancel — WP03 routes keys and calls [`FilterModal::apply`] /
//! [`FilterModal::clear`]. "Clear all" (FR-014) empties every criterion but
//! never fights the `/` text filter — the `/` query layer is reset by its own
//! path (WP03's apply_filter).

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, Paragraph};

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

use super::modal::modal_rect;

/// The Tab-navigated structured-filter editor (T012).
pub struct FilterModal {
    /// The StructuredFilter being edited.
    filter: StructuredFilter,
    /// Field row the cursor is on (Assignee → Label → Status → Priority).
    focus: FilterField,
}

impl Default for FilterModal {
    fn default() -> Self {
        Self::new()
    }
}

impl FilterModal {
    pub fn new() -> Self {
        Self {
            filter: StructuredFilter::default(),
            focus: FilterField::Assignee,
        }
    }

    /// Seed the modal with an existing filter (used when re-opening the modal
    /// for an already-applied filter).
    pub fn with_filter(filter: StructuredFilter) -> Self {
        Self {
            filter,
            focus: FilterField::Assignee,
        }
    }

    /// In-progress filter (public API for WP03 to read/commit).
    pub fn active_filter(&self) -> &StructuredFilter {
        &self.filter
    }

    pub fn active_filter_mut(&mut self) -> &mut StructuredFilter {
        &mut self.filter
    }

    /// Commit in-progress criteria into the active filter (WP03 calls this on
    /// Enter). Pure model transition — no key reading here.
    pub fn apply(&mut self) {}

    /// Clear/reset **all** criteria (FR-014). Never touches the `/` text
    /// filter layer (reset is a pure structured-filter op, mirrored by WP03's
    /// apply_filter for the query layer).
    pub fn clear(&mut self) {
        self.filter = StructuredFilter::default();
        self.focus = FilterField::Assignee;
    }

    /// Move focus to the next criterion row (Tab).
    pub fn focus_next(&mut self) {
        self.focus = self.focus.next();
    }

    /// Move focus to the previous criterion row (Shift+Tab).
    pub fn focus_prev(&mut self) {
        self.focus = self.focus.prev();
    }

    /// The currently-focused criterion row.
    pub fn focus(&self) -> FilterField {
        self.focus
    }

    /// Render the filter modal: a bordered, centered box (via
    /// [`modal_rect`]) listing each criterion row (label + editable value)
    /// with the focused row highlighted; unset criteria show a placeholder.
    pub fn render(&self, f: &mut Frame, area: Rect) {
        let rect = modal_rect(area);
        let mut rows: Vec<Line> = Vec::new();

        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 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 {
    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);
    }

    #[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::Assignee);
    }

    #[test]
    fn focus_cycles_backward() {
        let mut modal = FilterModal::new();
        modal.focus_prev();
        assert_eq!(modal.focus(), FilterField::Priority);
        modal.focus_prev();
        assert_eq!(modal.focus(), FilterField::Status);
    }

    #[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()),
        });
        modal.clear();
        assert_eq!(modal.active_filter(), &StructuredFilter::default());
        assert_eq!(modal.focus(), FilterField::Assignee);
    }
}