tix-tracker 0.4.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Settings modal widget (FR: board/column configuration).
//!
//! A centered modal editing the act board's columns: a multiple-choice
//! checkbox list (visible/hidden) plus inline prompts to add a column, create
//! a board, or delete the current board. Like `filter_modal`, this widget is a
//! **pure view + model**: it does not read keys or mutate the config — the app
//! routes keys and applies mutations (`App::handle_settings_key`).

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 super::modal::modal_rect;

/// Number of non-column rows in the settings modal: the display settings
/// (title wrap toggle, line limit, ordering). The app uses this to size the
/// browse cursor and to route toggles; the widget uses it to render the rows.
pub const DISPLAY_ROWS: usize = 3;

/// An active inline prompt inside the settings modal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SettingsPrompt {
    /// Naming a new column (`a`).
    AddColumn(String),
    /// Naming a new board (`n`).
    NewBoard(String),
    /// Confirming deletion of the current board (`x`).
    ConfirmDeleteBoard(String),
}

/// The settings modal: a cursor over the column checkbox rows plus an optional
/// inline prompt.
#[derive(Debug, Clone, Default)]
pub struct SettingsModal {
    /// Row cursor over the board's columns.
    pub selected: usize,
    /// Active inline prompt (None = browsing the checkbox list).
    pub prompt: Option<SettingsPrompt>,
}

impl SettingsModal {
    pub fn new() -> Self {
        Self::default()
    }

    /// Move the row cursor (clamped to `total_rows`, i.e. the board's columns
    /// plus [`DISPLAY_ROWS`]).
    pub fn move_selected(&mut self, delta: isize, total_rows: usize) {
        if total_rows == 0 {
            self.selected = 0;
            return;
        }
        let next = self.selected as isize + delta;
        self.selected = next.clamp(0, total_rows as isize - 1) as usize;
    }

    /// Jump the row cursor straight to `row` (a row click, D6), clamped to the
    /// same range the arrow keys navigate.
    pub fn move_selected_to(&mut self, row: usize, total_rows: usize) {
        if total_rows == 0 {
            self.selected = 0;
            return;
        }
        self.selected = row.min(total_rows - 1);
    }

    /// Render the settings modal over `area` for the given board.
    pub fn render(
        &self,
        f: &mut Frame,
        area: Rect,
        board_name: &str,
        board: &crate::config::BoardConfiguration,
    ) {
        let bindings =
            crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
                .expect("built-in bindings are valid");
        self.render_with_bindings(
            f,
            area,
            board_name,
            board,
            &bindings,
            crate::keybindings::InputContext::Settings,
        );
    }

    pub fn render_with_bindings(
        &self,
        f: &mut Frame,
        area: Rect,
        board_name: &str,
        board: &crate::config::BoardConfiguration,
        bindings: &crate::keybindings::EffectiveBindings,
        context: crate::keybindings::InputContext,
    ) {
        let columns = &board.columns;
        let hidden = &board.hidden_columns;
        let wrap_titles = board.wrap_titles;
        let title_lines = board.title_lines;
        let order_by = board.order_by;
        let rect = modal_rect(area);
        let mut rows: Vec<Line> = Vec::new();

        for (i, col) in columns.iter().enumerate() {
            let is_hidden = hidden.iter().any(|h| h == col);
            let mark = if is_hidden { "[ ]" } else { "[x]" };
            let cursor = if i == self.selected { "> " } else { "  " };
            let style = if i == self.selected {
                Style::default()
                    .fg(Color::Yellow)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::White)
            };
            let hidden_suffix = if is_hidden { " (hidden)" } else { "" };
            rows.push(Line::from(vec![
                ratatui::text::Span::styled(cursor, style),
                ratatui::text::Span::styled(format!("{mark} {col}{hidden_suffix}"), style),
            ]));
        }

