tix-tracker 0.2.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormFocus {
    Title,
    Status,
    Priority,
    Labels,
    Assignee,
    Estimate,
}

impl FormFocus {
    pub fn next(self) -> Self {
        match self {
            FormFocus::Title => FormFocus::Status,
            FormFocus::Status => FormFocus::Priority,
            FormFocus::Priority => FormFocus::Labels,
            FormFocus::Labels => FormFocus::Assignee,
            FormFocus::Assignee => FormFocus::Estimate,
            FormFocus::Estimate => FormFocus::Title,
        }
    }

    pub fn prev(self) -> Self {
        match self {
            FormFocus::Title => FormFocus::Estimate,
            FormFocus::Status => FormFocus::Title,
            FormFocus::Priority => FormFocus::Status,
            FormFocus::Labels => FormFocus::Priority,
            FormFocus::Assignee => FormFocus::Labels,
            FormFocus::Estimate => FormFocus::Assignee,
        }
    }

    pub fn label(self) -> &'static str {
        match self {
            FormFocus::Title => "Title",
            FormFocus::Status => "Status",
            FormFocus::Priority => "Priority",
            FormFocus::Labels => "Labels",
            FormFocus::Assignee => "Assignee",
            FormFocus::Estimate => "Estimate",
        }
    }
}

#[derive(Debug, Clone)]
pub struct FormState {
    pub title: String,
    pub status: String,
    pub priority: String,
    pub labels: String,
    pub assignee: String,
    pub estimate: String,
    pub focus: FormFocus,
}

impl FormState {
    pub fn new(default_status: &str) -> Self {
        Self::with_default_assignee(default_status, "")
    }

    pub fn with_default_assignee(default_status: &str, default_assignee: &str) -> Self {
        FormState {
            title: String::new(),
            status: default_status.to_string(),
            priority: String::new(),
            labels: String::new(),
            assignee: default_assignee.to_string(),
            estimate: String::new(),
            focus: FormFocus::Title,
        }
    }

    pub fn focused_value(&self) -> &str {
        match self.focus {
            FormFocus::Title => &self.title,
            FormFocus::Status => &self.status,
            FormFocus::Priority => &self.priority,
            FormFocus::Labels => &self.labels,
            FormFocus::Assignee => &self.assignee,
            FormFocus::Estimate => &self.estimate,
        }
    }

    pub fn focused_value_mut(&mut self) -> &mut String {
        match self.focus {
            FormFocus::Title => &mut self.title,
            FormFocus::Status => &mut self.status,
            FormFocus::Priority => &mut self.priority,
            FormFocus::Labels => &mut self.labels,
            FormFocus::Assignee => &mut self.assignee,
            FormFocus::Estimate => &mut self.estimate,
        }
    }

    pub fn insert_char(&mut self, c: char) {
        let value = self.focused_value_mut();
        value.push(c);
    }

    pub fn delete_char(&mut self) {
        let value = self.focused_value_mut();
        value.pop();
    }

    pub fn move_focus_next(&mut self) {
        self.focus = self.focus.next();
    }

    pub fn move_focus_prev(&mut self) {
        self.focus = self.focus.prev();
    }
}

pub fn render(form: &FormState, f: &mut Frame, area: Rect) {
    let focus_idx = match form.focus {
        FormFocus::Title => 0,
        FormFocus::Status => 1,
        FormFocus::Priority => 2,
        FormFocus::Labels => 3,
        FormFocus::Assignee => 4,
        FormFocus::Estimate => 5,
    };

    let fields = [
        ("Title", form.title.as_str()),
        ("Status", form.status.as_str()),
        ("Priority", form.priority.as_str()),
        ("Labels", form.labels.as_str()),
        ("Assignee", form.assignee.as_str()),
        ("Estimate", form.estimate.as_str()),
    ];

    let mut lines: Vec<Line<'static>> = Vec::new();
    for (i, (label, value)) in fields.iter().enumerate() {
        let cursor = if i == focus_idx { "> " } else { "  " };
        let label_style = if i == focus_idx {
            Style::default()
                .add_modifier(Modifier::BOLD)
                .fg(Color::Cyan)
        } else {
            Style::default()
        };
        let (display, value_style) = if value.is_empty() && i != focus_idx {
            ("(empty)".to_string(), Style::default().fg(Color::DarkGray))
        } else if i == focus_idx {
            (
                value.to_string(),
                Style::default().add_modifier(Modifier::UNDERLINED),
            )
        } else {
            (value.to_string(), Style::default())
        };
        lines.push(Line::from(vec![
            Span::styled(format!("{cursor}{label}: "), label_style),
            Span::styled(display, value_style),
        ]));
    }

    lines.push(Line::from(""));
    lines.push(Line::from(Span::styled(
        "Enter: Submit | Esc: Cancel | Tab: Next field",
        Style::default().fg(Color::DarkGray),
    )));

    let para =
        Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Create Issue"));

    f.render_widget(para, area);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn focus_cycles_forward() {
        assert_eq!(FormFocus::Title.next(), FormFocus::Status);
        assert_eq!(FormFocus::Estimate.next(), FormFocus::Title);
    }

    #[test]
    fn focus_cycles_backward() {
        assert_eq!(FormFocus::Title.prev(), FormFocus::Estimate);
        assert_eq!(FormFocus::Status.prev(), FormFocus::Title);
    }

    #[test]
    fn form_insert_and_delete() {
        let mut form = FormState::new("todo");
        form.insert_char('h');
        form.insert_char('i');
        assert_eq!(form.title, "hi");
        form.delete_char();
        assert_eq!(form.title, "h");
    }

    #[test]
    fn form_move_focus() {
        let mut form = FormState::new("todo");
        assert_eq!(form.focus, FormFocus::Title);
        form.move_focus_next();
        assert_eq!(form.focus, FormFocus::Status);
        form.move_focus_prev();
        assert_eq!(form.focus, FormFocus::Title);
    }

    #[test]
    fn render_smoke() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let backend = TestBackend::new(40, 12);
        let mut terminal = Terminal::new(backend).unwrap();
        let form = FormState::new("todo");
        terminal.draw(|f| render(&form, f, f.area())).unwrap();
    }

    #[test]
    fn with_default_assignee_populates_assignee_field() {
        let form = FormState::with_default_assignee("in progress", "alice");
        assert_eq!(form.assignee, "alice");
        assert_eq!(form.status, "in progress");
        assert_eq!(form.focus, FormFocus::Title);
    }
}