1use ratatui::Frame;
2use ratatui::layout::{Constraint, Direction, Layout, Rect};
3use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
6
7use crate::config::BoardConfiguration;
8use crate::model::Priority;
9use crate::search::{Corpus, StructuredFilter};
10use crate::storage::Store;
11
12use super::widgets;
13
14#[derive(Debug, Clone)]
15pub struct Card {
16 pub number: u32,
17 pub title: String,
18 pub priority: Priority,
19 pub labels: Vec<String>,
20 pub assignee: Option<String>,
21 pub filename: String,
22 pub commit: Option<String>,
23}
24
25#[derive(Debug, Clone)]
26pub struct BoardColumn {
27 pub name: String,
28 pub cards: Vec<Card>,
29}
30
31#[derive(Debug, Clone)]
32pub struct BoardState {
33 pub columns: Vec<BoardColumn>,
37 pub hidden: Vec<BoardColumn>,
41 pub mismatch: BoardColumn,
42 pub show_mismatch: bool,
47 pub active_col: usize,
48 pub selected: usize,
49 pub filter: Option<String>,
50}
51
52impl BoardState {
53 pub fn build(store: &Store, board_config: &BoardConfiguration) -> Self {
54 let issues = store.list().unwrap_or_default();
55 let show_mismatch = board_config.show_mismatch;
56 let hidden_names: Vec<&str> = board_config
57 .hidden_columns
58 .iter()
59 .map(String::as_str)
60 .collect();
61 let mut column_map: Vec<Vec<Card>> =
62 board_config.columns.iter().map(|_| Vec::new()).collect();
63 let mut mismatch_cards = Vec::new();
64
65 for issue in issues {
66 let card = Card {
67 number: issue.number,
68 title: issue.title.clone(),
69 priority: issue.priority.clone(),
70 labels: issue.labels.clone(),
71 assignee: issue.assignee.clone(),
72 filename: issue.filename.clone(),
73 commit: issue.commit.clone(),
74 };
75 if let Some(idx) = board_config
76 .columns
77 .iter()
78 .position(|c| c == issue.status.display())
79 {
80 column_map[idx].push(card);
81 } else {
82 mismatch_cards.push(card);
83 }
84 }
85
86 let mut columns: Vec<BoardColumn> = Vec::new();
87 let mut hidden: Vec<BoardColumn> = Vec::new();
88 for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
89 let col = BoardColumn {
90 name: name.clone(),
91 cards,
92 };
93 if hidden_names.contains(&name.as_str()) {
94 hidden.push(col);
95 } else {
96 columns.push(col);
97 }
98 }
99
100 let mismatch = BoardColumn {
101 name: "Mismatched".to_string(),
102 cards: mismatch_cards,
103 };
104
105 BoardState {
106 columns,
107 hidden,
108 mismatch,
109 show_mismatch,
110 active_col: 0,
111 selected: 0,
112 filter: None,
113 }
114 }
115
116 pub fn build_filtered(
127 board_config: &BoardConfiguration,
128 corpus: &Corpus,
129 query: &str,
130 filter: &StructuredFilter,
131 ) -> Self {
132 let show_mismatch = board_config.show_mismatch;
133 let filtering = !query.is_empty() || filter.has_active_criteria();
134 let hidden_names: Vec<&str> = board_config
135 .hidden_columns
136 .iter()
137 .map(String::as_str)
138 .collect();
139 let mut column_map: Vec<Vec<Card>> =
140 board_config.columns.iter().map(|_| Vec::new()).collect();
141 let mut mismatch_cards = Vec::new();
142
143 for (issue, text) in corpus.issues.iter().zip(corpus.texts.iter()) {
144 if !crate::search::matches(query, filter, text) {
145 continue;
146 }
147 let card = Card {
148 number: issue.number,
149 title: issue.title.clone(),
150 priority: issue.priority.clone(),
151 labels: issue.labels.clone(),
152 assignee: issue.assignee.clone(),
153 filename: issue.filename.clone(),
154 commit: issue.commit.clone(),
155 };
156 if let Some(idx) = board_config
157 .columns
158 .iter()
159 .position(|c| c == issue.status.display())
160 {
161 column_map[idx].push(card);
162 } else {
163 mismatch_cards.push(card);
164 }
165 }
166
167 let mut columns: Vec<BoardColumn> = Vec::new();
168 let mut hidden: Vec<BoardColumn> = Vec::new();
169 for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
170 let col = BoardColumn {
171 name: name.clone(),
172 cards,
173 };
174 if hidden_names.contains(&name.as_str()) {
175 if filtering {
176 columns.push(col);
180 } else {
181 hidden.push(col);
182 }
183 } else {
184 columns.push(col);
185 }
186 }
187
188 BoardState {
189 columns,
190 hidden,
191 mismatch: BoardColumn {
192 name: "Mismatched".to_string(),
193 cards: mismatch_cards,
194 },
195 show_mismatch,
196 active_col: 0,
197 selected: 0,
198 filter: Some(query.to_string()),
199 }
200 }
201
202 pub fn is_empty(&self) -> bool {
204 self.columns.iter().all(|c| c.cards.is_empty())
205 && (!self.show_mismatch || self.mismatch.cards.is_empty())
206 }
207
208 pub fn total_cols(&self) -> usize {
209 if self.show_mismatch {
210 self.columns.len() + 1
211 } else {
212 self.columns.len()
213 }
214 }
215
216 pub fn real_cols(&self) -> usize {
220 self.columns.len()
221 }
222
223 pub fn column_name(&self, idx: usize) -> &str {
224 if idx < self.columns.len() {
225 &self.columns[idx].name
226 } else if idx == self.columns.len() && self.show_mismatch {
227 &self.mismatch.name
228 } else {
229 "Unknown"
230 }
231 }
232
233 pub fn active_column_name(&self) -> &str {
238 let visible = self.columns.len();
239 if self.active_col < visible {
240 &self.columns[self.active_col].name
241 } else {
242 let hidden_idx = self.active_col - visible;
243 if hidden_idx < self.hidden.len() {
244 &self.hidden[hidden_idx].name
245 } else {
246 "Unknown"
247 }
248 }
249 }
250
251 pub fn column_cards(&self, idx: usize) -> &[Card] {
252 if idx < self.columns.len() {
253 &self.columns[idx].cards
254 } else if idx == self.columns.len() && self.show_mismatch {
255 &self.mismatch.cards
256 } else {
257 &[]
258 }
259 }
260
261 pub fn get_selected_card(&self) -> Option<&Card> {
262 self.column_cards(self.active_col).get(self.selected)
263 }
264
265 pub fn move_left(&mut self) {
266 let total = self.real_cols();
267 self.active_col = if total == 0 {
268 0
269 } else if self.active_col >= total {
270 total - 1
271 } else {
272 (self.active_col + total - 1) % total
273 };
274 self.clamp_selection();
275 }
276
277 pub fn move_right(&mut self) {
278 let total = self.real_cols();
279 self.active_col = if total == 0 || self.active_col >= total {
280 0
281 } else {
282 (self.active_col + 1) % total
283 };
284 self.clamp_selection();
285 }
286
287 pub fn move_up(&mut self) {
288 if self.selected > 0 {
289 self.selected -= 1;
290 }
291 }
292
293 pub fn move_down(&mut self) {
294 let cards = self.column_cards(self.active_col);
295 if self.selected + 1 < cards.len() {
296 self.selected += 1;
297 }
298 }
299
300 pub fn clamp_selection(&mut self) {
301 let cards = self.column_cards(self.active_col);
302 if cards.is_empty() {
303 self.selected = 0;
304 } else if self.selected >= cards.len() {
305 self.selected = cards.len() - 1;
306 }
307 }
308
309 pub fn select_column(&mut self, col: usize) {
310 let total = self.real_cols();
311 if total > 0 {
312 self.active_col = col % total;
313 }
314 self.clamp_selection();
315 }
316}
317
318pub fn render(board: &BoardState, f: &mut Frame, area: Rect, status_msg: &Option<String>) {
319 if area.height < 2 || area.width < 4 {
320 return;
321 }
322
323 let status_height: u16 = if status_msg.is_some() { 1 } else { 0 };
324 let board_height = area.height.saturating_sub(status_height);
325 let board_area = Rect {
326 height: board_height,
327 ..area
328 };
329 let status_area = Rect {
330 y: area.y + board_height,
331 height: status_height,
332 ..area
333 };
334
335 let total = board.total_cols();
336 if total == 0 {
337 return;
338 }
339
340 let chunks = Layout::default()
341 .direction(Direction::Horizontal)
342 .constraints(vec![Constraint::Ratio(1, total as u32); total])
343 .split(board_area);
344
345 for i in 0..total {
346 let col = if i < board.columns.len() {
347 &board.columns[i]
348 } else {
349 &board.mismatch
350 };
351
352 let is_active = i == board.active_col;
353 let is_mismatch_col = i == board.columns.len();
354 let is_mismatch_highlight = is_mismatch_col && !col.cards.is_empty();
355
356 let items: Vec<ListItem> = col
357 .cards
358 .iter()
359 .enumerate()
360 .map(|(j, card)| {
361 let line = widgets::card_line(card, chunks[i].width as usize);
362 let style = if is_active && j == board.selected {
363 Style::default()
364 .bg(Color::DarkGray)
365 .add_modifier(Modifier::BOLD)
366 } else if is_mismatch_highlight {
367 Style::default().fg(Color::Red)
368 } else {
369 Style::default()
370 };
371 ListItem::new(line).style(style)
372 })
373 .collect();
374
375 let title = format!("{} ({})", col.name, col.cards.len());
376 let border_style = if is_active {
377 Style::default().fg(Color::Cyan)
378 } else if is_mismatch_highlight {
379 Style::default().fg(Color::Red)
380 } else {
381 Style::default()
382 };
383
384 let block = Block::default()
385 .borders(Borders::ALL)
386 .title(title)
387 .border_style(border_style);
388
389 let list = List::new(items).block(block);
390
391 if is_active {
392 let mut state = ListState::default();
393 if !col.cards.is_empty() {
394 state.select(Some(board.selected));
395 }
396 f.render_stateful_widget(list, chunks[i], &mut state);
397 } else {
398 f.render_widget(list, chunks[i]);
399 }
400 }
401
402 if let Some(msg) = status_msg {
403 let para = Paragraph::new(Line::from(Span::raw(msg.clone())))
404 .style(Style::default().fg(Color::Yellow));
405 f.render_widget(para, status_area);
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::config::Config;
413
414 #[test]
415 fn total_cols_includes_mismatch() {
416 let board = BoardState {
417 columns: vec![
418 BoardColumn {
419 name: "a".into(),
420 cards: vec![],
421 },
422 BoardColumn {
423 name: "b".into(),
424 cards: vec![],
425 },
426 ],
427 mismatch: BoardColumn {
428 name: "Mismatched".into(),
429 cards: vec![],
430 },
431 active_col: 0,
432 selected: 0,
433 filter: None,
434 hidden: vec![],
435 show_mismatch: true,
436 };
437 assert_eq!(board.total_cols(), 3);
438 }
439
440 #[test]
441 fn column_name_mismatch_index() {
442 let board = BoardState {
443 columns: vec![BoardColumn {
444 name: "todo".into(),
445 cards: vec![],
446 }],
447 mismatch: BoardColumn {
448 name: "Mismatched".into(),
449 cards: vec![],
450 },
451 active_col: 0,
452 selected: 0,
453 filter: None,
454 hidden: vec![],
455 show_mismatch: true,
456 };
457 assert_eq!(board.column_name(0), "todo");
458 assert_eq!(board.column_name(1), "Mismatched");
459 }
460
461 #[test]
462 fn move_left_wraps_around() {
463 let mut board = BoardState {
464 columns: vec![
465 BoardColumn {
466 name: "a".into(),
467 cards: vec![],
468 },
469 BoardColumn {
470 name: "b".into(),
471 cards: vec![],
472 },
473 ],
474 hidden: vec![],
475 mismatch: BoardColumn {
476 name: "Mismatched".into(),
477 cards: vec![],
478 },
479 active_col: 0,
480 selected: 0,
481 filter: None,
482 show_mismatch: true,
483 };
484 board.move_left();
485 assert_eq!(board.active_col, 1);
486 }
487
488 #[test]
489 fn move_right_wraps_around() {
490 let mut board = BoardState {
491 columns: vec![
492 BoardColumn {
493 name: "a".into(),
494 cards: vec![],
495 },
496 BoardColumn {
497 name: "b".into(),
498 cards: vec![],
499 },
500 ],
501 mismatch: BoardColumn {
502 name: "Mismatched".into(),
503 cards: vec![],
504 },
505 active_col: 2,
506 selected: 0,
507 filter: None,
508 hidden: vec![],
509 show_mismatch: true,
510 };
511 board.move_right();
512 assert_eq!(board.active_col, 0);
513 }
514
515 #[test]
516 fn clamp_selection_empty_column() {
517 let mut board = BoardState {
518 columns: vec![BoardColumn {
519 name: "a".into(),
520 cards: vec![],
521 }],
522 mismatch: BoardColumn {
523 name: "Mismatched".into(),
524 cards: vec![],
525 },
526 active_col: 0,
527 selected: 5,
528 filter: None,
529 hidden: vec![],
530 show_mismatch: true,
531 };
532 board.clamp_selection();
533 assert_eq!(board.selected, 0);
534 }
535
536 #[test]
537 fn clamp_selection_single_card() {
538 let mut board = BoardState {
539 columns: vec![BoardColumn {
540 name: "a".into(),
541 cards: vec![Card {
542 number: 1,
543 title: "X".into(),
544 priority: crate::model::Priority::Medium,
545 labels: vec![],
546 assignee: None,
547 filename: "x.md".into(),
548 commit: None,
549 }],
550 }],
551 mismatch: BoardColumn {
552 name: "Mismatched".into(),
553 cards: vec![],
554 },
555 active_col: 0,
556 selected: 5,
557 filter: None,
558 hidden: vec![],
559 show_mismatch: true,
560 };
561 board.clamp_selection();
562 assert_eq!(board.selected, 0);
563 }
564
565 #[test]
566 fn move_up_clamps_at_top() {
567 let mut board = BoardState {
568 columns: vec![BoardColumn {
569 name: "a".into(),
570 cards: vec![Card {
571 number: 1,
572 title: "X".into(),
573 priority: crate::model::Priority::Medium,
574 labels: vec![],
575 assignee: None,
576 filename: "x.md".into(),
577 commit: None,
578 }],
579 }],
580 mismatch: BoardColumn {
581 name: "Mismatched".into(),
582 cards: vec![],
583 },
584 active_col: 0,
585 selected: 0,
586 filter: None,
587 hidden: vec![],
588 show_mismatch: true,
589 };
590 board.move_up();
591 assert_eq!(board.selected, 0);
592 }
593
594 #[test]
595 fn move_down_clamps_at_bottom() {
596 let mut board = BoardState {
597 columns: vec![BoardColumn {
598 name: "a".into(),
599 cards: vec![Card {
600 number: 1,
601 title: "X".into(),
602 priority: crate::model::Priority::Medium,
603 labels: vec![],
604 assignee: None,
605 filename: "x.md".into(),
606 commit: None,
607 }],
608 }],
609 mismatch: BoardColumn {
610 name: "Mismatched".into(),
611 cards: vec![],
612 },
613 active_col: 0,
614 selected: 0,
615 filter: None,
616 hidden: vec![],
617 show_mismatch: true,
618 };
619 board.move_down();
620 assert_eq!(board.selected, 0);
621 }
622
623 #[test]
624 fn render_smoke_does_not_panic_on_tiny_frame() {
625 use ratatui::Terminal;
626 use ratatui::backend::TestBackend;
627
628 let backend = TestBackend::new(5, 3);
629 let mut terminal = Terminal::new(backend).unwrap();
630 let board = BoardState {
631 columns: vec![],
632 mismatch: BoardColumn {
633 name: "Mismatched".into(),
634 cards: vec![],
635 },
636 active_col: 0,
637 selected: 0,
638 filter: None,
639 hidden: vec![],
640 show_mismatch: true,
641 };
642 terminal
643 .draw(|f| render(&board, f, f.area(), &None))
644 .unwrap();
645 }
646
647 #[test]
648 fn render_smoke_full_board() {
649 use ratatui::Terminal;
650 use ratatui::backend::TestBackend;
651
652 let backend = TestBackend::new(80, 24);
653 let mut terminal = Terminal::new(backend).unwrap();
654 let board = BoardState {
655 columns: vec![
656 BoardColumn {
657 name: "todo".into(),
658 cards: vec![],
659 },
660 BoardColumn {
661 name: "done".into(),
662 cards: vec![],
663 },
664 ],
665 mismatch: BoardColumn {
666 name: "Mismatched".into(),
667 cards: vec![],
668 },
669 active_col: 0,
670 selected: 0,
671 filter: None,
672 hidden: vec![],
673 show_mismatch: true,
674 };
675 terminal
676 .draw(|f| render(&board, f, f.area(), &Some("test msg".into())))
677 .unwrap();
678 }
679
680 #[test]
681 fn build_filtered_keeps_only_fuzzy_matches() {
682 use crate::model::Issue;
683 use crate::search::Corpus;
684
685 let to_issue = |number: u32, title: &str| Issue {
686 number,
687 title: title.to_string(),
688 status: crate::model::Status::new("todo"),
689 labels: vec![],
690 priority: crate::model::Priority::Medium,
691 assignee: None,
692 estimate: None,
693 commit: None,
694 gitlab_id: None,
695 linear_id: None,
696 mission: None,
697 task: None,
698 created: "2026-01-01".into(),
699 updated: "2026-01-01".into(),
700 filename: format!("2026-01-01-{number}.md"),
701 body: String::new(),
702 };
703 let corpus = Corpus {
704 issues: vec![to_issue(1, "Fix login bug"), to_issue(2, "Polish exports")],
705 texts: vec!["Fix login bug".to_string(), "Polish exports".to_string()],
706 };
707 let config = Config::default();
708 let board_config = config.default_config();
709
710 let board =
711 BoardState::build_filtered(board_config, &corpus, "log", &StructuredFilter::default());
712 assert_eq!(board.columns.len(), board_config.columns.len());
713 assert_eq!(board.column_cards(0).len(), 1);
714 assert_eq!(board.column_cards(0)[0].title, "Fix login bug");
715 assert_eq!(board.filter.as_deref(), Some("log"));
716
717 let none =
718 BoardState::build_filtered(board_config, &corpus, "zzw", &StructuredFilter::default());
719 assert!(none.is_empty());
720
721 let empty =
722 BoardState::build_filtered(board_config, &corpus, "", &StructuredFilter::default());
723 assert_eq!(empty.column_cards(0).len(), 2, "empty query keeps all");
724 assert!(empty.filter.is_some());
725 }
726
727 #[test]
728 fn is_empty_true_only_with_no_cards_anywhere() {
729 let board = BoardState {
730 columns: vec![BoardColumn {
731 name: "a".into(),
732 cards: vec![],
733 }],
734 mismatch: BoardColumn {
735 name: "Mismatched".into(),
736 cards: vec![],
737 },
738 active_col: 0,
739 selected: 0,
740 filter: None,
741 hidden: vec![],
742 show_mismatch: true,
743 };
744 assert!(board.is_empty());
745
746 let populated = BoardState {
747 columns: vec![BoardColumn {
748 name: "a".into(),
749 cards: vec![Card {
750 number: 1,
751 title: "X".into(),
752 priority: crate::model::Priority::Medium,
753 labels: vec![],
754 assignee: None,
755 filename: "x.md".into(),
756 commit: None,
757 }],
758 }],
759 mismatch: BoardColumn {
760 name: "Mismatched".into(),
761 cards: vec![],
762 },
763 active_col: 0,
764 selected: 0,
765 filter: None,
766 hidden: vec![],
767 show_mismatch: true,
768 };
769 assert!(!populated.is_empty());
770 }
771
772 #[test]
773 fn build_splits_hidden_columns_out_of_visible_surface() {
774 use crate::config::{BoardConfiguration, Config};
775
776 let mut config = Config::default();
777 config.kanban.configurations.insert(
778 "default".to_string(),
779 BoardConfiguration {
780 columns: vec!["todo".into(), "done".into()],
781 hidden_columns: vec!["done".into()],
782 ..BoardConfiguration::default()
783 },
784 );
785 let store = crate::storage::Store::open(&config).unwrap();
786 let board = BoardState::build(&store, config.default_config());
787 assert_eq!(board.columns.len(), 1);
788 assert_eq!(board.columns[0].name, "todo");
789 assert_eq!(board.hidden.len(), 1);
790 assert_eq!(board.hidden[0].name, "done");
791 assert_eq!(board.total_cols(), 1);
792 }
793
794 #[test]
795 fn move_left_skips_hidden_column() {
796 let mut board = BoardState {
797 columns: vec![BoardColumn {
798 name: "visible-a".into(),
799 cards: vec![],
800 }],
801 hidden: vec![BoardColumn {
802 name: "hidden-b".into(),
803 cards: vec![],
804 }],
805 mismatch: BoardColumn {
806 name: "Mismatched".into(),
807 cards: vec![],
808 },
809 active_col: 0,
810 selected: 0,
811 filter: None,
812 show_mismatch: true,
813 };
814 board.move_left();
815 assert_eq!(board.active_col, 0, "single visible column wraps to itself");
816 }
817
818 #[test]
819 fn move_right_skips_hidden_column() {
820 let mut board = BoardState {
821 columns: vec![BoardColumn {
822 name: "visible-a".into(),
823 cards: vec![],
824 }],
825 hidden: vec![BoardColumn {
826 name: "hidden-b".into(),
827 cards: vec![],
828 }],
829 mismatch: BoardColumn {
830 name: "Mismatched".into(),
831 cards: vec![],
832 },
833 active_col: 0,
834 selected: 0,
835 filter: None,
836 show_mismatch: true,
837 };
838 board.move_right();
839 assert_eq!(board.active_col, 0, "single visible column wraps to itself");
840 }
841
842 #[test]
843 fn all_columns_hidden_board_is_displayable_and_navigation_safe() {
844 let mut board = BoardState {
845 columns: vec![],
846 hidden: vec![BoardColumn {
847 name: "only-hidden".into(),
848 cards: vec![],
849 }],
850 mismatch: BoardColumn {
851 name: "Mismatched".into(),
852 cards: vec![],
853 },
854 active_col: 0,
855 selected: 0,
856 filter: None,
857 show_mismatch: false,
858 };
859 assert_eq!(board.total_cols(), 0);
860 board.move_left();
861 board.move_right();
862 assert_eq!(board.active_col, 0, "zero-column board must not panic");
863 let board2 = BoardState {
864 columns: vec![],
865 hidden: vec![],
866 mismatch: BoardColumn {
867 name: "Mismatched".into(),
868 cards: vec![],
869 },
870 active_col: 0,
871 selected: 0,
872 filter: None,
873 show_mismatch: false,
874 };
875 assert_eq!(board2.total_cols(), 0);
876 }
877}