        let display_rows: Vec<Line> = vec![
            format!("Wrap titles: {}", if wrap_titles { "[x]" } else { "[ ]" }),
            format!("Title lines: {title_lines}"),
            format!("Order by: {}", order_by.as_str()),
        ]
        .into_iter()
        .enumerate()
        .map(|(row, text)| {
            let idx = columns.len() + row;
            let cursor = if idx == self.selected { "> " } else { "  " };
            let style = if idx == self.selected {
                Style::default()
                    .fg(Color::Yellow)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::White)
            };
            Line::from(vec![
                ratatui::text::Span::styled(cursor, style),
                ratatui::text::Span::styled(text, style),
            ])
        })
        .collect();
        rows.extend(display_rows);

        if let Some(prompt) = &self.prompt {
            rows.push(Line::from(""));
            let line = match prompt {
                SettingsPrompt::AddColumn(buffer) => format!("New column: {buffer}_"),
                SettingsPrompt::NewBoard(buffer) => format!("New board: {buffer}_"),
                SettingsPrompt::ConfirmDeleteBoard(name) => {
                    format!("Delete board '{name}'? [y/N]")
                }
            };
            rows.push(Line::from(ratatui::text::Span::styled(
                line,
                Style::default().fg(Color::Cyan),
            )));
        } else {
            rows.push(Line::from(""));
            rows.push(Line::from(ratatui::text::Span::styled(
                format!(
                    "{}: toggle · {}: new column · {}: new board · {}: delete board · {}: switch · {}: close",
                    bindings.label(context, crate::keybindings::TuiAction::Toggle),
                    bindings.label(context, crate::keybindings::TuiAction::AddColumn),
                    bindings.label(context, crate::keybindings::TuiAction::NewBoard),
                    bindings.label(context, crate::keybindings::TuiAction::Delete),
                    bindings.label(context, crate::keybindings::TuiAction::Next),
                    bindings.label(context, crate::keybindings::TuiAction::Close),
                ),
                Style::default().fg(Color::DarkGray),
            )));
        }

        let block = Block::default()
            .borders(Borders::ALL)
            .title(format!(" Settings — {board_name} "))
            .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 render_smoke(prompt: Option<SettingsPrompt>, w: u16, h: u16) {
        let backend = TestBackend::new(w, h);
        let mut terminal = Terminal::new(backend).unwrap();
        let modal = SettingsModal {
            selected: 1,
            prompt,
        };
        let board = crate::config::BoardConfiguration {
            columns: vec!["todo".to_string(), "done".to_string()],
            hidden_columns: vec!["done".to_string()],
            ..Default::default()
        };
        terminal
            .draw(|f| modal.render(f, f.area(), "default", &board))
            .unwrap();
    }

    #[test]
    fn renders_display_rows_after_columns() {
        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        let modal = SettingsModal {
            selected: 2,
            prompt: None,
        };
        let board = crate::config::BoardConfiguration {
            columns: vec!["todo".to_string(), "done".to_string()],
            hidden_columns: vec![],
            wrap_titles: false,
            title_lines: 5,
            order_by: crate::config::OrderBy::None,
            ..Default::default()
        };
        terminal
            .draw(|f| modal.render(f, f.area(), "default", &board))
            .unwrap();
        let buffer = terminal.backend().buffer();

        let rows: Vec<String> = (0..buffer.area().height)
            .map(|y| {
                (0..buffer.area().width)
                    .map(|x| buffer[(x, y)].symbol().chars().next().unwrap_or(' '))
                    .collect()
            })
            .collect();

        let row_of =
            |needle: &str| -> Option<usize> { rows.iter().position(|r| r.contains(needle)) };

        let wrap_row = row_of("Wrap titles: [ ]").expect("wrap row shows unchecked mark");
        let lines_row = row_of("Title lines: 5").expect("line-limit row shows the value");
        let order_row = row_of("Order by: none").expect("ordering row shows the value");
        assert!(
            wrap_row < lines_row && lines_row < order_row,
            "display rows render beneath the columns in order"
        );

        // The selected display row (selected=2 → first display row) is highlighted.
        assert!(
            rows[wrap_row].contains('>'),
            "selected display row shows the cursor: {:?}",
            rows[wrap_row]
        );
        assert!(
            !rows[lines_row].contains('>'),
            "unselected display row has no cursor: {:?}",
            rows[lines_row]
        );
        let selection_x = rows[wrap_row].find('>').expect("cursor sits in the row") as u16;
        let selected = &buffer[(selection_x + 2, wrap_row as u16)];
        assert_eq!(selected.fg, Color::Yellow, "selected row gets cursor style");
    }

    #[test]
    fn renders_at_80x24() {
        render_smoke(None, 80, 24);
    }

    #[test]
    fn renders_prompt_at_80x24() {
        render_smoke(Some(SettingsPrompt::AddColumn("".into())), 80, 24);
    }

    #[test]
    fn renders_at_tiny_20x5() {
        render_smoke(None, 20, 5);
    }

    #[test]
    fn move_selected_clamps_within_columns() {
        let mut modal = SettingsModal::new();
        modal.move_selected(-1, 3);
        assert_eq!(modal.selected, 0);
        modal.move_selected(5, [0usize, 1, 2, 3].len());
        assert_eq!(modal.selected, 3);
        modal.move_selected(1, 0);
        assert_eq!(modal.selected, 0);
    }

    #[test]
    fn defaults_to_first_row() {
        assert_eq!(SettingsModal::new().selected, 0);
        assert_eq!(SettingsModal::new().prompt, None);
    }
}