use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use super::modal::modal_rect;
pub const DISPLAY_ROWS: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SettingsPrompt {
AddColumn(String),
NewBoard(String),
ConfirmDeleteBoard(String),
}
#[derive(Debug, Clone, Default)]
pub struct SettingsModal {
pub selected: usize,
pub prompt: Option<SettingsPrompt>,
}
impl SettingsModal {
pub fn new() -> Self {
Self::default()
}
pub fn move_selected(&mut self, delta: isize, total_rows: usize) {
if total_rows == 0 {
self.selected = 0;
return;
}
let next = self.selected as isize + delta;
self.selected = next.clamp(0, total_rows as isize - 1) as usize;
}
pub fn move_selected_to(&mut self, row: usize, total_rows: usize) {
if total_rows == 0 {
self.selected = 0;
return;
}
self.selected = row.min(total_rows - 1);
}
pub fn render(
&self,
f: &mut Frame,
area: Rect,
board_name: &str,
board: &crate::config::BoardConfiguration,
) {
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.expect("built-in bindings are valid");
self.render_with_bindings(
f,
area,
board_name,
board,
&bindings,
crate::keybindings::InputContext::Settings,
);
}
pub fn render_with_bindings(
&self,
f: &mut Frame,
area: Rect,
board_name: &str,
board: &crate::config::BoardConfiguration,
bindings: &crate::keybindings::EffectiveBindings,
context: crate::keybindings::InputContext,
) {
let columns = &board.columns;
let hidden = &board.hidden_columns;
let wrap_titles = board.wrap_titles;
let title_lines = board.title_lines;
let order_by = board.order_by;
let rect = modal_rect(area);
let mut rows: Vec<Line> = Vec::new();
for (i, col) in columns.iter().enumerate() {
let is_hidden = hidden.iter().any(|h| h == col);
let mark = if is_hidden { "[ ]" } else { "[x]" };
let cursor = if i == self.selected { "> " } else { " " };
let style = if i == self.selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let hidden_suffix = if is_hidden { " (hidden)" } else { "" };
rows.push(Line::from(vec![
ratatui::text::Span::styled(cursor, style),
ratatui::text::Span::styled(format!("{mark} {col}{hidden_suffix}"), style),
]));
}
let display_rows: Vec<Line> = vec![
format!("Wrap titles: {}", if wrap_titles { "[x]" } else { "[ ]" }),
format!("Title lines: {title_lines}"),
format!("Order by: {}", order_by.as_str()),
]
.into_iter()
.enumerate()
.map(|(row, text)| {
let idx = columns.len() + row;
let cursor = if idx == self.selected { "> " } else { " " };
let style = if idx == self.selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
Line::from(vec![
ratatui::text::Span::styled(cursor, style),
ratatui::text::Span::styled(text, style),
])
})
.collect();
rows.extend(display_rows);
if let Some(prompt) = &self.prompt {
rows.push(Line::from(""));
let line = match prompt {
SettingsPrompt::AddColumn(buffer) => format!("New column: {buffer}_"),
SettingsPrompt::NewBoard(buffer) => format!("New board: {buffer}_"),
SettingsPrompt::ConfirmDeleteBoard(name) => {
format!("Delete board '{name}'? [y/N]")
}
};
rows.push(Line::from(ratatui::text::Span::styled(
line,
Style::default().fg(Color::Cyan),
)));
} else {
rows.push(Line::from(""));
rows.push(Line::from(ratatui::text::Span::styled(
format!(
"{}: toggle · {}: new column · {}: new board · {}: delete board · {}: switch · {}: close",
bindings.label(context, crate::keybindings::TuiAction::Toggle),
bindings.label(context, crate::keybindings::TuiAction::AddColumn),
bindings.label(context, crate::keybindings::TuiAction::NewBoard),
bindings.label(context, crate::keybindings::TuiAction::Delete),
bindings.label(context, crate::keybindings::TuiAction::Next),
bindings.label(context, crate::keybindings::TuiAction::Close),
),
Style::default().fg(Color::DarkGray),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Settings — {board_name} "))
.style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(rows).block(block).style(Style::default());
f.render_widget(Clear, rect);
f.render_widget(para, rect);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn render_smoke(prompt: Option<SettingsPrompt>, w: u16, h: u16) {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
let modal = SettingsModal {
selected: 1,
prompt,
};
let board = crate::config::BoardConfiguration {
columns: vec!["todo".to_string(), "done".to_string()],
hidden_columns: vec!["done".to_string()],
..Default::default()
};
terminal
.draw(|f| modal.render(f, f.area(), "default", &board))
.unwrap();
}
#[test]
fn renders_display_rows_after_columns() {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let modal = SettingsModal {
selected: 2,
prompt: None,
};
let board = crate::config::BoardConfiguration {
columns: vec!["todo".to_string(), "done".to_string()],
hidden_columns: vec![],
wrap_titles: false,
title_lines: 5,
order_by: crate::config::OrderBy::None,
..Default::default()
};
terminal
.draw(|f| modal.render(f, f.area(), "default", &board))
.unwrap();
let buffer = terminal.backend().buffer();
let rows: Vec<String> = (0..buffer.area().height)
.map(|y| {
(0..buffer.area().width)
.map(|x| buffer[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect()
})
.collect();
let row_of =
|needle: &str| -> Option<usize> { rows.iter().position(|r| r.contains(needle)) };
let wrap_row = row_of("Wrap titles: [ ]").expect("wrap row shows unchecked mark");
let lines_row = row_of("Title lines: 5").expect("line-limit row shows the value");
let order_row = row_of("Order by: none").expect("ordering row shows the value");
assert!(
wrap_row < lines_row && lines_row < order_row,
"display rows render beneath the columns in order"
);
assert!(
rows[wrap_row].contains('>'),
"selected display row shows the cursor: {:?}",
rows[wrap_row]
);
assert!(
!rows[lines_row].contains('>'),
"unselected display row has no cursor: {:?}",
rows[lines_row]
);
let selection_x = rows[wrap_row].find('>').expect("cursor sits in the row") as u16;
let selected = &buffer[(selection_x + 2, wrap_row as u16)];
assert_eq!(selected.fg, Color::Yellow, "selected row gets cursor style");
}
#[test]
fn renders_at_80x24() {
render_smoke(None, 80, 24);
}
#[test]
fn renders_prompt_at_80x24() {
render_smoke(Some(SettingsPrompt::AddColumn("".into())), 80, 24);
}
#[test]
fn renders_at_tiny_20x5() {
render_smoke(None, 20, 5);
}
#[test]
fn move_selected_clamps_within_columns() {
let mut modal = SettingsModal::new();
modal.move_selected(-1, 3);
assert_eq!(modal.selected, 0);
modal.move_selected(5, [0usize, 1, 2, 3].len());
assert_eq!(modal.selected, 3);
modal.move_selected(1, 0);
assert_eq!(modal.selected, 0);
}
#[test]
fn defaults_to_first_row() {
assert_eq!(SettingsModal::new().selected, 0);
assert_eq!(SettingsModal::new().prompt, None);
}
}