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 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 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 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}