use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::app::{Action, App};
use crate::keybindings::{EffectiveBindings, InputContext};
use crate::ui::planning::{PlanningRow, prompt_line, rows_lines};
pub const DISPLAY_ROWS: usize = 3;
pub const GENERAL_ROWS: usize = 2;
pub fn board_rows_len(board: &crate::config::BoardConfiguration) -> usize {
board.columns.len() + DISPLAY_ROWS + GENERAL_ROWS
}
pub const SECTION_RAIL_WIDTH: u16 = 21;
pub fn config_body_rect(area: Rect) -> Rect {
let rail = SECTION_RAIL_WIDTH.min(area.width);
Rect {
x: area.x + rail,
width: area.width - rail,
..area
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConfigSection {
#[default]
Dashboards,
Cycles,
Milestones,
Types,
CustomAttributes,
}
impl ConfigSection {
pub const ALL: [Self; 5] = [
Self::Dashboards,
Self::Cycles,
Self::Milestones,
Self::Types,
Self::CustomAttributes,
];
pub const fn label(self) -> &'static str {
match self {
Self::Dashboards => "Dashboards",
Self::Cycles => "Cycles",
Self::Milestones => "Milestones",
Self::Types => "Types",
Self::CustomAttributes => "Attributes",
}
}
pub fn next(self) -> Self {
let index = self.index();
Self::ALL[(index + 1) % Self::ALL.len()]
}
pub fn prev(self) -> Self {
let index = self.index();
Self::ALL[(index + Self::ALL.len() - 1) % Self::ALL.len()]
}
pub const fn index(self) -> usize {
match self {
Self::Dashboards => 0,
Self::Cycles => 1,
Self::Milestones => 2,
Self::Types => 3,
Self::CustomAttributes => 4,
}
}
pub const fn is_list(self) -> bool {
matches!(self, Self::Types | Self::CustomAttributes)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConfigLevel {
#[default]
Sections,
Index,
Detail,
}
impl ConfigLevel {
pub const fn index(self) -> usize {
match self {
Self::Sections => 0,
Self::Index => 1,
Self::Detail => 2,
}
}
pub const fn deeper(self) -> Self {
match self {
Self::Sections => Self::Index,
Self::Index | Self::Detail => Self::Detail,
}
}
pub const fn shallower(self) -> Self {
match self {
Self::Sections | Self::Index => Self::Sections,
Self::Detail => Self::Index,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct DashboardView<'a> {
pub name: &'a str,
pub board: &'a crate::config::BoardConfiguration,
pub editor: Option<&'a str>,
pub viewer: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SettingsPrompt {
AddColumn(String),
NewBoard(String),
RenameBoard {
old: String,
buffer: String,
},
ConfirmDeleteBoard {
name: String,
referencing: usize,
},
SetEditor(String),
SetViewer(String),
AddType(String),
AddAttribute(String),
}
impl SettingsPrompt {
pub fn line(&self) -> String {
match self {
Self::AddColumn(buffer) => format!("New column: {buffer}_"),
Self::NewBoard(buffer) => format!("New board: {buffer}_"),
Self::RenameBoard { buffer, .. } => format!("Rename board to: {buffer}_"),
Self::ConfirmDeleteBoard { name, referencing } => format!(
"Delete board '{name}'? {referencing} {} reference it. [y/N]",
crate::ui::planning::issues_word(*referencing)
),
Self::SetEditor(buffer) => format!("Editor: {buffer}_"),
Self::SetViewer(buffer) => format!("Viewer: {buffer}_"),
Self::AddType(buffer) => format!("New type: {buffer}_"),
Self::AddAttribute(buffer) => format!("New attribute: {buffer}_"),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SettingsModal {
pub selected: usize,
pub detail_selected: usize,
pub prompt: Option<SettingsPrompt>,
pub section: ConfigSection,
pub level: ConfigLevel,
}
impl SettingsModal {
pub fn new() -> Self {
Self::default()
}
pub fn focus(&mut self, level: ConfigLevel) {
self.level = level;
}
pub fn move_level(&mut self, delta: isize) {
let next = self.level.index() as isize + delta;
self.level = match next {
n if n <= 0 => ConfigLevel::Sections,
1 => ConfigLevel::Index,
_ => ConfigLevel::Detail,
};
}
pub fn set_section(&mut self, section: ConfigSection) {
if self.section != section {
self.section = section;
self.selected = 0;
self.detail_selected = 0;
}
}
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_detail(&mut self, delta: isize, total_rows: usize) {
if total_rows == 0 {
self.detail_selected = 0;
return;
}
let next = self.detail_selected as isize + delta;
self.detail_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 move_detail_to(&mut self, row: usize, total_rows: usize) {
if total_rows == 0 {
self.detail_selected = 0;
return;
}
self.detail_selected = row.min(total_rows - 1);
}
pub fn render_body(
&self,
f: &mut Frame,
rect: Rect,
view: DashboardView<'_>,
bindings: &EffectiveBindings,
focused: bool,
) {
let DashboardView {
name: board_name,
board,
editor,
viewer,
} = view;
let context = InputContext::Settings;
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 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.detail_selected {
"> "
} else {
" "
};
let style = row_style(i == self.detail_selected);
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_texts = [
format!("Wrap titles: {}", if wrap_titles { "[x]" } else { "[ ]" }),
format!("Title lines: {title_lines}"),
format!("Order by: {}", order_by.as_str()),
format!("Editor: {}", editor.unwrap_or("(default)")),
format!("Viewer: {viewer}"),
];
for (row, text) in display_texts.into_iter().enumerate() {
let idx = columns.len() + row;
let style = row_style(idx == self.detail_selected);
let cursor = if idx == self.detail_selected {
"> "
} else {
" "
};
rows.push(Line::from(vec![
ratatui::text::Span::styled(cursor, style),
ratatui::text::Span::styled(text, style),
]));
}
if let Some(prompt) = &self.prompt {
rows.push(Line::from(""));
let line = prompt.line();
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!(
"{}: activate · {}: toggle · {}: new column · {} / {}: move column · {}: back · {}: close",
bindings.label(context, crate::keybindings::TuiAction::Submit),
bindings.label(context, crate::keybindings::TuiAction::Toggle),
bindings.label(context, crate::keybindings::TuiAction::AddColumn),
bindings.label(context, crate::keybindings::TuiAction::MoveColumnUp),
bindings.label(context, crate::keybindings::TuiAction::MoveColumnDown),
bindings.label(context, crate::keybindings::TuiAction::MoveLeft),
bindings.label(context, crate::keybindings::TuiAction::Close),
),
Style::default().fg(Color::DarkGray),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {board_name} — columns & display "))
.style(pane_style(focused));
let para = Paragraph::new(rows).block(block).style(Style::default());
f.render_widget(para, rect);
}
}
pub fn dashboard_index_rows(config: &crate::config::Config) -> Vec<(String, String)> {
config
.configs()
.map(|(name, board)| (name.to_string(), board.columns.join(" · ")))
.collect()
}
fn row_style(selected: bool) -> Style {
if selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
}
}
fn pane_style(focused: bool) -> Style {
if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
}
}
pub fn render_config(
app: &App,
f: &mut Frame,
area: Rect,
view: DashboardView<'_>,
cycles: &[PlanningRow],
milestones: &[PlanningRow],
) {
let Some(modal) = app.settings.as_ref() else {
return;
};
if app.milestone_mode {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area);
render_planning_section(app, f, chunks[0], "Milestones", milestones, true);
render_milestone_issues(app, f, chunks[1]);
return;
}
if modal.level == ConfigLevel::Detail && modal.section == ConfigSection::Dashboards {
modal.render_body(f, area, view, &app.bindings, true);
for row in 0..board_rows_len(view.board) {
app.record_region(body_row_rect(area, row), Action::ConfigDetailRow(row));
}
return;
}
render_section_rail(
app,
f,
area,
modal.section,
modal.level == ConfigLevel::Sections,
);
let body = config_body_rect(area);
let body_focused = modal.level == ConfigLevel::Index;
match modal.section {
ConfigSection::Dashboards => {
let rows = dashboard_index_rows(&app.store.config);
render_dashboard_index(app, f, body, &rows, body_focused);
}
ConfigSection::Cycles => {
render_planning_section(app, f, body, "Cycles", cycles, body_focused);
}
ConfigSection::Milestones => {
render_planning_section(app, f, body, "Milestones", milestones, body_focused);
}
ConfigSection::Types => {
let rows = app.store.config.custom_types();
render_list_section(app, f, body, "Types", &rows, body_focused);
}
ConfigSection::CustomAttributes => {
let rows = app.store.config.attributes();
render_list_section(app, f, body, "Attributes", &rows, body_focused);
}
}
}
fn render_dashboard_index(
app: &App,
f: &mut Frame,
body: Rect,
rows: &[(String, String)],
focused: bool,
) {
let selected = app.settings.as_ref().map(|m| m.selected).unwrap_or(0);
let prompt = app.settings.as_ref().and_then(|m| m.prompt.as_ref());
let mut lines: Vec<Line> = Vec::new();
if rows.is_empty() && prompt.is_none() {
lines.push(Line::from("No dashboards configured — press n to add one."));
} else {
for (index, (name, columns)) in rows.iter().enumerate() {
let style = row_style(index == selected);
let cursor = if index == selected { "> " } else { " " };
lines.push(Line::from(vec![
ratatui::text::Span::styled(cursor, style),
ratatui::text::Span::styled(format!("{name:<18}"), style),
ratatui::text::Span::styled(columns.clone(), Style::default().fg(Color::DarkGray)),
]));
}
}
lines.push(Line::from(""));
match prompt {
Some(prompt) => lines.push(Line::from(ratatui::text::Span::styled(
prompt.line(),
Style::default().fg(Color::Cyan),
))),
None => lines.push(Line::from(ratatui::text::Span::styled(
format!(
"{}: open detail · {}: new board · {}: rename board · {}: delete board · {}: switch board · {}: close",
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::MoveRight
),
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::NewBoard
),
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::EditPlanningItem
),
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::Delete
),
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::NextBoard
),
app.bindings
.label(InputContext::Settings, crate::keybindings::TuiAction::Close),
),
Style::default().fg(Color::DarkGray),
))),
}
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Dashboards — {} boards ", rows.len()))
.style(pane_style(focused));
f.render_widget(Paragraph::new(lines).block(block), body);
for index in 0..rows.len() {
app.record_region(body_row_rect(body, index), Action::SettingsRow(index));
}
}
fn render_list_section(
app: &App,
f: &mut Frame,
body: Rect,
title: &str,
rows: &[String],
focused: bool,
) {
let (selected, prompt) = app
.settings
.as_ref()
.map(|modal| (modal.selected, modal.prompt.as_ref()))
.unwrap_or((0, None));
let mut lines: Vec<Line> = Vec::new();
if rows.is_empty() && prompt.is_none() {
lines.push(Line::from(format!(
"No {title} configured — press n to add one."
)));
} else {
for (index, row) in rows.iter().enumerate() {
let cursor = if index == selected { "> " } else { " " };
let style = row_style(index == selected);
lines.push(Line::from(vec![
ratatui::text::Span::styled(cursor, style),
ratatui::text::Span::styled(row.clone(), style),
]));
}
}
lines.push(Line::from(""));
match prompt {
Some(prompt) => lines.push(Line::from(ratatui::text::Span::styled(
prompt.line(),
Style::default().fg(Color::Cyan),
))),
None => lines.push(Line::from(ratatui::text::Span::styled(
format!(
"{}: new · {}: remove selected · {} / {}: level · {}: close",
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::NewBoard
),
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::Delete
),
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::MoveLeft
),
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::MoveRight
),
app.bindings
.label(InputContext::Settings, crate::keybindings::TuiAction::Close),
),
Style::default().fg(Color::DarkGray),
))),
}
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {title} — {} items ", rows.len()))
.style(pane_style(focused));
f.render_widget(Paragraph::new(lines).block(block), body);
for index in 0..rows.len() {
app.record_region(body_row_rect(body, index), Action::ConfigListRow(index));
}
}
fn body_row_rect(body: Rect, row: usize) -> Rect {
Rect {
x: body.x + 1,
y: body.y + 1 + row as u16,
width: body.width.saturating_sub(2),
height: 1,
}
}
fn render_section_rail(app: &App, f: &mut Frame, area: Rect, active: ConfigSection, focused: bool) {
let rail = Rect {
width: SECTION_RAIL_WIDTH.min(area.width),
..area
};
let mut lines: Vec<Line> = Vec::new();
for section in ConfigSection::ALL {
let style = if section == active {
row_style(true)
} else if app.is_hovered(&Action::ConfigSection(section)) {
Style::default()
.fg(Color::Black)
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else {
row_style(false)
};
let cursor = if section == active { "> " } else { " " };
lines.push(Line::from(vec![
ratatui::text::Span::styled(cursor, style),
ratatui::text::Span::styled(section.label(), style),
]));
}
let block = Block::default()
.borders(Borders::ALL)
.title(" Sections ")
.style(pane_style(focused));
f.render_widget(Paragraph::new(lines).block(block), rail);
for section in ConfigSection::ALL {
let hit = Rect {
x: rail.x + 1,
y: rail.y + 1 + section.index() as u16,
width: rail.width.saturating_sub(2),
height: 1,
};
app.record_region(hit, Action::ConfigSection(section));
}
}
fn render_planning_section(
app: &App,
f: &mut Frame,
body: Rect,
label: &str,
rows: &[PlanningRow],
focused: bool,
) {
let selected = app.planning.as_ref().map(|p| p.selected).unwrap_or(0);
let prompt = app.planning.as_ref().and_then(|p| p.prompt.as_ref());
let inner = body.height.saturating_sub(2) as usize;
let view_h = inner.saturating_sub(2);
let mut lines: Vec<Line> = Vec::new();
if rows.is_empty() {
if prompt.is_none() {
lines.push(Line::from(format!(
"No {} yet — press n to create one.",
label.to_lowercase()
)));
}
} else {
lines.extend(rows_lines(rows, selected, view_h));
}
lines.push(Line::from(""));
match prompt {
Some(prompt) => lines.push(prompt_line(prompt)),
None => lines.push(Line::from(ratatui::text::Span::styled(
format!(
"{}: new · {}: open/edit · {}: delete · {}: open detail · {} / {}: level · {}: close",
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::NewBoard
),
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::EditPlanningItem
),
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::Delete
),
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::Submit
),
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::MoveLeft
),
app.bindings.label(
InputContext::Settings,
crate::keybindings::TuiAction::MoveRight
),
app.bindings
.label(InputContext::Settings, crate::keybindings::TuiAction::Close),
),
Style::default().fg(Color::DarkGray),
))),
}
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {label} — {} items ", rows.len()))
.style(pane_style(focused));
f.render_widget(Paragraph::new(lines).block(block), body);
for i in 0..rows.len() {
app.record_region(body_row_rect(body, i), Action::ConfigPlanningRow(i));
}
}
fn render_milestone_issues(app: &App, f: &mut Frame, area: Rect) {
let (title, rows) = match app.active_milestone_name() {
Some(name) => (
format!("issues — {name}"),
crate::ui::issue_table::milestone_rows(&app.store, &name),
),
None => ("issues".to_string(), Vec::new()),
};
crate::ui::issue_table::render(app, f, area, &title, &rows);
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn draw(
modal: &SettingsModal,
board: &crate::config::BoardConfiguration,
w: u16,
h: u16,
) -> Terminal<TestBackend> {
draw_focused(modal, board, w, h, false)
}
fn draw_focused(
modal: &SettingsModal,
board: &crate::config::BoardConfiguration,
w: u16,
h: u16,
focused: bool,
) -> Terminal<TestBackend> {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.expect("built-in bindings are valid");
terminal
.draw(|f| {
modal.render_body(
f,
f.area(),
DashboardView {
name: "default",
board,
editor: None,
viewer: "mdterm (auto)",
},
&bindings,
focused,
);
})
.unwrap();
terminal
}
fn rows_of(terminal: &Terminal<TestBackend>) -> Vec<String> {
let buffer = terminal.backend().buffer();
(0..buffer.area().height)
.map(|y| {
(0..buffer.area().width)
.map(|x| buffer[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect()
})
.collect()
}
fn test_board() -> crate::config::BoardConfiguration {
crate::config::BoardConfiguration {
columns: vec!["todo".to_string(), "done".to_string()],
hidden_columns: vec!["done".to_string()],
..Default::default()
}
}
fn render_smoke(prompt: Option<SettingsPrompt>, w: u16, h: u16) {
let modal = SettingsModal {
detail_selected: 1,
prompt,
..SettingsModal::default()
};
let _ = draw(&modal, &test_board(), w, h);
}
#[test]
fn renders_display_rows_after_columns() {
let modal = SettingsModal {
detail_selected: 2,
prompt: None,
..SettingsModal::default()
};
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()
};
let terminal = draw(&modal, &board, 80, 24);
let buffer = terminal.backend().buffer();
let rows = rows_of(&terminal);
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");
let editor_row = row_of("Editor: (default)").expect("general row shows the editor");
let viewer_row = row_of("Viewer: mdterm (auto)").expect("general row shows the viewer");
assert!(
wrap_row < lines_row
&& lines_row < order_row
&& order_row < editor_row
&& editor_row < viewer_row,
"display and general 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_rename_and_delete_prompts_at_80x24() {
render_smoke(
Some(SettingsPrompt::RenameBoard {
old: "default".into(),
buffer: "ops".into(),
}),
80,
24,
);
render_smoke(
Some(SettingsPrompt::ConfirmDeleteBoard {
name: "default".into(),
referencing: 0,
}),
80,
24,
);
render_smoke(Some(SettingsPrompt::SetEditor("nvim".into())), 80, 24);
render_smoke(Some(SettingsPrompt::SetViewer("less".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 move_detail_clamps_within_rows() {
let mut modal = SettingsModal::new();
modal.move_detail(-1, 4);
assert_eq!(modal.detail_selected, 0);
modal.move_detail(9, 4);
assert_eq!(modal.detail_selected, 3);
modal.move_detail(1, 0);
assert_eq!(modal.detail_selected, 0);
}
#[test]
fn defaults_to_first_row() {
assert_eq!(SettingsModal::new().selected, 0);
assert_eq!(SettingsModal::new().detail_selected, 0);
assert_eq!(SettingsModal::new().prompt, None);
assert_eq!(SettingsModal::new().section, ConfigSection::Dashboards);
assert_eq!(SettingsModal::new().level, ConfigLevel::Sections);
}
#[test]
fn sections_cycle_in_both_directions_and_wrap() {
assert_eq!(ConfigSection::Dashboards.next(), ConfigSection::Cycles);
assert_eq!(ConfigSection::Cycles.next(), ConfigSection::Milestones);
assert_eq!(ConfigSection::Milestones.next(), ConfigSection::Types);
assert_eq!(ConfigSection::Types.next(), ConfigSection::CustomAttributes);
assert_eq!(
ConfigSection::CustomAttributes.next(),
ConfigSection::Dashboards
);
assert_eq!(
ConfigSection::Dashboards.prev(),
ConfigSection::CustomAttributes
);
assert_eq!(ConfigSection::CustomAttributes.prev(), ConfigSection::Types);
assert_eq!(ConfigSection::ALL.len(), 5);
assert_eq!(ConfigSection::CustomAttributes.label(), "Attributes");
}
#[test]
fn set_section_resets_the_cursors_only_on_a_change() {
let mut modal = SettingsModal {
selected: 2,
detail_selected: 1,
..SettingsModal::default()
};
modal.set_section(ConfigSection::Cycles);
assert_eq!(modal.selected, 0);
assert_eq!(modal.detail_selected, 0);
modal.selected = 1;
modal.detail_selected = 2;
modal.set_section(ConfigSection::Cycles);
assert_eq!(
modal.selected, 1,
"re-selecting the same section keeps the index cursor"
);
assert_eq!(
modal.detail_selected, 2,
"re-selecting the same section keeps the detail cursor"
);
}
#[test]
fn board_rows_len_counts_columns_display_and_general_rows() {
let board = crate::config::BoardConfiguration {
columns: vec!["todo".into(), "done".into()],
..Default::default()
};
assert_eq!(board_rows_len(&board), 2 + DISPLAY_ROWS + GENERAL_ROWS);
}
#[test]
fn dashboard_index_rows_list_every_board_with_its_columns() {
let mut config = crate::config::Config::default();
config.kanban.configurations.clear();
config
.kanban
.configurations
.insert("default".into(), crate::config::starter_board());
let ops = crate::config::BoardConfiguration {
columns: vec!["backlog".into(), "shipped".into()],
..Default::default()
};
config.kanban.configurations.insert("ops".into(), ops);
let rows = dashboard_index_rows(&config);
assert_eq!(rows.len(), 2, "every configured board is listed: {rows:?}");
let (_, columns) = rows
.iter()
.find(|(name, _)| name == "ops")
.expect("the `ops` board is listed");
assert!(
columns.contains("backlog") && columns.contains("shipped"),
"the row shows the board's columns: {columns}"
);
}
#[test]
fn level_focus_defaults_to_the_section_rail() {
let modal = SettingsModal::new();
assert_eq!(modal.level, ConfigLevel::Sections);
assert_eq!(ConfigLevel::Sections.index(), 0);
assert_eq!(ConfigLevel::Index.index(), 1);
assert_eq!(ConfigLevel::Detail.index(), 2);
}
#[test]
fn move_level_clamps_at_both_ends() {
let mut modal = SettingsModal::new();
modal.move_level(1);
assert_eq!(modal.level, ConfigLevel::Index);
modal.move_level(1);
assert_eq!(modal.level, ConfigLevel::Detail);
modal.move_level(1);
assert_eq!(modal.level, ConfigLevel::Detail, "the bottom end clamps");
modal.move_level(-1);
assert_eq!(modal.level, ConfigLevel::Index);
modal.move_level(-1);
assert_eq!(modal.level, ConfigLevel::Sections);
modal.move_level(-1);
assert_eq!(modal.level, ConfigLevel::Sections, "the top end clamps");
modal.focus(ConfigLevel::Detail);
assert_eq!(modal.level, ConfigLevel::Detail);
}
#[test]
fn focused_detail_border_is_rendered_distinctly() {
let board = test_board();
let modal = SettingsModal::new();
let focused = draw_focused(&modal, &board, 80, 24, true);
let unfocused = draw_focused(&modal, &board, 80, 24, false);
let rect = focused.backend().buffer().area;
let border = |term: &Terminal<TestBackend>| term.backend().buffer()[(rect.x, rect.y)].fg;
assert_eq!(
border(&focused),
Color::Yellow,
"the focused detail's border is accented"
);
assert_eq!(
border(&unfocused),
Color::DarkGray,
"the unfocused detail's border is dimmed"
);
assert_ne!(border(&focused), border(&unfocused));
}
}