1use 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
17pub const DISPLAY_ROWS: usize = 3;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum SettingsPrompt {
25 AddColumn(String),
27 NewBoard(String),
29 ConfirmDeleteBoard(String),
31}
32
33#[derive(Debug, Clone, Default)]
36pub struct SettingsModal {
37 pub selected: usize,
39 pub prompt: Option<SettingsPrompt>,
41}
42
43impl SettingsModal {
44 pub fn new() -> Self {
45 Self::default()
46 }
47
48 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 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 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}