Skip to main content

tix/ui/
settings.rs

1//! Settings modal widget (FR: board/column configuration).
2//!
3//! A centered modal editing the act board's columns: a multiple-choice
4//! checkbox list (visible/hidden) plus inline prompts to add a column, create
5//! a board, or delete the current board. Like `filter_modal`, this widget is a
6//! **pure view + model**: it does not read keys or mutate the config — the app
7//! routes keys and applies mutations (`App::handle_settings_key`).
8
9use ratatui::Frame;
10use ratatui::layout::Rect;
11use ratatui::style::{Color, Modifier, Style};
12use ratatui::text::Line;
13use ratatui::widgets::{Block, Borders, Clear, Paragraph};
14
15use super::modal::modal_rect;
16
17/// An active inline prompt inside the settings modal.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum SettingsPrompt {
20    /// Naming a new column (`a`).
21    AddColumn(String),
22    /// Naming a new board (`n`).
23    NewBoard(String),
24    /// Confirming deletion of the current board (`x`).
25    ConfirmDeleteBoard(String),
26}
27
28/// The settings modal: a cursor over the column checkbox rows plus an optional
29/// inline prompt.
30#[derive(Debug, Clone, Default)]
31pub struct SettingsModal {
32    /// Row cursor over the board's columns.
33    pub selected: usize,
34    /// Active inline prompt (None = browsing the checkbox list).
35    pub prompt: Option<SettingsPrompt>,
36}
37
38impl SettingsModal {
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Move the row cursor (clamped to `column_count`).
44    pub fn move_selected(&mut self, delta: isize, column_count: usize) {
45        if column_count == 0 {
46            self.selected = 0;
47            return;
48        }
49        let next = self.selected as isize + delta;
50        self.selected = next.clamp(0, column_count as isize - 1) as usize;
51    }
52
53    /// Render the settings modal over `area` for the given board.
54    pub fn render(
55        &self,
56        f: &mut Frame,
57        area: Rect,
58        board_name: &str,
59        columns: &[String],
60        hidden: &[String],
61    ) {
62        let rect = modal_rect(area);
63        let mut rows: Vec<Line> = Vec::new();
64
65        for (i, col) in columns.iter().enumerate() {
66            let is_hidden = hidden.iter().any(|h| h == col);
67            let mark = if is_hidden { "[ ]" } else { "[x]" };
68            let cursor = if i == self.selected { "> " } else { "  " };
69            let style = if i == self.selected {
70                Style::default()
71                    .fg(Color::Yellow)
72                    .add_modifier(Modifier::BOLD)
73            } else {
74                Style::default().fg(Color::White)
75            };
76            let hidden_suffix = if is_hidden { " (hidden)" } else { "" };
77            rows.push(Line::from(vec![
78                ratatui::text::Span::styled(cursor, style),
79                ratatui::text::Span::styled(format!("{mark} {col}{hidden_suffix}"), style),
80            ]));
81        }
82
83        if let Some(prompt) = &self.prompt {
84            rows.push(Line::from(""));
85            let line = match prompt {
86                SettingsPrompt::AddColumn(buffer) => format!("New column: {buffer}_"),
87                SettingsPrompt::NewBoard(buffer) => format!("New board: {buffer}_"),
88                SettingsPrompt::ConfirmDeleteBoard(name) => {
89                    format!("Delete board '{name}'? [y/N]")
90                }
91            };
92            rows.push(Line::from(ratatui::text::Span::styled(
93                line,
94                Style::default().fg(Color::Cyan),
95            )));
96        } else {
97            rows.push(Line::from(""));
98            rows.push(Line::from(ratatui::text::Span::styled(
99                "Space: toggle · a: new column · n: new board · x: delete board · Tab: switch · q: close",
100                Style::default().fg(Color::DarkGray),
101            )));
102        }
103
104        let block = Block::default()
105            .borders(Borders::ALL)
106            .title(format!(" Settings — {board_name} "))
107            .style(Style::default().fg(Color::Cyan));
108        let para = Paragraph::new(rows).block(block).style(Style::default());
109        f.render_widget(Clear, rect);
110        f.render_widget(para, rect);
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use ratatui::Terminal;
118    use ratatui::backend::TestBackend;
119
120    fn render_smoke(prompt: Option<SettingsPrompt>, w: u16, h: u16) {
121        let backend = TestBackend::new(w, h);
122        let mut terminal = Terminal::new(backend).unwrap();
123        let modal = SettingsModal {
124            selected: 1,
125            prompt,
126        };
127        let columns = vec!["todo".to_string(), "done".to_string()];
128        let hidden = vec!["done".to_string()];
129        terminal
130            .draw(|f| modal.render(f, f.area(), "default", &columns, &hidden))
131            .unwrap();
132    }
133
134    #[test]
135    fn renders_at_80x24() {
136        render_smoke(None, 80, 24);
137    }
138
139    #[test]
140    fn renders_prompt_at_80x24() {
141        render_smoke(Some(SettingsPrompt::AddColumn("".into())), 80, 24);
142    }
143
144    #[test]
145    fn renders_at_tiny_20x5() {
146        render_smoke(None, 20, 5);
147    }
148
149    #[test]
150    fn move_selected_clamps_within_columns() {
151        let mut modal = SettingsModal::new();
152        modal.move_selected(-1, 3);
153        assert_eq!(modal.selected, 0);
154        modal.move_selected(5, [0usize, 1, 2, 3].len());
155        assert_eq!(modal.selected, 3);
156        modal.move_selected(1, 0);
157        assert_eq!(modal.selected, 0);
158    }
159
160    #[test]
161    fn defaults_to_first_row() {
162        assert_eq!(SettingsModal::new().selected, 0);
163        assert_eq!(SettingsModal::new().prompt, None);
164    }
165}