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/// Number of non-column rows in the settings modal: the display settings
18/// (title wrap toggle, line limit, ordering). The app uses this to size the
19/// browse cursor and to route toggles; the widget uses it to render the rows.
20pub const DISPLAY_ROWS: usize = 3;
21
22/// An active inline prompt inside the settings modal.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum SettingsPrompt {
25    /// Naming a new column (`a`).
26    AddColumn(String),
27    /// Naming a new board (`n`).
28    NewBoard(String),
29    /// Confirming deletion of the current board (`x`).
30    ConfirmDeleteBoard(String),
31}
32
33/// The settings modal: a cursor over the column checkbox rows plus an optional
34/// inline prompt.
35#[derive(Debug, Clone, Default)]
36pub struct SettingsModal {
37    /// Row cursor over the board's columns.
38    pub selected: usize,
39    /// Active inline prompt (None = browsing the checkbox list).
40    pub prompt: Option<SettingsPrompt>,
41}
42
43impl SettingsModal {
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Move the row cursor (clamped to `total_rows`, i.e. the board's columns
49    /// plus [`DISPLAY_ROWS`]).
50    pub fn move_selected(&mut self, delta: isize, total_rows: usize) {
51        if total_rows == 0 {
52            self.selected = 0;
53            return;
54        }
55        let next = self.selected as isize + delta;
56        self.selected = next.clamp(0, total_rows as isize - 1) as usize;
57    }
58
59    /// Render the settings modal over `area` for the given board.
60    pub fn render(
61        &self,
62        f: &mut Frame,
63        area: Rect,
64        board_name: &str,
65        board: &crate::config::BoardConfiguration,
66    ) {
67        let columns = &board.columns;
68        let hidden = &board.hidden_columns;
69        let wrap_titles = board.wrap_titles;
70        let title_lines = board.title_lines;
71        let order_by = board.order_by;
72        let rect = modal_rect(area);
73        let mut rows: Vec<Line> = Vec::new();
74
75        for (i, col) in columns.iter().enumerate() {
76            let is_hidden = hidden.iter().any(|h| h == col);
77            let mark = if is_hidden { "[ ]" } else { "[x]" };
78            let cursor = if i == self.selected { "> " } else { "  " };
79            let style = if i == self.selected {
80                Style::default()
81                    .fg(Color::Yellow)
82                    .add_modifier(Modifier::BOLD)
83            } else {
84                Style::default().fg(Color::White)
85            };
86            let hidden_suffix = if is_hidden { " (hidden)" } else { "" };
87            rows.push(Line::from(vec![
88                ratatui::text::Span::styled(cursor, style),
89                ratatui::text::Span::styled(format!("{mark} {col}{hidden_suffix}"), style),
90            ]));
91        }
92
93        let display_rows: Vec<Line> = vec![
94            format!("Wrap titles: {}", if wrap_titles { "[x]" } else { "[ ]" }),
95            format!("Title lines: {title_lines}"),
96            format!("Order by: {}", order_by.as_str()),
97        ]
98        .into_iter()
99        .enumerate()
100        .map(|(row, text)| {
101            let idx = columns.len() + row;
102            let cursor = if idx == self.selected { "> " } else { "  " };
103            let style = if idx == self.selected {
104                Style::default()
105                    .fg(Color::Yellow)
106                    .add_modifier(Modifier::BOLD)
107            } else {
108                Style::default().fg(Color::White)
109            };
110            Line::from(vec![
111                ratatui::text::Span::styled(cursor, style),
112                ratatui::text::Span::styled(text, style),
113            ])
114        })
115        .collect();
116        rows.extend(display_rows);
117
118        if let Some(prompt) = &self.prompt {
119            rows.push(Line::from(""));
120            let line = match prompt {
121                SettingsPrompt::AddColumn(buffer) => format!("New column: {buffer}_"),
122                SettingsPrompt::NewBoard(buffer) => format!("New board: {buffer}_"),
123                SettingsPrompt::ConfirmDeleteBoard(name) => {
124                    format!("Delete board '{name}'? [y/N]")
125                }
126            };
127            rows.push(Line::from(ratatui::text::Span::styled(
128                line,
129                Style::default().fg(Color::Cyan),
130            )));
131        } else {
132            rows.push(Line::from(""));
133            rows.push(Line::from(ratatui::text::Span::styled(
134                "Space: toggle · a: new column · n: new board · x: delete board · Tab: switch · q: close",
135                Style::default().fg(Color::DarkGray),
136            )));
137        }
138
139        let block = Block::default()
140            .borders(Borders::ALL)
141            .title(format!(" Settings — {board_name} "))
142            .style(Style::default().fg(Color::Cyan));
143        let para = Paragraph::new(rows).block(block).style(Style::default());
144        f.render_widget(Clear, rect);
145        f.render_widget(para, rect);
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use ratatui::Terminal;
153    use ratatui::backend::TestBackend;
154
155    fn render_smoke(prompt: Option<SettingsPrompt>, w: u16, h: u16) {
156        let backend = TestBackend::new(w, h);
157        let mut terminal = Terminal::new(backend).unwrap();
158        let modal = SettingsModal {
159            selected: 1,
160            prompt,
161        };
162        let board = crate::config::BoardConfiguration {
163            columns: vec!["todo".to_string(), "done".to_string()],
164            hidden_columns: vec!["done".to_string()],
165            ..Default::default()
166        };
167        terminal
168            .draw(|f| modal.render(f, f.area(), "default", &board))
169            .unwrap();
170    }
171
172    #[test]
173    fn renders_display_rows_after_columns() {
174        let backend = TestBackend::new(80, 24);
175        let mut terminal = Terminal::new(backend).unwrap();
176        let modal = SettingsModal {
177            selected: 2,
178            prompt: None,
179        };
180        let board = crate::config::BoardConfiguration {
181            columns: vec!["todo".to_string(), "done".to_string()],
182            hidden_columns: vec![],
183            wrap_titles: false,
184            title_lines: 5,
185            order_by: crate::config::OrderBy::None,
186            ..Default::default()
187        };
188        terminal
189            .draw(|f| modal.render(f, f.area(), "default", &board))
190            .unwrap();
191        let buffer = terminal.backend().buffer();
192
193        let rows: Vec<String> = (0..buffer.area().height)
194            .map(|y| {
195                (0..buffer.area().width)
196                    .map(|x| buffer[(x, y)].symbol().chars().next().unwrap_or(' '))
197                    .collect()
198            })
199            .collect();
200
201        let row_of =
202            |needle: &str| -> Option<usize> { rows.iter().position(|r| r.contains(needle)) };
203
204        let wrap_row = row_of("Wrap titles: [ ]").expect("wrap row shows unchecked mark");
205        let lines_row = row_of("Title lines: 5").expect("line-limit row shows the value");
206        let order_row = row_of("Order by: none").expect("ordering row shows the value");
207        assert!(
208            wrap_row < lines_row && lines_row < order_row,
209            "display rows render beneath the columns in order"
210        );
211
212        // The selected display row (selected=2 → first display row) is highlighted.
213        assert!(
214            rows[wrap_row].contains('>'),
215            "selected display row shows the cursor: {:?}",
216            rows[wrap_row]
217        );
218        assert!(
219            !rows[lines_row].contains('>'),
220            "unselected display row has no cursor: {:?}",
221            rows[lines_row]
222        );
223        let selection_x = rows[wrap_row].find('>').expect("cursor sits in the row") as u16;
224        let selected = &buffer[(selection_x + 2, wrap_row as u16)];
225        assert_eq!(selected.fg, Color::Yellow, "selected row gets cursor style");
226    }
227
228    #[test]
229    fn renders_at_80x24() {
230        render_smoke(None, 80, 24);
231    }
232
233    #[test]
234    fn renders_prompt_at_80x24() {
235        render_smoke(Some(SettingsPrompt::AddColumn("".into())), 80, 24);
236    }
237
238    #[test]
239    fn renders_at_tiny_20x5() {
240        render_smoke(None, 20, 5);
241    }
242
243    #[test]
244    fn move_selected_clamps_within_columns() {
245        let mut modal = SettingsModal::new();
246        modal.move_selected(-1, 3);
247        assert_eq!(modal.selected, 0);
248        modal.move_selected(5, [0usize, 1, 2, 3].len());
249        assert_eq!(modal.selected, 3);
250        modal.move_selected(1, 0);
251        assert_eq!(modal.selected, 0);
252    }
253
254    #[test]
255    fn defaults_to_first_row() {
256        assert_eq!(SettingsModal::new().selected, 0);
257        assert_eq!(SettingsModal::new().prompt, None);
258    }
259}