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    /// Jump the row cursor straight to `row` (a row click, D6), clamped to the
60    /// same range the arrow keys navigate.
61    pub fn move_selected_to(&mut self, row: usize, total_rows: usize) {
62        if total_rows == 0 {
63            self.selected = 0;
64            return;
65        }
66        self.selected = row.min(total_rows - 1);
67    }
68
69    /// Render the settings modal over `area` for the given board.
70    pub fn render(
71        &self,
72        f: &mut Frame,
73        area: Rect,
74        board_name: &str,
75        board: &crate::config::BoardConfiguration,
76    ) {
77        let bindings =
78            crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
79                .expect("built-in bindings are valid");
80        self.render_with_bindings(
81            f,
82            area,
83            board_name,
84            board,
85            &bindings,
86            crate::keybindings::InputContext::Settings,
87        );
88    }
89
90    pub fn render_with_bindings(
91        &self,
92        f: &mut Frame,
93        area: Rect,
94        board_name: &str,
95        board: &crate::config::BoardConfiguration,
96        bindings: &crate::keybindings::EffectiveBindings,
97        context: crate::keybindings::InputContext,
98    ) {
99        let columns = &board.columns;
100        let hidden = &board.hidden_columns;
101        let wrap_titles = board.wrap_titles;
102        let title_lines = board.title_lines;
103        let order_by = board.order_by;
104        let rect = modal_rect(area);
105        let mut rows: Vec<Line> = Vec::new();
106
107        for (i, col) in columns.iter().enumerate() {
108            let is_hidden = hidden.iter().any(|h| h == col);
109            let mark = if is_hidden { "[ ]" } else { "[x]" };
110            let cursor = if i == self.selected { "> " } else { "  " };
111            let style = if i == self.selected {
112                Style::default()
113                    .fg(Color::Yellow)
114                    .add_modifier(Modifier::BOLD)
115            } else {
116                Style::default().fg(Color::White)
117            };
118            let hidden_suffix = if is_hidden { " (hidden)" } else { "" };
119            rows.push(Line::from(vec![
120                ratatui::text::Span::styled(cursor, style),
121                ratatui::text::Span::styled(format!("{mark} {col}{hidden_suffix}"), style),
122            ]));
123        }
124
125        let display_rows: Vec<Line> = vec![
126            format!("Wrap titles: {}", if wrap_titles { "[x]" } else { "[ ]" }),
127            format!("Title lines: {title_lines}"),
128            format!("Order by: {}", order_by.as_str()),
129        ]
130        .into_iter()
131        .enumerate()
132        .map(|(row, text)| {
133            let idx = columns.len() + row;
134            let cursor = if idx == self.selected { "> " } else { "  " };
135            let style = if idx == self.selected {
136                Style::default()
137                    .fg(Color::Yellow)
138                    .add_modifier(Modifier::BOLD)
139            } else {
140                Style::default().fg(Color::White)
141            };
142            Line::from(vec![
143                ratatui::text::Span::styled(cursor, style),
144                ratatui::text::Span::styled(text, style),
145            ])
146        })
147        .collect();
148        rows.extend(display_rows);
149
150        if let Some(prompt) = &self.prompt {
151            rows.push(Line::from(""));
152            let line = match prompt {
153                SettingsPrompt::AddColumn(buffer) => format!("New column: {buffer}_"),
154                SettingsPrompt::NewBoard(buffer) => format!("New board: {buffer}_"),
155                SettingsPrompt::ConfirmDeleteBoard(name) => {
156                    format!("Delete board '{name}'? [y/N]")
157                }
158            };
159            rows.push(Line::from(ratatui::text::Span::styled(
160                line,
161                Style::default().fg(Color::Cyan),
162            )));
163        } else {
164            rows.push(Line::from(""));
165            rows.push(Line::from(ratatui::text::Span::styled(
166                format!(
167                    "{}: toggle · {}: new column · {}: new board · {}: delete board · {}: switch · {}: close",
168                    bindings.label(context, crate::keybindings::TuiAction::Toggle),
169                    bindings.label(context, crate::keybindings::TuiAction::AddColumn),
170                    bindings.label(context, crate::keybindings::TuiAction::NewBoard),
171                    bindings.label(context, crate::keybindings::TuiAction::Delete),
172                    bindings.label(context, crate::keybindings::TuiAction::Next),
173                    bindings.label(context, crate::keybindings::TuiAction::Close),
174                ),
175                Style::default().fg(Color::DarkGray),
176            )));
177        }
178
179        let block = Block::default()
180            .borders(Borders::ALL)
181            .title(format!(" Settings — {board_name} "))
182            .style(Style::default().fg(Color::Cyan));
183        let para = Paragraph::new(rows).block(block).style(Style::default());
184        f.render_widget(Clear, rect);
185        f.render_widget(para, rect);
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use ratatui::Terminal;
193    use ratatui::backend::TestBackend;
194
195    fn render_smoke(prompt: Option<SettingsPrompt>, w: u16, h: u16) {
196        let backend = TestBackend::new(w, h);
197        let mut terminal = Terminal::new(backend).unwrap();
198        let modal = SettingsModal {
199            selected: 1,
200            prompt,
201        };
202        let board = crate::config::BoardConfiguration {
203            columns: vec!["todo".to_string(), "done".to_string()],
204            hidden_columns: vec!["done".to_string()],
205            ..Default::default()
206        };
207        terminal
208            .draw(|f| modal.render(f, f.area(), "default", &board))
209            .unwrap();
210    }
211
212    #[test]
213    fn renders_display_rows_after_columns() {
214        let backend = TestBackend::new(80, 24);
215        let mut terminal = Terminal::new(backend).unwrap();
216        let modal = SettingsModal {
217            selected: 2,
218            prompt: None,
219        };
220        let board = crate::config::BoardConfiguration {
221            columns: vec!["todo".to_string(), "done".to_string()],
222            hidden_columns: vec![],
223            wrap_titles: false,
224            title_lines: 5,
225            order_by: crate::config::OrderBy::None,
226            ..Default::default()
227        };
228        terminal
229            .draw(|f| modal.render(f, f.area(), "default", &board))
230            .unwrap();
231        let buffer = terminal.backend().buffer();
232
233        let rows: Vec<String> = (0..buffer.area().height)
234            .map(|y| {
235                (0..buffer.area().width)
236                    .map(|x| buffer[(x, y)].symbol().chars().next().unwrap_or(' '))
237                    .collect()
238            })
239            .collect();
240
241        let row_of =
242            |needle: &str| -> Option<usize> { rows.iter().position(|r| r.contains(needle)) };
243
244        let wrap_row = row_of("Wrap titles: [ ]").expect("wrap row shows unchecked mark");
245        let lines_row = row_of("Title lines: 5").expect("line-limit row shows the value");
246        let order_row = row_of("Order by: none").expect("ordering row shows the value");
247        assert!(
248            wrap_row < lines_row && lines_row < order_row,
249            "display rows render beneath the columns in order"
250        );
251
252        // The selected display row (selected=2 → first display row) is highlighted.
253        assert!(
254            rows[wrap_row].contains('>'),
255            "selected display row shows the cursor: {:?}",
256            rows[wrap_row]
257        );
258        assert!(
259            !rows[lines_row].contains('>'),
260            "unselected display row has no cursor: {:?}",
261            rows[lines_row]
262        );
263        let selection_x = rows[wrap_row].find('>').expect("cursor sits in the row") as u16;
264        let selected = &buffer[(selection_x + 2, wrap_row as u16)];
265        assert_eq!(selected.fg, Color::Yellow, "selected row gets cursor style");
266    }
267
268    #[test]
269    fn renders_at_80x24() {
270        render_smoke(None, 80, 24);
271    }
272
273    #[test]
274    fn renders_prompt_at_80x24() {
275        render_smoke(Some(SettingsPrompt::AddColumn("".into())), 80, 24);
276    }
277
278    #[test]
279    fn renders_at_tiny_20x5() {
280        render_smoke(None, 20, 5);
281    }
282
283    #[test]
284    fn move_selected_clamps_within_columns() {
285        let mut modal = SettingsModal::new();
286        modal.move_selected(-1, 3);
287        assert_eq!(modal.selected, 0);
288        modal.move_selected(5, [0usize, 1, 2, 3].len());
289        assert_eq!(modal.selected, 3);
290        modal.move_selected(1, 0);
291        assert_eq!(modal.selected, 0);
292    }
293
294    #[test]
295    fn defaults_to_first_row() {
296        assert_eq!(SettingsModal::new().selected, 0);
297        assert_eq!(SettingsModal::new().prompt, None);
298    }
299}