tix-tracker 0.5.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, Span};
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();
    }

    /// Jump focus directly to a specific criterion row (a row click, D6) —
    /// the same focus state the Tab/arrow cycling produces, chosen directly.
    pub fn focus_field(&mut self, field: FilterField) {
        if FilterField::ALL.contains(&field) {
            self.focus = field;
        }
    }

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

    /// Whether the focused criterion is a picker-only date field.
    pub fn is_date_focus(&self) -> bool {
        self.focus.is_date()
    }

    /// The focused criterion's value, or `""` when unset.
    pub fn focused_value(&self) -> String {
        self.focus.value(&self.filter).unwrap_or("").to_string()
    }

    /// Overwrite the focused date criterion (no-op when it isn't a date field).
    /// An empty value clears the criterion.
    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();
        }
    }

    /// Whether the focused date criterion already holds a complete valid date.
    pub fn focused_date_is_complete(&self) -> bool {
        self.focus.is_date() && crate::ui::date_picker::is_complete_date(&self.focused_value())
    }

    /// Type one character into the focused criterion. Date criteria apply the
    /// masked `YYYY-MM-DD` edit; others append directly.
    pub fn type_char(&mut self, c: char) {
        if self.focus.is_date() {
            if let Some(next) = crate::ui::date_picker::type_date(&self.focused_value(), c) {
                *self.focus.value_mut(&mut self.filter) = next;
            }
        } else {
            self.focus.value_mut(&mut self.filter).push(c);
        }
    }

    /// Delete the last character of the focused criterion (masked for dates).
    pub fn backspace(&mut self) {
        if self.focus.is_date() {
            let next = crate::ui::date_picker::backspace_date(&self.focused_value());
            if next.is_empty() {
                self.focus.unset(&mut self.filter);
            } else {
                *self.focus.value_mut(&mut self.filter) = next;
            }
        } else {
            self.focus.value_mut(&mut self.filter).pop();
        }
    }

    /// 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 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();

        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),
            ]));
        }

        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!(
                "{}: reset \u{b7} {}: date \u{b7} {}: apply \u{b7} {}: cancel",
                bindings.label(context, crate::keybindings::TuiAction::Reset),
                bindings.label(context, crate::keybindings::TuiAction::OpenPicker),
                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 {
    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("r / Shift+R: reset") && text.contains("Enter: apply"),
            "got:\n{text}"
        );
    }

    #[test]
    fn footer_render_does_not_panic_on_tiny_terminal() {
        let text = buffer_text(20, 5);
        assert!(!text.is_empty());
    }

    #[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();
        // The backward wrap lands on the **last** row, whatever that is — and the
        // last row has changed twice. `Parent` was appended as the tenth (T024
        // step 2) and `Category` as the eleventh (WP08 T034 step 2), both appended
        // rather than interleaved. This is the one place the appending decision is
        // visible, and it is visible on purpose: a user pressing Up from the first
        // row should reach the last row, not a row chosen by history.
        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,
        });
        modal.clear();
        assert_eq!(modal.active_filter(), &StructuredFilter::default());
        assert_eq!(modal.focus(), FilterField::Assignee);
    }
}