use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use crate::config::BoardConfiguration;
use crate::model::Priority;
use crate::search::{Corpus, StructuredFilter};
use crate::storage::Store;
use super::widgets;
#[derive(Debug, Clone)]
pub struct Card {
pub number: u32,
pub title: String,
pub priority: Priority,
pub labels: Vec<String>,
pub assignee: Option<String>,
pub filename: String,
pub commit: Option<String>,
}
#[derive(Debug, Clone)]
pub struct BoardColumn {
pub name: String,
pub cards: Vec<Card>,
}
#[derive(Debug, Clone)]
pub struct BoardState {
pub columns: Vec<BoardColumn>,
pub hidden: Vec<BoardColumn>,
pub mismatch: BoardColumn,
pub show_mismatch: bool,
pub active_col: usize,
pub selected: usize,
pub filter: Option<String>,
}
impl BoardState {
pub fn build(store: &Store, board_config: &BoardConfiguration) -> Self {
let issues = store.list().unwrap_or_default();
let show_mismatch = board_config.show_mismatch;
let hidden_names: Vec<&str> = board_config
.hidden_columns
.iter()
.map(String::as_str)
.collect();
let mut column_map: Vec<Vec<Card>> =
board_config.columns.iter().map(|_| Vec::new()).collect();
let mut mismatch_cards = Vec::new();
for issue in issues {
let card = Card {
number: issue.number,
title: issue.title.clone(),
priority: issue.priority.clone(),
labels: issue.labels.clone(),
assignee: issue.assignee.clone(),
filename: issue.filename.clone(),
commit: issue.commit.clone(),
};
if let Some(idx) = board_config
.columns
.iter()
.position(|c| c == issue.status.display())
{
column_map[idx].push(card);
} else {
mismatch_cards.push(card);
}
}
let mut columns: Vec<BoardColumn> = Vec::new();
let mut hidden: Vec<BoardColumn> = Vec::new();
for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
let col = BoardColumn {
name: name.clone(),
cards,
};
if hidden_names.contains(&name.as_str()) {
hidden.push(col);
} else {
columns.push(col);
}
}
let mismatch = BoardColumn {
name: "Mismatched".to_string(),
cards: mismatch_cards,
};
BoardState {
columns,
hidden,
mismatch,
show_mismatch,
active_col: 0,
selected: 0,
filter: None,
}
}
pub fn build_filtered(
board_config: &BoardConfiguration,
corpus: &Corpus,
query: &str,
filter: &StructuredFilter,
) -> Self {
let show_mismatch = board_config.show_mismatch;
let filtering = !query.is_empty() || filter.has_active_criteria();
let hidden_names: Vec<&str> = board_config
.hidden_columns
.iter()
.map(String::as_str)
.collect();
let mut column_map: Vec<Vec<Card>> =
board_config.columns.iter().map(|_| Vec::new()).collect();
let mut mismatch_cards = Vec::new();
for (issue, text) in corpus.issues.iter().zip(corpus.texts.iter()) {
if !crate::search::matches(query, filter, text) {
continue;
}
let card = Card {
number: issue.number,
title: issue.title.clone(),
priority: issue.priority.clone(),
labels: issue.labels.clone(),
assignee: issue.assignee.clone(),
filename: issue.filename.clone(),
commit: issue.commit.clone(),
};
if let Some(idx) = board_config
.columns
.iter()
.position(|c| c == issue.status.display())
{
column_map[idx].push(card);
} else {
mismatch_cards.push(card);
}
}
let mut columns: Vec<BoardColumn> = Vec::new();
let mut hidden: Vec<BoardColumn> = Vec::new();
for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
let col = BoardColumn {
name: name.clone(),
cards,
};
if hidden_names.contains(&name.as_str()) {
if filtering {
columns.push(col);
} else {
hidden.push(col);
}
} else {
columns.push(col);
}
}
BoardState {
columns,
hidden,
mismatch: BoardColumn {
name: "Mismatched".to_string(),
cards: mismatch_cards,
},
show_mismatch,
active_col: 0,
selected: 0,
filter: Some(query.to_string()),
}
}
pub fn is_empty(&self) -> bool {
self.columns.iter().all(|c| c.cards.is_empty())
&& (!self.show_mismatch || self.mismatch.cards.is_empty())
}
pub fn total_cols(&self) -> usize {
if self.show_mismatch {
self.columns.len() + 1
} else {
self.columns.len()
}
}
pub fn real_cols(&self) -> usize {
self.columns.len()
}
pub fn column_name(&self, idx: usize) -> &str {
if idx < self.columns.len() {
&self.columns[idx].name
} else if idx == self.columns.len() && self.show_mismatch {
&self.mismatch.name
} else {
"Unknown"
}
}
pub fn active_column_name(&self) -> &str {
let visible = self.columns.len();
if self.active_col < visible {
&self.columns[self.active_col].name
} else {
let hidden_idx = self.active_col - visible;
if hidden_idx < self.hidden.len() {
&self.hidden[hidden_idx].name
} else {
"Unknown"
}
}
}
pub fn column_cards(&self, idx: usize) -> &[Card] {
if idx < self.columns.len() {
&self.columns[idx].cards
} else if idx == self.columns.len() && self.show_mismatch {
&self.mismatch.cards
} else {
&[]
}
}
pub fn get_selected_card(&self) -> Option<&Card> {
self.column_cards(self.active_col).get(self.selected)
}
pub fn move_left(&mut self) {
let total = self.real_cols();
self.active_col = if total == 0 {
0
} else if self.active_col >= total {
total - 1
} else {
(self.active_col + total - 1) % total
};
self.clamp_selection();
}
pub fn move_right(&mut self) {
let total = self.real_cols();
self.active_col = if total == 0 || self.active_col >= total {
0
} else {
(self.active_col + 1) % total
};
self.clamp_selection();
}
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
pub fn move_down(&mut self) {
let cards = self.column_cards(self.active_col);
if self.selected + 1 < cards.len() {
self.selected += 1;
}
}
pub fn clamp_selection(&mut self) {
let cards = self.column_cards(self.active_col);
if cards.is_empty() {
self.selected = 0;
} else if self.selected >= cards.len() {
self.selected = cards.len() - 1;
}
}
pub fn select_column(&mut self, col: usize) {
let total = self.real_cols();
if total > 0 {
self.active_col = col % total;
}
self.clamp_selection();
}
}
pub fn render(board: &BoardState, f: &mut Frame, area: Rect, status_msg: &Option<String>) {
if area.height < 2 || area.width < 4 {
return;
}
let status_height: u16 = if status_msg.is_some() { 1 } else { 0 };
let board_height = area.height.saturating_sub(status_height);
let board_area = Rect {
height: board_height,
..area
};
let status_area = Rect {
y: area.y + board_height,
height: status_height,
..area
};
let total = board.total_cols();
if total == 0 {
return;
}
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![Constraint::Ratio(1, total as u32); total])
.split(board_area);
for i in 0..total {
let col = if i < board.columns.len() {
&board.columns[i]
} else {
&board.mismatch
};
let is_active = i == board.active_col;
let is_mismatch_col = i == board.columns.len();
let is_mismatch_highlight = is_mismatch_col && !col.cards.is_empty();
let items: Vec<ListItem> = col
.cards
.iter()
.enumerate()
.map(|(j, card)| {
let line = widgets::card_line(card, chunks[i].width as usize);
let style = if is_active && j == board.selected {
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else if is_mismatch_highlight {
Style::default().fg(Color::Red)
} else {
Style::default()
};
ListItem::new(line).style(style)
})
.collect();
let title = format!("{} ({})", col.name, col.cards.len());
let border_style = if is_active {
Style::default().fg(Color::Cyan)
} else if is_mismatch_highlight {
Style::default().fg(Color::Red)
} else {
Style::default()
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style);
let list = List::new(items).block(block);
if is_active {
let mut state = ListState::default();
if !col.cards.is_empty() {
state.select(Some(board.selected));
}
f.render_stateful_widget(list, chunks[i], &mut state);
} else {
f.render_widget(list, chunks[i]);
}
}
if let Some(msg) = status_msg {
let para = Paragraph::new(Line::from(Span::raw(msg.clone())))
.style(Style::default().fg(Color::Yellow));
f.render_widget(para, status_area);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
#[test]
fn total_cols_includes_mismatch() {
let board = BoardState {
columns: vec![
BoardColumn {
name: "a".into(),
cards: vec![],
},
BoardColumn {
name: "b".into(),
cards: vec![],
},
],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
assert_eq!(board.total_cols(), 3);
}
#[test]
fn column_name_mismatch_index() {
let board = BoardState {
columns: vec![BoardColumn {
name: "todo".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
assert_eq!(board.column_name(0), "todo");
assert_eq!(board.column_name(1), "Mismatched");
}
#[test]
fn move_left_wraps_around() {
let mut board = BoardState {
columns: vec![
BoardColumn {
name: "a".into(),
cards: vec![],
},
BoardColumn {
name: "b".into(),
cards: vec![],
},
],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
show_mismatch: true,
};
board.move_left();
assert_eq!(board.active_col, 1);
}
#[test]
fn move_right_wraps_around() {
let mut board = BoardState {
columns: vec![
BoardColumn {
name: "a".into(),
cards: vec![],
},
BoardColumn {
name: "b".into(),
cards: vec![],
},
],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 2,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
board.move_right();
assert_eq!(board.active_col, 0);
}
#[test]
fn clamp_selection_empty_column() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 5,
filter: None,
hidden: vec![],
show_mismatch: true,
};
board.clamp_selection();
assert_eq!(board.selected, 0);
}
#[test]
fn clamp_selection_single_card() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![Card {
number: 1,
title: "X".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
}],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 5,
filter: None,
hidden: vec![],
show_mismatch: true,
};
board.clamp_selection();
assert_eq!(board.selected, 0);
}
#[test]
fn move_up_clamps_at_top() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![Card {
number: 1,
title: "X".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
}],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
board.move_up();
assert_eq!(board.selected, 0);
}
#[test]
fn move_down_clamps_at_bottom() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![Card {
number: 1,
title: "X".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
}],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
board.move_down();
assert_eq!(board.selected, 0);
}
#[test]
fn render_smoke_does_not_panic_on_tiny_frame() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(5, 3);
let mut terminal = Terminal::new(backend).unwrap();
let board = BoardState {
columns: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
terminal
.draw(|f| render(&board, f, f.area(), &None))
.unwrap();
}
#[test]
fn render_smoke_full_board() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let board = BoardState {
columns: vec![
BoardColumn {
name: "todo".into(),
cards: vec![],
},
BoardColumn {
name: "done".into(),
cards: vec![],
},
],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
terminal
.draw(|f| render(&board, f, f.area(), &Some("test msg".into())))
.unwrap();
}
#[test]
fn build_filtered_keeps_only_fuzzy_matches() {
use crate::model::Issue;
use crate::search::Corpus;
let to_issue = |number: u32, title: &str| Issue {
number,
title: title.to_string(),
status: crate::model::Status::new("todo"),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: format!("2026-01-01-{number}.md"),
body: String::new(),
};
let corpus = Corpus {
issues: vec![to_issue(1, "Fix login bug"), to_issue(2, "Polish exports")],
texts: vec!["Fix login bug".to_string(), "Polish exports".to_string()],
};
let config = Config::default();
let board_config = config.default_config();
let board =
BoardState::build_filtered(board_config, &corpus, "log", &StructuredFilter::default());
assert_eq!(board.columns.len(), board_config.columns.len());
assert_eq!(board.column_cards(0).len(), 1);
assert_eq!(board.column_cards(0)[0].title, "Fix login bug");
assert_eq!(board.filter.as_deref(), Some("log"));
let none =
BoardState::build_filtered(board_config, &corpus, "zzw", &StructuredFilter::default());
assert!(none.is_empty());
let empty =
BoardState::build_filtered(board_config, &corpus, "", &StructuredFilter::default());
assert_eq!(empty.column_cards(0).len(), 2, "empty query keeps all");
assert!(empty.filter.is_some());
}
#[test]
fn is_empty_true_only_with_no_cards_anywhere() {
let board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
assert!(board.is_empty());
let populated = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![Card {
number: 1,
title: "X".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
}],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
assert!(!populated.is_empty());
}
#[test]
fn build_splits_hidden_columns_out_of_visible_surface() {
use crate::config::{BoardConfiguration, Config};
let mut config = Config::default();
config.kanban.configurations.insert(
"default".to_string(),
BoardConfiguration {
columns: vec!["todo".into(), "done".into()],
hidden_columns: vec!["done".into()],
..BoardConfiguration::default()
},
);
let store = crate::storage::Store::open(&config).unwrap();
let board = BoardState::build(&store, config.default_config());
assert_eq!(board.columns.len(), 1);
assert_eq!(board.columns[0].name, "todo");
assert_eq!(board.hidden.len(), 1);
assert_eq!(board.hidden[0].name, "done");
assert_eq!(board.total_cols(), 1);
}
#[test]
fn move_left_skips_hidden_column() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "visible-a".into(),
cards: vec![],
}],
hidden: vec![BoardColumn {
name: "hidden-b".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
show_mismatch: true,
};
board.move_left();
assert_eq!(board.active_col, 0, "single visible column wraps to itself");
}
#[test]
fn move_right_skips_hidden_column() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "visible-a".into(),
cards: vec![],
}],
hidden: vec![BoardColumn {
name: "hidden-b".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
show_mismatch: true,
};
board.move_right();
assert_eq!(board.active_col, 0, "single visible column wraps to itself");
}
#[test]
fn all_columns_hidden_board_is_displayable_and_navigation_safe() {
let mut board = BoardState {
columns: vec![],
hidden: vec![BoardColumn {
name: "only-hidden".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
show_mismatch: false,
};
assert_eq!(board.total_cols(), 0);
board.move_left();
board.move_right();
assert_eq!(board.active_col, 0, "zero-column board must not panic");
let board2 = BoardState {
columns: vec![],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
show_mismatch: false,
};
assert_eq!(board2.total_cols(), 0);
}
}