1use ratatui::Frame;
2use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
3use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
6use std::collections::HashSet;
7
8use crate::config::BoardConfiguration;
9use crate::model::{Issue, Priority};
10use crate::search::{Corpus, StructuredFilter};
11use crate::storage::Store;
12
13use super::widgets;
14
15#[derive(Debug, Clone)]
16struct GroupSection {
17 name: String,
18 start: usize,
19 end: usize,
20 done: usize,
21 total: usize,
22}
23
24#[derive(Debug, Clone)]
25pub struct Card {
26 pub number: u32,
27 pub title: String,
28 pub priority: Priority,
29 pub labels: Vec<String>,
30 pub assignee: Option<String>,
31 pub filename: String,
32 pub commit: Option<String>,
33 pub created: String,
34 pub due_date: Option<String>,
35 pub status: String,
36 pub category: Option<String>,
48 pub category_color: Option<crate::workflow::ColorHint>,
51 pub done: bool,
58 pub cycle: Option<String>,
59 pub milestone: Option<String>,
60 pub blocked: Option<String>,
66 pub children: Option<String>,
75 pub parent: Option<u32>,
80 pub depth: u8,
86 pub archived: bool,
93 pub related: usize,
100}
101
102impl crate::config::OrderBy {
103 pub fn sort(&self, cards: &mut [Card]) {
109 match self {
110 crate::config::OrderBy::CreatedAtDesc => {
111 cards.sort_by(|a, b| b.created.cmp(&a.created))
112 }
113 crate::config::OrderBy::CreatedAtAsc => cards.sort_by(|a, b| a.created.cmp(&b.created)),
114 crate::config::OrderBy::None => {}
115 }
116 }
117}
118
119fn sort_by_default_order(board_config: &BoardConfiguration, cards: &mut [Card]) {
120 match board_config.order_by {
121 crate::config::OrderBy::CreatedAtDesc => {
122 cards.sort_by(|a, b| {
123 b.created
124 .cmp(&a.created)
125 .then_with(|| a.filename.cmp(&b.filename))
126 });
127 }
128 crate::config::OrderBy::CreatedAtAsc => {
129 cards.sort_by(|a, b| {
130 a.created
131 .cmp(&b.created)
132 .then_with(|| a.filename.cmp(&b.filename))
133 });
134 }
135 crate::config::OrderBy::None => {
136 cards.sort_by(|a, b| a.filename.cmp(&b.filename));
137 }
138 }
139}
140
141fn sort_cards(board_config: &BoardConfiguration, cards: &mut [Card]) {
142 if board_config.card_order.is_empty() {
143 sort_by_default_order(board_config, cards);
144 return;
145 }
146
147 let mut baseline = cards.to_vec();
148 sort_by_default_order(board_config, &mut baseline);
149
150 let manual_cards: Vec<Card> = board_config
151 .card_order
152 .iter()
153 .filter_map(|filename| {
154 baseline
155 .iter()
156 .find(|card| card.filename == *filename)
157 .cloned()
158 })
159 .collect();
160 let touched: HashSet<String> = manual_cards
161 .iter()
162 .map(|card| card.filename.clone())
163 .collect();
164 let mut manual_iter = manual_cards.into_iter();
165 let mut ordered = Vec::with_capacity(baseline.len());
166
167 for card in baseline {
168 if touched.contains(card.filename.as_str()) {
169 if let Some(manual_card) = manual_iter.next() {
170 ordered.push(manual_card);
171 } else {
172 ordered.push(card);
173 }
174 } else {
175 ordered.push(card);
176 }
177 }
178
179 cards.clone_from_slice(&ordered);
180}
181
182fn nest_children(cards: &mut Vec<Card>) {
199 let count = cards.len();
200 if count == 0 {
201 return;
202 }
203 let position: std::collections::HashMap<u32, usize> = cards
204 .iter()
205 .enumerate()
206 .map(|(i, card)| (card.number, i))
207 .collect();
208 let is_root = |index: usize| match cards[index].parent {
211 None => true,
212 Some(parent) => !position.contains_key(&parent),
213 };
214
215 let mut adopted: Vec<Vec<usize>> = vec![Vec::new(); count];
216 let mut top_level: Vec<usize> = Vec::with_capacity(count);
217 for (index, card) in cards.iter().enumerate() {
218 match card
219 .parent
220 .and_then(|parent| position.get(&parent).copied())
221 {
222 Some(parent) if is_root(parent) => adopted[parent].push(index),
223 _ => top_level.push(index),
224 }
225 }
226
227 let mut ordered: Vec<Card> = Vec::with_capacity(count);
228 for index in top_level {
229 let mut card = cards[index].clone();
230 card.depth = 0;
231 ordered.push(card);
232 for &child in &adopted[index] {
233 let mut child_card = cards[child].clone();
234 child_card.depth = 1;
235 ordered.push(child_card);
236 }
237 }
238 *cards = ordered;
239}
240
241pub(crate) fn sparse_card_order(board_config: &BoardConfiguration, cards: &[Card]) -> Vec<String> {
242 let mut baseline = cards.to_vec();
243 sort_by_default_order(board_config, &mut baseline);
244 baseline
245 .iter()
246 .zip(cards.iter())
247 .filter(|(baseline_card, current_card)| baseline_card.filename != current_card.filename)
248 .map(|(_, current_card)| current_card.filename.clone())
249 .collect()
250}
251
252#[derive(Debug, Clone)]
253pub struct BoardColumn {
254 pub name: String,
255 pub cards: Vec<Card>,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum HitTarget {
263 Card { col: usize, row: usize },
264 Column { col: usize },
265}
266
267#[derive(Debug, Clone)]
268pub struct BoardState {
269 pub columns: Vec<BoardColumn>,
273 pub hidden: Vec<BoardColumn>,
277 pub mismatch: BoardColumn,
278 pub show_mismatch: bool,
283 pub active_col: usize,
284 pub selected: usize,
285 pub hover: Option<(usize, usize)>,
289 pub hover_col: Option<usize>,
294 pub filter: Option<String>,
295}
296
297pub fn truncate_to_cells(msg: &str, budget: usize) -> String {
305 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
306
307 const ELLIPSIS: char = '\u{2026}';
308 if budget == 0 {
309 return String::new();
310 }
311 if UnicodeWidthStr::width(msg) <= budget {
312 return msg.to_string();
313 }
314
315 let marker_width = UnicodeWidthChar::width(ELLIPSIS).unwrap_or(1);
319 let mut out = String::new();
320 let mut used = 0usize;
321 for ch in msg.chars() {
322 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
323 if used + w + marker_width > budget {
324 break;
325 }
326 out.push(ch);
327 used += w;
328 }
329 out.push(ELLIPSIS);
330 out
331}
332
333fn blocked_reasons(
338 issues: &[crate::model::Issue],
339 done_column: &str,
340) -> std::collections::BTreeMap<u32, String> {
341 let views: Vec<crate::relationships::IssueView<'_>> =
342 issues.iter().map(crate::model::Issue::issue_view).collect();
343 let graph = crate::relationships::RelationshipGraph::new(&views, done_column);
344 issues
345 .iter()
346 .filter_map(|issue| {
347 crate::commands::blocked_note(&graph.blocked_state(issue.number))
348 .map(|note| (issue.number, note))
349 })
350 .collect()
351}
352
353fn child_markers(
360 issues: &[crate::model::Issue],
361 done_column: &str,
362) -> std::collections::BTreeMap<u32, String> {
363 let views: Vec<crate::relationships::IssueView<'_>> =
364 issues.iter().map(crate::model::Issue::issue_view).collect();
365 let index = crate::hierarchy::HierarchyIndex::build(&views);
366 issues
367 .iter()
368 .filter_map(|issue| {
369 let facts =
370 crate::commands::parent::facts_for(&index, issues, issue.number, done_column);
371 facts.has_children().then(|| {
372 (
373 issue.number,
374 format!(
375 "{} {}/{}",
376 facts.rollup.direct_children,
377 facts.rollup.completed_children,
378 facts.rollup.progress.total
379 ),
380 )
381 })
382 })
383 .collect()
384}
385
386impl BoardState {
387 pub fn resolution_for(
403 store: &Store,
404 board_config: &BoardConfiguration,
405 ) -> crate::workflow::CategoryResolution {
406 let name = store
407 .config
408 .configs()
409 .find(|(_, board)| std::ptr::eq(*board, board_config))
410 .map(|(name, _)| name)
411 .unwrap_or("default");
412 crate::workflow::resolution(&store.config, board_config, name)
413 }
414
415 pub fn build(store: &Store, board_config: &BoardConfiguration) -> Self {
416 Self::build_with(store, board_config, false, false)
417 }
418
419 pub fn build_with_archived(
428 store: &Store,
429 board_config: &BoardConfiguration,
430 include_archived: bool,
431 ) -> Self {
432 Self::build_with(store, board_config, include_archived, false)
433 }
434
435 pub fn build_with(
444 store: &Store,
445 board_config: &BoardConfiguration,
446 include_archived: bool,
447 show_children: bool,
448 ) -> Self {
449 let issues = if include_archived {
450 store.list_all()
451 } else {
452 store.list()
453 }
454 .unwrap_or_default();
455 let resolution = Self::resolution_for(store, board_config);
456 Self::build_from_issues(
457 &issues,
458 board_config,
459 &store.config,
460 &resolution,
461 show_children,
462 )
463 }
464
465 pub fn build_from_issues(
473 issues: &[Issue],
474 board_config: &BoardConfiguration,
475 config: &crate::config::Config,
476 resolution: &crate::workflow::CategoryResolution,
477 show_children: bool,
478 ) -> Self {
479 let done_column = crate::workflow::completion_column_or_default(board_config, config);
488 let done_column = done_column.as_str();
489 let blocked = blocked_reasons(issues, done_column);
490 let children = child_markers(issues, done_column);
491 let show_mismatch = board_config.show_mismatch;
492 let hidden_names: Vec<&str> = board_config
493 .hidden_columns
494 .iter()
495 .map(String::as_str)
496 .collect();
497 let mut column_map: Vec<Vec<Card>> =
498 board_config.columns.iter().map(|_| Vec::new()).collect();
499 let mut mismatch_cards = Vec::new();
500
501 for issue in issues {
502 let card = Card {
503 number: issue.number,
504 title: issue.title.clone(),
505 priority: issue.priority.clone(),
506 labels: issue.labels.clone(),
507 assignee: issue.assignee.clone(),
508 filename: issue.filename.clone(),
509 commit: issue.commit.clone(),
510 created: issue.created.clone(),
511 due_date: issue.due_date.clone(),
512 status: issue.status.display().to_string(),
513 category: crate::workflow::category_of(issue, resolution)
514 .map(|c| c.label().to_string()),
515 category_color: crate::workflow::category_of(issue, resolution)
516 .and_then(|c| c.color_hint()),
517 done: crate::workflow::is_completed(issue, resolution),
518 cycle: issue.cycles.as_ref().and_then(|v| v.first().cloned()),
519 milestone: issue.milestones.as_ref().and_then(|v| v.first().cloned()),
520 blocked: blocked.get(&issue.number).cloned(),
521 children: children.get(&issue.number).cloned(),
522 parent: issue.parent,
523 depth: 0,
524 archived: issue.archived,
525 related: issue.related_issues.len(),
526 };
527 if let Some(idx) = board_config
528 .columns
529 .iter()
530 .position(|c| c == issue.status.display())
531 {
532 column_map[idx].push(card);
533 } else {
534 mismatch_cards.push(card);
535 }
536 }
537
538 for cards in column_map.iter_mut() {
539 sort_cards(board_config, cards);
540 }
541 sort_cards(board_config, &mut mismatch_cards);
542
543 let mut columns: Vec<BoardColumn> = Vec::new();
544 let mut hidden: Vec<BoardColumn> = Vec::new();
545 for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
546 let col = BoardColumn {
547 name: name.clone(),
548 cards,
549 };
550 if hidden_names.contains(&name.as_str()) {
551 hidden.push(col);
552 } else {
553 columns.push(col);
554 }
555 }
556
557 let mismatch = BoardColumn {
558 name: "Mismatched".to_string(),
559 cards: mismatch_cards,
560 };
561
562 let mut state = BoardState {
563 columns,
564 hidden,
565 mismatch,
566 show_mismatch,
567 active_col: 0,
568 selected: 0,
569 filter: None,
570 hover: None,
571 hover_col: None,
572 };
573 state.apply_child_order(show_children);
574 state
575 }
576
577 fn apply_child_order(&mut self, show_children: bool) {
584 if !show_children {
585 return;
586 }
587 for column in &mut self.columns {
588 nest_children(&mut column.cards);
589 }
590 nest_children(&mut self.mismatch.cards);
591 }
592
593 pub fn build_filtered(
604 board_config: &BoardConfiguration,
605 config: &crate::config::Config,
606 corpus: &Corpus,
607 query: &str,
608 filter: &StructuredFilter,
609 resolution: &crate::workflow::CategoryResolution,
610 ) -> Self {
611 Self::build_filtered_with(
612 board_config,
613 config,
614 corpus,
615 query,
616 filter,
617 resolution,
618 false,
619 )
620 }
621
622 pub fn build_filtered_with(
629 board_config: &BoardConfiguration,
630 config: &crate::config::Config,
631 corpus: &Corpus,
632 query: &str,
633 filter: &StructuredFilter,
634 resolution: &crate::workflow::CategoryResolution,
635 show_children: bool,
636 ) -> Self {
637 let show_mismatch = board_config.show_mismatch;
638 let done_column = crate::workflow::completion_column_or_default(board_config, config);
641 let done_column = done_column.as_str();
642 let blocked = blocked_reasons(&corpus.issues, done_column);
645 let children = child_markers(&corpus.issues, done_column);
649 let filtering = !query.is_empty() || filter.has_active_criteria();
650 let sort_key = crate::search::control_token(query, "sort");
651 let number = crate::search::number_query(query);
657 let hidden_names: Vec<&str> = board_config
658 .hidden_columns
659 .iter()
660 .map(String::as_str)
661 .collect();
662 let mut column_map: Vec<Vec<Card>> =
663 board_config.columns.iter().map(|_| Vec::new()).collect();
664 let mut mismatch_cards = Vec::new();
665 let mut matched_issues: Vec<&crate::model::Issue> = Vec::new();
666
667 for (issue, text) in corpus.issues.iter().zip(corpus.texts().iter()) {
668 let keep = if let Some(n) = number {
679 issue.number == n && filter.evaluate_issue(issue, text, resolution)
680 } else {
681 crate::search::matches_issue(query, filter, issue, text, resolution)
682 };
683 if !keep {
684 continue;
685 }
686 matched_issues.push(issue);
687 }
688
689 Self::sort_filtered_issues(&mut matched_issues, sort_key.as_deref());
690
691 for issue in matched_issues {
692 let card = Card {
693 number: issue.number,
694 title: issue.title.clone(),
695 priority: issue.priority.clone(),
696 labels: issue.labels.clone(),
697 assignee: issue.assignee.clone(),
698 filename: issue.filename.clone(),
699 commit: issue.commit.clone(),
700 created: issue.created.clone(),
701 due_date: issue.due_date.clone(),
702 status: issue.status.display().to_string(),
703 category: crate::workflow::category_of(issue, resolution)
704 .map(|c| c.label().to_string()),
705 category_color: crate::workflow::category_of(issue, resolution)
706 .and_then(|c| c.color_hint()),
707 done: crate::workflow::is_completed(issue, resolution),
708 cycle: issue.cycles.as_ref().and_then(|v| v.first().cloned()),
709 milestone: issue.milestones.as_ref().and_then(|v| v.first().cloned()),
710 blocked: blocked.get(&issue.number).cloned(),
711 children: children.get(&issue.number).cloned(),
712 parent: issue.parent,
713 depth: 0,
714 archived: issue.archived,
715 related: issue.related_issues.len(),
716 };
717 if let Some(idx) = board_config
718 .columns
719 .iter()
720 .position(|c| c == issue.status.display())
721 {
722 column_map[idx].push(card);
723 } else {
724 mismatch_cards.push(card);
725 }
726 }
727
728 if sort_key.is_none() {
729 for cards in column_map.iter_mut() {
730 sort_cards(board_config, cards);
731 }
732 sort_cards(board_config, &mut mismatch_cards);
733 }
734
735 let mut columns: Vec<BoardColumn> = Vec::new();
736 let mut hidden: Vec<BoardColumn> = Vec::new();
737 for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
738 let col = BoardColumn {
739 name: name.clone(),
740 cards,
741 };
742 if hidden_names.contains(&name.as_str()) {
743 if filtering {
744 columns.push(col);
748 } else {
749 hidden.push(col);
750 }
751 } else {
752 columns.push(col);
753 }
754 }
755
756 let mut state = BoardState {
757 columns,
758 hidden,
759 mismatch: BoardColumn {
760 name: "Mismatched".to_string(),
761 cards: mismatch_cards,
762 },
763 show_mismatch,
764 active_col: 0,
765 selected: 0,
766 filter: Some(query.to_string()),
767 hover: None,
768 hover_col: None,
769 };
770 state.apply_child_order(show_children);
771 state
772 }
773
774 fn sort_filtered_issues(issues: &mut [&crate::model::Issue], sort: Option<&str>) {
775 match sort.unwrap_or("created") {
776 "due" => {
777 issues.sort_by(|a, b| a.due_date.cmp(&b.due_date).then(a.number.cmp(&b.number)))
778 }
779 "start" => issues.sort_by(|a, b| {
780 a.start_date
781 .cmp(&b.start_date)
782 .then(a.number.cmp(&b.number))
783 }),
784 "reviewer" => {
785 issues.sort_by(|a, b| a.reviewer.cmp(&b.reviewer).then(a.number.cmp(&b.number)))
786 }
787 "cycle" => issues.sort_by(|a, b| {
788 a.cycles
789 .as_ref()
790 .and_then(|v| v.first())
791 .cmp(&b.cycles.as_ref().and_then(|v| v.first()))
792 .then(a.number.cmp(&b.number))
793 }),
794 "milestone" => issues.sort_by(|a, b| {
795 a.milestones
796 .as_ref()
797 .and_then(|v| v.first())
798 .cmp(&b.milestones.as_ref().and_then(|v| v.first()))
799 .then(a.number.cmp(&b.number))
800 }),
801 "status" => issues.sort_by(|a, b| {
802 a.status
803 .display()
804 .cmp(b.status.display())
805 .then(a.number.cmp(&b.number))
806 }),
807 "priority" => issues.sort_by(|a, b| {
808 a.priority
809 .to_string()
810 .cmp(&b.priority.to_string())
811 .then(a.number.cmp(&b.number))
812 }),
813 _ => issues.sort_by(|a, b| a.created.cmp(&b.created).then(a.number.cmp(&b.number))),
814 }
815 }
816
817 pub fn is_empty(&self) -> bool {
819 self.columns.iter().all(|c| c.cards.is_empty())
820 && (!self.show_mismatch || self.mismatch.cards.is_empty())
821 }
822
823 pub fn total_cols(&self) -> usize {
824 if self.show_mismatch {
825 self.columns.len() + 1
826 } else {
827 self.columns.len()
828 }
829 }
830
831 pub fn hit_test(
840 &self,
841 area: Rect,
842 display: BoardDisplay,
843 status: bool,
844 x: u16,
845 y: u16,
846 ) -> Option<HitTarget> {
847 if area.height < 2 || area.width < 4 || self.total_cols() == 0 {
848 return None;
849 }
850 let board_height = area.height.saturating_sub(u16::from(status));
851 if y >= area.y + board_height {
852 return None;
853 }
854 let board_area = Rect {
855 height: board_height,
856 ..area
857 };
858
859 let rects = compute_column_rects(board_area, self.total_cols());
860 let (col, col_rect) = rects
861 .iter()
862 .enumerate()
863 .find(|(_, r)| r.contains(Position::new(x, y)))?;
864
865 let cards: &[Card] = if col < self.columns.len() {
866 &self.columns[col].cards
867 } else {
868 &self.mismatch.cards
869 };
870
871 let group_mode = self
872 .filter
873 .as_deref()
874 .and_then(|q| crate::search::control_token(q, "group"));
875
876 let mut items: Vec<Option<usize>> = Vec::new();
879 if let Some(mode) = group_mode.as_deref() {
880 if let Some(sections) = Self::group_sections(cards, Some(mode)) {
881 for section in sections {
882 items.push(None);
883 for (j, card) in cards
884 .iter()
885 .enumerate()
886 .take(section.end)
887 .skip(section.start)
888 {
889 let count = widgets::card_line_count(
890 card,
891 col_rect.width as usize,
892 display.wrap_titles,
893 display.title_lines,
894 );
895 for _ in 0..count {
896 items.push(Some(j));
897 }
898 }
899 }
900 }
901 } else {
902 for (j, card) in cards.iter().enumerate() {
903 let count = widgets::card_line_count(
904 card,
905 col_rect.width as usize,
906 display.wrap_titles,
907 display.title_lines,
908 );
909 for _ in 0..count {
910 items.push(Some(j));
911 }
912 }
913 }
914
915 let has_footer = BoardState::group_footer(cards, group_mode.as_deref()).is_some();
916 let list_height = col_rect.height.saturating_sub(u16::from(has_footer));
917 let content_top = col_rect.y + 1;
919 let content_end = col_rect.y + list_height.saturating_sub(1);
920 if y >= content_top && y < content_end {
921 let offset = usize::from(y - content_top);
922 if let Some(Some(row)) = items.get(offset) {
923 return Some(HitTarget::Card { col, row: *row });
924 }
925 }
926 Some(HitTarget::Column { col })
927 }
928
929 pub fn real_cols(&self) -> usize {
933 self.columns.len()
934 }
935
936 pub fn column_name(&self, idx: usize) -> &str {
937 if idx < self.columns.len() {
938 &self.columns[idx].name
939 } else if idx == self.columns.len() && self.show_mismatch {
940 &self.mismatch.name
941 } else {
942 "Unknown"
943 }
944 }
945
946 pub fn active_column_name(&self) -> &str {
951 let visible = self.columns.len();
952 if self.active_col < visible {
953 &self.columns[self.active_col].name
954 } else {
955 let hidden_idx = self.active_col - visible;
956 if hidden_idx < self.hidden.len() {
957 &self.hidden[hidden_idx].name
958 } else {
959 "Unknown"
960 }
961 }
962 }
963
964 pub fn column_cards(&self, idx: usize) -> &[Card] {
965 if idx < self.columns.len() {
966 &self.columns[idx].cards
967 } else if idx == self.columns.len() && self.show_mismatch {
968 &self.mismatch.cards
969 } else {
970 &[]
971 }
972 }
973
974 pub fn get_selected_card(&self) -> Option<&Card> {
975 self.column_cards(self.active_col).get(self.selected)
976 }
977
978 pub fn move_left(&mut self) {
979 let total = self.real_cols();
980 self.active_col = if total == 0 {
981 0
982 } else if self.active_col >= total {
983 total - 1
984 } else {
985 (self.active_col + total - 1) % total
986 };
987 self.clamp_selection();
988 }
989
990 pub fn move_right(&mut self) {
991 let total = self.real_cols();
992 self.active_col = if total == 0 || self.active_col >= total {
993 0
994 } else {
995 (self.active_col + 1) % total
996 };
997 self.clamp_selection();
998 }
999
1000 pub fn move_up(&mut self) {
1001 if self.selected > 0 {
1002 self.selected -= 1;
1003 }
1004 }
1005
1006 pub fn move_down(&mut self) {
1007 let cards = self.column_cards(self.active_col);
1008 if self.selected + 1 < cards.len() {
1009 self.selected += 1;
1010 }
1011 }
1012
1013 pub fn move_selection_by(&mut self, cards: i32) {
1020 let len = self.column_cards(self.active_col).len();
1021 if len == 0 {
1022 self.selected = 0;
1023 return;
1024 }
1025 let next = i32::try_from(self.selected)
1026 .unwrap_or(i32::MAX)
1027 .saturating_add(cards);
1028 self.selected = next.clamp(0, i32::try_from(len).unwrap_or(i32::MAX) - 1) as usize;
1029 }
1030
1031 pub fn clamp_selection(&mut self) {
1032 let cards = self.column_cards(self.active_col);
1033 if cards.is_empty() {
1034 self.selected = 0;
1035 } else if self.selected >= cards.len() {
1036 self.selected = cards.len() - 1;
1037 }
1038 }
1039
1040 pub fn select_column(&mut self, col: usize) {
1041 let total = self.real_cols();
1042 if total > 0 {
1043 self.active_col = col % total;
1044 }
1045 self.clamp_selection();
1046 }
1047
1048 fn group_footer(cards: &[Card], mode: Option<&str>) -> Option<String> {
1049 let sections = Self::group_sections(cards, mode)?;
1050 let text = sections
1051 .into_iter()
1052 .map(|section| {
1053 let pct = if section.total == 0 {
1054 0
1055 } else {
1056 (section.done * 100) / section.total
1057 };
1058 format!("{} {}/{} {pct}%", section.name, section.done, section.total)
1059 })
1060 .collect::<Vec<_>>()
1061 .join(" | ");
1062 Some(text)
1063 }
1064
1065 fn card_group(card: &Card, key: &str) -> String {
1066 match key {
1067 "cycle" => card.cycle.clone().unwrap_or_else(|| "(none)".to_string()),
1068 "milestone" => card
1069 .milestone
1070 .clone()
1071 .unwrap_or_else(|| "(none)".to_string()),
1072 _ => "(none)".to_string(),
1073 }
1074 }
1075
1076 fn group_sections(cards: &[Card], mode: Option<&str>) -> Option<Vec<GroupSection>> {
1077 let key = mode.filter(|m| *m == "cycle" || *m == "milestone")?;
1078 if cards.is_empty() {
1079 return None;
1080 }
1081 let mut sections = Vec::new();
1082 let mut current = String::new();
1083 let mut start = 0usize;
1084 let mut done = 0usize;
1085 let mut total = 0usize;
1086 for (idx, card) in cards.iter().enumerate() {
1087 let group = Self::card_group(card, key);
1088 if idx == 0 {
1089 current = group;
1090 } else if group != current {
1091 sections.push(GroupSection {
1092 name: std::mem::take(&mut current),
1093 start,
1094 end: idx,
1095 done,
1096 total,
1097 });
1098 current = group;
1099 start = idx;
1100 done = 0;
1101 total = 0;
1102 }
1103 total += 1;
1104 if card.done {
1105 done += 1;
1106 }
1107 }
1108 sections.push(GroupSection {
1109 name: current,
1110 start,
1111 end: cards.len(),
1112 done,
1113 total,
1114 });
1115 Some(sections)
1116 }
1117}
1118
1119#[derive(Debug, Clone, Copy)]
1122pub struct BoardDisplay {
1123 pub wrap_titles: bool,
1124 pub title_lines: usize,
1125}
1126
1127impl Default for BoardDisplay {
1128 fn default() -> Self {
1129 BoardDisplay {
1130 wrap_titles: true,
1131 title_lines: 3,
1132 }
1133 }
1134}
1135
1136impl From<&crate::config::BoardConfiguration> for BoardDisplay {
1137 fn from(config: &crate::config::BoardConfiguration) -> Self {
1138 BoardDisplay {
1139 wrap_titles: config.wrap_titles,
1140 title_lines: config.title_lines,
1141 }
1142 }
1143}
1144
1145pub fn compute_column_rects(area: Rect, total_cols: usize) -> Vec<Rect> {
1149 if total_cols == 0 {
1150 return Vec::new();
1151 }
1152 Layout::default()
1153 .direction(Direction::Horizontal)
1154 .constraints(vec![Constraint::Ratio(1, total_cols as u32); total_cols])
1155 .split(area)
1156 .to_vec()
1157}
1158
1159pub fn render(
1160 board: &BoardState,
1161 f: &mut Frame,
1162 area: Rect,
1163 status_msg: &Option<String>,
1164 drag_preview_col: Option<usize>,
1165 display: BoardDisplay,
1166 theme: &crate::ui::color::Theme,
1167) -> usize {
1168 let mut page_cards = 0usize;
1175
1176 if area.height < 2 || area.width < 4 {
1177 return 0;
1178 }
1179
1180 let status_height: u16 = if status_msg.is_some() { 1 } else { 0 };
1181 let board_height = area.height.saturating_sub(status_height);
1182 let board_area = Rect {
1183 height: board_height,
1184 ..area
1185 };
1186 let status_area = Rect {
1187 y: area.y + board_height,
1188 height: status_height,
1189 ..area
1190 };
1191
1192 let total = board.total_cols();
1193 if total == 0 {
1194 return 0;
1195 }
1196
1197 let chunks = compute_column_rects(board_area, total);
1198
1199 for (i, chunk) in chunks.iter().enumerate() {
1200 let col = if i < board.columns.len() {
1201 &board.columns[i]
1202 } else {
1203 &board.mismatch
1204 };
1205 let group_mode = board
1206 .filter
1207 .as_deref()
1208 .and_then(|q| crate::search::control_token(q, "group"));
1209
1210 let is_active = i == board.active_col;
1211 let is_mismatch_col = i == board.columns.len();
1212 let is_mismatch_highlight = is_mismatch_col && !col.cards.is_empty();
1213 let hovered_col = board.hover_col;
1216
1217 let mut selected_row: Option<usize> = None;
1218 let items: Vec<ListItem> = if let Some(mode) = group_mode.as_deref() {
1219 if let Some(sections) = BoardState::group_sections(&col.cards, Some(mode)) {
1220 let mut items = Vec::new();
1221 for section in sections {
1222 let pct = if section.total == 0 {
1223 0
1224 } else {
1225 (section.done * 100) / section.total
1226 };
1227 let header = format!(
1228 "-- {mode}: {} ({}/{} done, {pct}%) --",
1229 section.name, section.done, section.total
1230 );
1231 items.push(ListItem::new(Line::from(Span::styled(
1232 header,
1233 Style::default()
1234 .fg(Color::Yellow)
1235 .add_modifier(Modifier::BOLD),
1236 ))));
1237 for j in section.start..section.end {
1238 let card = &col.cards[j];
1239 let lines = widgets::card_lines_themed(
1240 card,
1241 chunk.width as usize,
1242 display.wrap_titles,
1243 display.title_lines,
1244 theme,
1245 );
1246 let style = if is_active && j == board.selected {
1247 selected_row = Some(items.len());
1248 Style::default()
1249 .bg(Color::DarkGray)
1250 .add_modifier(Modifier::BOLD)
1251 } else if board.hover == Some((i, j)) {
1252 if drag_preview_col == Some(i) {
1254 Style::default()
1255 .fg(Color::Yellow)
1256 .add_modifier(Modifier::BOLD)
1257 } else {
1258 Style::default().fg(Color::Cyan)
1259 }
1260 } else if is_mismatch_highlight {
1261 Style::default().fg(Color::Red)
1262 } else {
1263 Style::default()
1264 };
1265 items.push(ListItem::new(lines).style(style));
1266 }
1267 }
1268 items
1269 } else {
1270 Vec::new()
1271 }
1272 } else {
1273 col.cards
1274 .iter()
1275 .enumerate()
1276 .map(|(j, card)| {
1277 let lines = widgets::card_lines_themed(
1278 card,
1279 chunk.width as usize,
1280 display.wrap_titles,
1281 display.title_lines,
1282 theme,
1283 );
1284 let style = if is_active && j == board.selected {
1285 selected_row = Some(j);
1286 Style::default()
1287 .bg(Color::DarkGray)
1288 .add_modifier(Modifier::BOLD)
1289 } else if board.hover == Some((i, j)) {
1290 if drag_preview_col == Some(i) {
1291 Style::default()
1292 .fg(Color::Yellow)
1293 .add_modifier(Modifier::BOLD)
1294 } else {
1295 Style::default().fg(Color::Cyan)
1296 }
1297 } else if is_mismatch_highlight {
1298 Style::default().fg(Color::Red)
1299 } else {
1300 Style::default()
1301 };
1302 ListItem::new(lines).style(style)
1303 })
1304 .collect()
1305 };
1306
1307 let title = format!("{} ({})", col.name, col.cards.len());
1308 let border_style = if drag_preview_col == Some(i) {
1309 Style::default().fg(Color::Yellow)
1310 } else if is_active {
1311 Style::default().fg(Color::Cyan)
1312 } else if is_mismatch_highlight {
1313 Style::default().fg(Color::Red)
1314 } else if hovered_col == Some(i) {
1315 Style::default()
1318 .fg(Color::Blue)
1319 .add_modifier(Modifier::BOLD)
1320 } else {
1321 Style::default()
1322 };
1323
1324 let block = Block::default()
1325 .borders(Borders::ALL)
1326 .title(title)
1327 .border_style(border_style);
1328
1329 let col_chunks = if BoardState::group_footer(&col.cards, group_mode.as_deref()).is_some() {
1330 Layout::default()
1331 .direction(Direction::Vertical)
1332 .constraints([Constraint::Min(1), Constraint::Length(1)])
1333 .split(*chunk)
1334 } else {
1335 Layout::default()
1336 .direction(Direction::Vertical)
1337 .constraints([Constraint::Min(1), Constraint::Length(0)])
1338 .split(*chunk)
1339 };
1340
1341 let list = List::new(items).block(block);
1342
1343 if is_active {
1344 page_cards = cards_per_screen(
1347 &col.cards,
1348 chunk.width as usize,
1349 col_chunks[0].height.saturating_sub(2),
1350 display,
1351 );
1352 let mut state = ListState::default();
1353 if let Some(row) = selected_row {
1354 state.select(Some(row));
1355 }
1356 f.render_stateful_widget(list, col_chunks[0], &mut state);
1357 } else {
1358 f.render_widget(list, col_chunks[0]);
1359 }
1360 if let Some(footer) = BoardState::group_footer(&col.cards, group_mode.as_deref()) {
1361 let para = Paragraph::new(Line::from(Span::styled(
1362 footer,
1363 Style::default().fg(Color::DarkGray),
1364 )));
1365 f.render_widget(para, col_chunks[1]);
1366 }
1367 }
1368
1369 if let Some(msg) = status_msg {
1370 let budget = (status_area.width as usize).saturating_sub(1);
1387 let text = truncate_to_cells(msg, budget);
1388 let para =
1389 Paragraph::new(Line::from(Span::raw(text))).style(Style::default().fg(Color::Yellow));
1390 f.render_widget(para, status_area);
1391 }
1392
1393 page_cards
1394}
1395
1396fn cards_per_screen(cards: &[Card], width: usize, rows: u16, display: BoardDisplay) -> usize {
1407 if cards.is_empty() {
1408 return 0;
1409 }
1410 let mut used: u16 = 0;
1411 let mut fits = 0usize;
1412 for card in cards {
1413 let height = u16::try_from(
1414 widgets::card_lines(card, width, display.wrap_titles, display.title_lines).len(),
1415 )
1416 .unwrap_or(u16::MAX);
1417 if fits > 0 && used.saturating_add(height) > rows {
1418 break;
1419 }
1420 used = used.saturating_add(height);
1421 fits += 1;
1422 if used >= rows {
1423 break;
1424 }
1425 }
1426 fits.max(1)
1427}
1428
1429#[cfg(test)]
1430mod tests {
1431 use super::*;
1432 use crate::config::Config;
1433
1434 #[test]
1435 fn compute_column_rects_splits_into_equal_columns() {
1436 let area = Rect::new(5, 7, 90, 30);
1437 let rects = compute_column_rects(area, 3);
1438 assert_eq!(rects.len(), 3);
1439 assert_eq!((rects[0].x, rects[0].width), (5, 30));
1440 assert_eq!((rects[1].x, rects[1].width), (35, 30));
1441 assert_eq!((rects[2].x, rects[2].width), (65, 30));
1442 assert_eq!((rects[0].y, rects[0].height), (7, 30));
1443 assert_eq!((rects[2].y, rects[2].height), (7, 30));
1444 }
1445
1446 #[test]
1447 fn compute_column_rects_zero_columns_is_empty() {
1448 assert!(compute_column_rects(Rect::new(0, 0, 10, 10), 0).is_empty());
1449 }
1450
1451 #[test]
1452 fn card_line_count_matches_rendered_card_heights() {
1453 use ratatui::Terminal;
1454 use ratatui::backend::TestBackend;
1455
1456 let wrapped = "A deliberately long card title that cannot fit on a single line inside the narrow column and must wrap over several rows";
1457 let cards = vec![
1458 Card {
1459 number: 1,
1460 title: "Short".into(),
1461 priority: crate::model::Priority::Medium,
1462 labels: vec![],
1463 assignee: None,
1464 filename: "1.md".into(),
1465 commit: None,
1466 created: "2026-01-01".into(),
1467 due_date: None,
1468 status: "todo".into(),
1469 category: None,
1470 category_color: None,
1471 done: false,
1472 cycle: None,
1473 milestone: None,
1474 blocked: None,
1475 children: None,
1476 parent: None,
1477 depth: 0,
1478 archived: false,
1479 related: 0,
1480 },
1481 Card {
1482 number: 2,
1483 title: wrapped.into(),
1484 priority: crate::model::Priority::Medium,
1485 labels: vec![],
1486 assignee: None,
1487 filename: "2.md".into(),
1488 commit: None,
1489 created: "2026-01-01".into(),
1490 due_date: None,
1491 status: "todo".into(),
1492 category: None,
1493 category_color: None,
1494 done: false,
1495 cycle: None,
1496 milestone: None,
1497 blocked: None,
1498 children: None,
1499 parent: None,
1500 depth: 0,
1501 archived: false,
1502 related: 0,
1503 },
1504 Card {
1505 number: 3,
1506 title: "Another long title card that also wraps".into(),
1507 priority: crate::model::Priority::Medium,
1508 labels: vec![],
1509 assignee: None,
1510 filename: "3.md".into(),
1511 commit: None,
1512 created: "2026-01-01".into(),
1513 due_date: None,
1514 status: "todo".into(),
1515 category: None,
1516 category_color: None,
1517 done: false,
1518 cycle: None,
1519 milestone: None,
1520 blocked: None,
1521 children: None,
1522 parent: None,
1523 depth: 0,
1524 archived: false,
1525 related: 0,
1526 },
1527 ];
1528 let board = BoardState {
1529 columns: vec![BoardColumn {
1530 name: "todo".into(),
1531 cards,
1532 }],
1533 hidden: vec![],
1534 mismatch: BoardColumn {
1535 name: "Mismatched".into(),
1536 cards: vec![],
1537 },
1538 active_col: 0,
1539 selected: 0,
1540 filter: None,
1541 hover: None,
1542 hover_col: None,
1543 show_mismatch: false,
1544 };
1545 let display = BoardDisplay {
1546 wrap_titles: true,
1547 title_lines: 3,
1548 };
1549 let width = 80u16;
1550 let height = 30u16;
1551 let backend = TestBackend::new(width, height);
1552 let mut terminal = Terminal::new(backend).unwrap();
1553 terminal
1554 .draw(|f| {
1555 render(
1556 &board,
1557 f,
1558 f.area(),
1559 &None,
1560 None,
1561 display,
1562 &crate::ui::color::Theme::default(),
1563 );
1564 })
1565 .unwrap();
1566 let buffer = terminal.backend().buffer();
1567
1568 let col_width = compute_column_rects(Rect::new(0, 0, width, height), 1)[0].width as usize;
1569
1570 let mut starts: Vec<(u32, u16)> = Vec::new();
1571 for y in 0..height {
1572 let line: String = (1..width.min(24))
1573 .map(|x| buffer[(x, y)].symbol())
1574 .collect();
1575 for card in &board.columns[0].cards {
1576 if line.starts_with(&format!("#{} ", card.number))
1577 && !starts.iter().any(|(n, _)| *n == card.number)
1578 {
1579 starts.push((card.number, y));
1580 break;
1581 }
1582 }
1583 }
1584 starts.sort_by_key(|(_, y)| *y);
1585 assert_eq!(
1586 starts.len(),
1587 3,
1588 "every card found in the buffer: {starts:?}"
1589 );
1590 for (idx, (number, y)) in starts.iter().enumerate() {
1591 let span: u16 = if idx + 1 < starts.len() {
1592 starts[idx + 1].1 - y
1593 } else {
1594 u16::try_from(
1595 (*y..height)
1596 .filter(|&rw| {
1597 rw != height - 1
1598 && (1..(width - 1)).any(|x| buffer[(x, rw)].symbol() != " ")
1599 })
1600 .count(),
1601 )
1602 .unwrap()
1603 };
1604 let card = board.columns[0]
1605 .cards
1606 .iter()
1607 .find(|c| c.number == *number)
1608 .expect("card on the board");
1609 let expected =
1610 widgets::card_line_count(card, col_width, display.wrap_titles, display.title_lines);
1611 assert_eq!(
1612 span, expected,
1613 "rendered height of card #{number} must match card_line_count"
1614 );
1615 }
1616 }
1617
1618 #[test]
1619 fn hit_test_cards_and_column_fallback() {
1620 let tall = "A deliberately long card title that cannot fit on a single line inside the narrow column and wraps over several rows";
1621 let cards = vec![
1622 Card {
1623 number: 1,
1624 title: "one".into(),
1625 priority: Priority::Medium,
1626 labels: vec![],
1627 assignee: None,
1628 filename: "1.md".into(),
1629 commit: None,
1630 created: "2026-01-01".into(),
1631 due_date: None,
1632 status: "todo".into(),
1633 category: None,
1634 category_color: None,
1635 done: false,
1636 cycle: None,
1637 milestone: None,
1638 blocked: None,
1639 children: None,
1640 parent: None,
1641 depth: 0,
1642 archived: false,
1643 related: 0,
1644 },
1645 Card {
1646 number: 2,
1647 title: tall.into(),
1648 priority: Priority::Medium,
1649 labels: vec![],
1650 assignee: None,
1651 filename: "2.md".into(),
1652 commit: None,
1653 created: "2026-01-02".into(),
1654 due_date: None,
1655 status: "todo".into(),
1656 category: None,
1657 category_color: None,
1658 done: false,
1659 cycle: None,
1660 milestone: None,
1661 blocked: None,
1662 children: None,
1663 parent: None,
1664 depth: 0,
1665 archived: false,
1666 related: 0,
1667 },
1668 ];
1669 let board = BoardState {
1670 columns: vec![
1671 BoardColumn {
1672 name: "todo".into(),
1673 cards,
1674 },
1675 BoardColumn {
1676 name: "done".into(),
1677 cards: vec![Card {
1678 number: 3,
1679 title: "three".into(),
1680 priority: Priority::Medium,
1681 labels: vec![],
1682 assignee: None,
1683 filename: "3.md".into(),
1684 commit: None,
1685 created: "2026-01-03".into(),
1686 due_date: None,
1687 status: "done".into(),
1688 category: None,
1689 category_color: None,
1690 done: true,
1691 cycle: None,
1692 milestone: None,
1693 blocked: None,
1694 children: None,
1695 parent: None,
1696 depth: 0,
1697 archived: false,
1698 related: 0,
1699 }],
1700 },
1701 ],
1702 hidden: vec![],
1703 mismatch: BoardColumn {
1704 name: "Mismatched".into(),
1705 cards: vec![],
1706 },
1707 active_col: 0,
1708 selected: 0,
1709 filter: None,
1710 hover: None,
1711 hover_col: None,
1712 show_mismatch: false,
1713 };
1714 let display = BoardDisplay::default();
1715 let area = Rect::new(0, 0, 80, 30);
1716 let widths = compute_column_rects(area, 2);
1717 let col0 = widths[0].width as usize;
1718 let card1_height = widgets::card_line_count(&board.columns[0].cards[0], col0, true, 3);
1719
1720 assert_eq!(
1722 board.hit_test(area, display, false, 3, 1),
1723 Some(HitTarget::Card { col: 0, row: 0 })
1724 );
1725 assert_eq!(
1727 board.hit_test(area, display, false, 3, 1 + card1_height),
1728 Some(HitTarget::Card { col: 0, row: 1 })
1729 );
1730 assert_eq!(
1732 board.hit_test(area, display, false, 3, 10),
1733 Some(HitTarget::Column { col: 0 })
1734 );
1735 assert_eq!(
1737 board.hit_test(area, display, false, 0, 5),
1738 Some(HitTarget::Column { col: 0 })
1739 );
1740 assert_eq!(
1742 board.hit_test(area, display, false, 55, 0),
1743 Some(HitTarget::Column { col: 1 })
1744 );
1745 assert_eq!(
1746 board.hit_test(area, display, false, 55, 1),
1747 Some(HitTarget::Card { col: 1, row: 0 })
1748 );
1749 assert_eq!(
1751 board.hit_test(area, display, false, 3, 29),
1752 Some(HitTarget::Column { col: 0 })
1753 );
1754 }
1755
1756 #[test]
1757 fn hit_test_outside_area_is_none() {
1758 let board = BoardState {
1759 columns: vec![BoardColumn {
1760 name: "todo".into(),
1761 cards: vec![Card {
1762 number: 1,
1763 title: "one".into(),
1764 priority: Priority::Medium,
1765 labels: vec![],
1766 assignee: None,
1767 filename: "1.md".into(),
1768 commit: None,
1769 created: "2026-01-01".into(),
1770 due_date: None,
1771 status: "todo".into(),
1772 category: None,
1773 category_color: None,
1774 done: false,
1775 cycle: None,
1776 milestone: None,
1777 blocked: None,
1778 children: None,
1779 parent: None,
1780 depth: 0,
1781 archived: false,
1782 related: 0,
1783 }],
1784 }],
1785 hidden: vec![],
1786 mismatch: BoardColumn {
1787 name: "Mismatched".into(),
1788 cards: vec![],
1789 },
1790 active_col: 0,
1791 selected: 0,
1792 filter: None,
1793 hover: None,
1794 hover_col: None,
1795 show_mismatch: false,
1796 };
1797 let display = BoardDisplay::default();
1798 let area = Rect::new(0, 5, 40, 12);
1799 assert_eq!(board.hit_test(area, display, false, 3, 3), None);
1801 assert_eq!(
1803 board.hit_test(area, display, false, 3, 6),
1804 Some(HitTarget::Card { col: 0, row: 0 })
1805 );
1806 assert_eq!(board.hit_test(area, display, false, 44, 7), None);
1808 }
1809
1810 #[test]
1811 fn hit_test_respects_status_row() {
1812 let board = BoardState {
1813 columns: vec![BoardColumn {
1814 name: "todo".into(),
1815 cards: vec![Card {
1816 number: 1,
1817 title: "one".into(),
1818 priority: Priority::Medium,
1819 labels: vec![],
1820 assignee: None,
1821 filename: "1.md".into(),
1822 commit: None,
1823 created: "2026-01-01".into(),
1824 due_date: None,
1825 status: "todo".into(),
1826 category: None,
1827 category_color: None,
1828 done: false,
1829 cycle: None,
1830 milestone: None,
1831 blocked: None,
1832 children: None,
1833 parent: None,
1834 depth: 0,
1835 archived: false,
1836 related: 0,
1837 }],
1838 }],
1839 hidden: vec![],
1840 mismatch: BoardColumn {
1841 name: "Mismatched".into(),
1842 cards: vec![],
1843 },
1844 active_col: 0,
1845 selected: 0,
1846 filter: None,
1847 hover: None,
1848 hover_col: None,
1849 show_mismatch: false,
1850 };
1851 let display = BoardDisplay::default();
1852 let area = Rect::new(0, 0, 80, 30);
1853 assert_eq!(board.hit_test(area, display, true, 3, 29), None);
1856 assert_eq!(
1857 board.hit_test(area, display, true, 3, 1),
1858 Some(HitTarget::Card { col: 0, row: 0 })
1859 );
1860 }
1861
1862 #[test]
1863 fn hit_test_group_header_is_not_a_card() {
1864 let cycle_card = |number: u32, cycle: &str| Card {
1865 number,
1866 title: format!("task {number}"),
1867 priority: Priority::Medium,
1868 labels: vec![],
1869 assignee: None,
1870 filename: format!("{number}.md"),
1871 commit: None,
1872 created: format!("2026-01-{number:02}"),
1873 due_date: None,
1874 status: "todo".into(),
1875 category: None,
1876 category_color: None,
1877 done: false,
1878 cycle: Some(cycle.into()),
1879 milestone: None,
1880 blocked: None,
1881 children: None,
1882 parent: None,
1883 depth: 0,
1884 archived: false,
1885 related: 0,
1886 };
1887 let board = BoardState {
1888 columns: vec![
1889 BoardColumn {
1890 name: "todo".into(),
1891 cards: vec![cycle_card(1, "alpha"), cycle_card(2, "beta")],
1892 },
1893 BoardColumn {
1894 name: "done".into(),
1895 cards: vec![],
1896 },
1897 ],
1898 hidden: vec![],
1899 mismatch: BoardColumn {
1900 name: "Mismatched".into(),
1901 cards: vec![],
1902 },
1903 active_col: 0,
1904 selected: 0,
1905 filter: Some("group:cycle".into()),
1906 hover: None,
1907 hover_col: None,
1908 show_mismatch: false,
1909 };
1910 let display = BoardDisplay::default();
1911 let area = Rect::new(0, 0, 80, 30);
1912 let col0 = area.width / 2;
1913 let first_height =
1914 widgets::card_line_count(&board.columns[0].cards[0], col0 as usize, true, 3);
1915
1916 assert_eq!(
1918 board.hit_test(area, display, false, 5, 1),
1919 Some(HitTarget::Column { col: 0 })
1920 );
1921 assert_eq!(
1923 board.hit_test(area, display, false, 5, 2),
1924 Some(HitTarget::Card { col: 0, row: 0 })
1925 );
1926 assert_eq!(
1928 board.hit_test(area, display, false, 5, 2 + first_height),
1929 Some(HitTarget::Column { col: 0 })
1930 );
1931 assert_eq!(
1932 board.hit_test(area, display, false, 5, 3 + first_height),
1933 Some(HitTarget::Card { col: 0, row: 1 })
1934 );
1935 }
1936
1937 #[test]
1938 fn hit_test_mismatch_column_resolves() {
1939 let board = BoardState {
1940 columns: vec![BoardColumn {
1941 name: "todo".into(),
1942 cards: vec![],
1943 }],
1944 hidden: vec![],
1945 mismatch: BoardColumn {
1946 name: "Mismatched".into(),
1947 cards: vec![Card {
1948 number: 9,
1949 title: "oddball".into(),
1950 priority: Priority::Medium,
1951 labels: vec![],
1952 assignee: None,
1953 filename: "9.md".into(),
1954 commit: None,
1955 created: "2026-01-09".into(),
1956 due_date: None,
1957 status: "weird".into(),
1958 category: None,
1959 category_color: None,
1960 done: false,
1961 cycle: None,
1962 milestone: None,
1963 blocked: None,
1964 children: None,
1965 parent: None,
1966 depth: 0,
1967 archived: false,
1968 related: 0,
1969 }],
1970 },
1971 active_col: 0,
1972 selected: 0,
1973 filter: None,
1974 hover: None,
1975 hover_col: None,
1976 show_mismatch: true,
1977 };
1978 let display = BoardDisplay::default();
1979 let area = Rect::new(0, 0, 90, 30);
1980 let mismatch_col = board.columns.len();
1981 assert_eq!(
1983 board.hit_test(area, display, false, 70, 1),
1984 Some(HitTarget::Card {
1985 col: mismatch_col,
1986 row: 0
1987 })
1988 );
1989 }
1990
1991 #[test]
1992 fn render_highlights_hovered_card_without_selecting() {
1993 use ratatui::Terminal;
1994 use ratatui::backend::TestBackend;
1995 let board = BoardState {
1996 columns: vec![BoardColumn {
1997 name: "todo".into(),
1998 cards: vec![
1999 Card {
2000 number: 1,
2001 title: "Alpha".into(),
2002 priority: Priority::Medium,
2003 labels: vec![],
2004 assignee: None,
2005 filename: "1.md".into(),
2006 commit: None,
2007 created: "2026-01-01".into(),
2008 due_date: None,
2009 status: "todo".into(),
2010 category: None,
2011 category_color: None,
2012 done: false,
2013 cycle: None,
2014 milestone: None,
2015 blocked: None,
2016 children: None,
2017 parent: None,
2018 depth: 0,
2019 archived: false,
2020 related: 0,
2021 },
2022 Card {
2023 number: 2,
2024 title: "Beta".into(),
2025 priority: Priority::Medium,
2026 labels: vec![],
2027 assignee: None,
2028 filename: "2.md".into(),
2029 commit: None,
2030 created: "2026-01-02".into(),
2031 due_date: None,
2032 status: "todo".into(),
2033 category: None,
2034 category_color: None,
2035 done: false,
2036 cycle: None,
2037 milestone: None,
2038 blocked: None,
2039 children: None,
2040 parent: None,
2041 depth: 0,
2042 archived: false,
2043 related: 0,
2044 },
2045 ],
2046 }],
2047 hidden: vec![],
2048 mismatch: BoardColumn {
2049 name: "Mismatched".into(),
2050 cards: vec![],
2051 },
2052 show_mismatch: false,
2053 active_col: 0,
2054 selected: 0,
2055 hover: Some((0, 1)),
2056 hover_col: Some(0),
2057 filter: None,
2058 };
2059 let display = BoardDisplay::default();
2060 let backend = TestBackend::new(80, 30);
2061 let mut terminal = Terminal::new(backend).unwrap();
2062 terminal
2063 .draw(|f| {
2064 render(
2065 &board,
2066 f,
2067 f.area(),
2068 &None,
2069 None,
2070 display,
2071 &crate::ui::color::Theme::default(),
2072 );
2073 })
2074 .unwrap();
2075 let buffer = terminal.backend().buffer();
2076
2077 let mut rows = [None, None];
2078 for y in 0..30u16 {
2079 let line: String = (1..12).map(|x| buffer[(x, y)].symbol()).collect();
2080 if line.starts_with("#1") {
2081 rows[0] = Some(y);
2082 }
2083 if line.starts_with("#2") {
2084 rows[1] = Some(y);
2085 }
2086 }
2087 let y1 = rows[0].expect("first card rendered");
2088 let y2 = rows[1].expect("second card rendered");
2089
2090 assert_eq!(buffer[(3, y1)].style().bg, Some(Color::DarkGray));
2092 assert_eq!(buffer[(3, y2)].style().fg, Some(Color::Cyan));
2094 assert_eq!((board.active_col, board.selected), (0, 0));
2096 }
2097
2098 #[test]
2099 fn render_highlights_the_hovered_cards_column() {
2100 use ratatui::Terminal;
2101 use ratatui::backend::TestBackend;
2102
2103 let card = |number: u32, title: &str, status: &str| Card {
2104 number,
2105 title: title.into(),
2106 priority: Priority::Medium,
2107 labels: vec![],
2108 assignee: None,
2109 filename: format!("{number}.md"),
2110 commit: None,
2111 created: "2026-01-01".into(),
2112 due_date: None,
2113 status: status.into(),
2114 category: None,
2115 category_color: None,
2116 done: false,
2117 cycle: None,
2118 milestone: None,
2119 blocked: None,
2120 children: None,
2121 parent: None,
2122 depth: 0,
2123 archived: false,
2124 related: 0,
2125 };
2126 let board = BoardState {
2127 columns: vec![
2128 BoardColumn {
2129 name: "todo".into(),
2130 cards: vec![card(1, "Alpha", "todo")],
2131 },
2132 BoardColumn {
2133 name: "doing".into(),
2134 cards: vec![card(2, "Beta", "doing")],
2135 },
2136 ],
2137 hidden: vec![],
2138 mismatch: BoardColumn {
2139 name: "Mismatched".into(),
2140 cards: vec![],
2141 },
2142 show_mismatch: false,
2143 active_col: 0,
2144 selected: 0,
2145 hover: Some((1, 0)),
2147 hover_col: Some(1),
2148 filter: None,
2149 };
2150 let display = BoardDisplay::default();
2151 let area = Rect::new(0, 0, 80, 30);
2152 let backend = TestBackend::new(80, 30);
2153 let mut terminal = Terminal::new(backend).unwrap();
2154 terminal
2155 .draw(|f| {
2156 render(
2157 &board,
2158 f,
2159 f.area(),
2160 &None,
2161 None,
2162 display,
2163 &crate::ui::color::Theme::default(),
2164 );
2165 })
2166 .unwrap();
2167 let buffer = terminal.backend().buffer();
2168
2169 let chunks = compute_column_rects(area, board.total_cols());
2170 let hovered = chunks[1];
2171 let active = chunks[0];
2172 assert_eq!(
2173 buffer[(hovered.x, hovered.y)].style().fg,
2174 Some(Color::Blue),
2175 "the hovered column is outlined"
2176 );
2177 assert_eq!(
2178 buffer[(active.x, active.y)].style().fg,
2179 Some(Color::Cyan),
2180 "the active column keeps its own colour"
2181 );
2182 }
2183
2184 #[test]
2185 fn render_marks_an_empty_hovered_column() {
2186 use ratatui::Terminal;
2187 use ratatui::backend::TestBackend;
2188
2189 let board = BoardState {
2190 columns: vec![
2191 BoardColumn {
2192 name: "todo".into(),
2193 cards: vec![],
2194 },
2195 BoardColumn {
2196 name: "doing".into(),
2197 cards: vec![],
2198 },
2199 ],
2200 hidden: vec![],
2201 mismatch: BoardColumn {
2202 name: "Mismatched".into(),
2203 cards: vec![],
2204 },
2205 show_mismatch: false,
2206 active_col: 0,
2207 selected: 0,
2208 hover: None,
2209 hover_col: Some(1),
2211 filter: None,
2212 };
2213 let display = BoardDisplay::default();
2214 let area = Rect::new(0, 0, 80, 30);
2215 let backend = TestBackend::new(80, 30);
2216 let mut terminal = Terminal::new(backend).unwrap();
2217 terminal
2218 .draw(|f| {
2219 render(
2220 &board,
2221 f,
2222 f.area(),
2223 &None,
2224 None,
2225 display,
2226 &crate::ui::color::Theme::default(),
2227 );
2228 })
2229 .unwrap();
2230 let buffer = terminal.backend().buffer();
2231
2232 let chunks = compute_column_rects(area, board.total_cols());
2233 assert_eq!(
2234 buffer[(chunks[1].x, chunks[1].y)].style().fg,
2235 Some(Color::Blue),
2236 "an empty column with no cards is still outlined when hovered"
2237 );
2238 assert_eq!(
2239 buffer[(chunks[0].x, chunks[0].y)].style().fg,
2240 Some(Color::Cyan),
2241 "the active column keeps its own colour"
2242 );
2243 }
2244
2245 #[test]
2246 fn total_cols_includes_mismatch() {
2247 let board = BoardState {
2248 columns: vec![
2249 BoardColumn {
2250 name: "a".into(),
2251 cards: vec![],
2252 },
2253 BoardColumn {
2254 name: "b".into(),
2255 cards: vec![],
2256 },
2257 ],
2258 mismatch: BoardColumn {
2259 name: "Mismatched".into(),
2260 cards: vec![],
2261 },
2262 active_col: 0,
2263 selected: 0,
2264 filter: None,
2265 hover: None,
2266 hover_col: None,
2267 hidden: vec![],
2268 show_mismatch: true,
2269 };
2270 assert_eq!(board.total_cols(), 3);
2271 }
2272
2273 #[test]
2274 fn column_name_mismatch_index() {
2275 let board = BoardState {
2276 columns: vec![BoardColumn {
2277 name: "todo".into(),
2278 cards: vec![],
2279 }],
2280 mismatch: BoardColumn {
2281 name: "Mismatched".into(),
2282 cards: vec![],
2283 },
2284 active_col: 0,
2285 selected: 0,
2286 filter: None,
2287 hover: None,
2288 hover_col: None,
2289 hidden: vec![],
2290 show_mismatch: true,
2291 };
2292 assert_eq!(board.column_name(0), "todo");
2293 assert_eq!(board.column_name(1), "Mismatched");
2294 }
2295
2296 #[test]
2297 fn move_left_wraps_around() {
2298 let mut board = BoardState {
2299 columns: vec![
2300 BoardColumn {
2301 name: "a".into(),
2302 cards: vec![],
2303 },
2304 BoardColumn {
2305 name: "b".into(),
2306 cards: vec![],
2307 },
2308 ],
2309 hidden: vec![],
2310 mismatch: BoardColumn {
2311 name: "Mismatched".into(),
2312 cards: vec![],
2313 },
2314 active_col: 0,
2315 selected: 0,
2316 filter: None,
2317 hover: None,
2318 hover_col: None,
2319 show_mismatch: true,
2320 };
2321 board.move_left();
2322 assert_eq!(board.active_col, 1);
2323 }
2324
2325 #[test]
2326 fn move_right_wraps_around() {
2327 let mut board = BoardState {
2328 columns: vec![
2329 BoardColumn {
2330 name: "a".into(),
2331 cards: vec![],
2332 },
2333 BoardColumn {
2334 name: "b".into(),
2335 cards: vec![],
2336 },
2337 ],
2338 mismatch: BoardColumn {
2339 name: "Mismatched".into(),
2340 cards: vec![],
2341 },
2342 active_col: 2,
2343 selected: 0,
2344 filter: None,
2345 hover: None,
2346 hover_col: None,
2347 hidden: vec![],
2348 show_mismatch: true,
2349 };
2350 board.move_right();
2351 assert_eq!(board.active_col, 0);
2352 }
2353
2354 #[test]
2355 fn clamp_selection_empty_column() {
2356 let mut board = BoardState {
2357 columns: vec![BoardColumn {
2358 name: "a".into(),
2359 cards: vec![],
2360 }],
2361 mismatch: BoardColumn {
2362 name: "Mismatched".into(),
2363 cards: vec![],
2364 },
2365 active_col: 0,
2366 selected: 5,
2367 filter: None,
2368 hover: None,
2369 hover_col: None,
2370 hidden: vec![],
2371 show_mismatch: true,
2372 };
2373 board.clamp_selection();
2374 assert_eq!(board.selected, 0);
2375 }
2376
2377 #[test]
2378 fn clamp_selection_single_card() {
2379 let mut board = BoardState {
2380 columns: vec![BoardColumn {
2381 name: "a".into(),
2382 cards: vec![Card {
2383 number: 1,
2384 title: "X".into(),
2385 priority: crate::model::Priority::Medium,
2386 labels: vec![],
2387 assignee: None,
2388 filename: "x.md".into(),
2389 commit: None,
2390 created: "2026-01-01".into(),
2391 due_date: None,
2392 status: "todo".into(),
2393 category: None,
2394 category_color: None,
2395 done: false,
2396 cycle: None,
2397 milestone: None,
2398 blocked: None,
2399 children: None,
2400 parent: None,
2401 depth: 0,
2402 archived: false,
2403 related: 0,
2404 }],
2405 }],
2406 mismatch: BoardColumn {
2407 name: "Mismatched".into(),
2408 cards: vec![],
2409 },
2410 active_col: 0,
2411 selected: 5,
2412 filter: None,
2413 hover: None,
2414 hover_col: None,
2415 hidden: vec![],
2416 show_mismatch: true,
2417 };
2418 board.clamp_selection();
2419 assert_eq!(board.selected, 0);
2420 }
2421
2422 #[test]
2423 fn move_up_clamps_at_top() {
2424 let mut board = BoardState {
2425 columns: vec![BoardColumn {
2426 name: "a".into(),
2427 cards: vec![Card {
2428 number: 1,
2429 title: "X".into(),
2430 priority: crate::model::Priority::Medium,
2431 labels: vec![],
2432 assignee: None,
2433 filename: "x.md".into(),
2434 commit: None,
2435 created: "2026-01-01".into(),
2436 due_date: None,
2437 status: "todo".into(),
2438 category: None,
2439 category_color: None,
2440 done: false,
2441 cycle: None,
2442 milestone: None,
2443 blocked: None,
2444 children: None,
2445 parent: None,
2446 depth: 0,
2447 archived: false,
2448 related: 0,
2449 }],
2450 }],
2451 mismatch: BoardColumn {
2452 name: "Mismatched".into(),
2453 cards: vec![],
2454 },
2455 active_col: 0,
2456 selected: 0,
2457 filter: None,
2458 hover: None,
2459 hover_col: None,
2460 hidden: vec![],
2461 show_mismatch: true,
2462 };
2463 board.move_up();
2464 assert_eq!(board.selected, 0);
2465 }
2466
2467 #[test]
2468 fn move_down_clamps_at_bottom() {
2469 let mut board = BoardState {
2470 columns: vec![BoardColumn {
2471 name: "a".into(),
2472 cards: vec![Card {
2473 number: 1,
2474 title: "X".into(),
2475 priority: crate::model::Priority::Medium,
2476 labels: vec![],
2477 assignee: None,
2478 filename: "x.md".into(),
2479 commit: None,
2480 created: "2026-01-01".into(),
2481 due_date: None,
2482 status: "todo".into(),
2483 category: None,
2484 category_color: None,
2485 done: false,
2486 cycle: None,
2487 milestone: None,
2488 blocked: None,
2489 children: None,
2490 parent: None,
2491 depth: 0,
2492 archived: false,
2493 related: 0,
2494 }],
2495 }],
2496 mismatch: BoardColumn {
2497 name: "Mismatched".into(),
2498 cards: vec![],
2499 },
2500 active_col: 0,
2501 selected: 0,
2502 filter: None,
2503 hover: None,
2504 hover_col: None,
2505 hidden: vec![],
2506 show_mismatch: true,
2507 };
2508 board.move_down();
2509 assert_eq!(board.selected, 0);
2510 }
2511
2512 #[test]
2513 fn render_smoke_does_not_panic_on_tiny_frame() {
2514 use ratatui::Terminal;
2515 use ratatui::backend::TestBackend;
2516
2517 let backend = TestBackend::new(5, 3);
2518 let mut terminal = Terminal::new(backend).unwrap();
2519 let board = BoardState {
2520 columns: vec![],
2521 mismatch: BoardColumn {
2522 name: "Mismatched".into(),
2523 cards: vec![],
2524 },
2525 active_col: 0,
2526 selected: 0,
2527 filter: None,
2528 hover: None,
2529 hover_col: None,
2530 hidden: vec![],
2531 show_mismatch: true,
2532 };
2533 terminal
2534 .draw(|f| {
2535 render(
2536 &board,
2537 f,
2538 f.area(),
2539 &None,
2540 None,
2541 BoardDisplay::default(),
2542 &crate::ui::color::Theme::default(),
2543 );
2544 })
2545 .unwrap();
2546 }
2547
2548 #[test]
2549 fn render_smoke_full_board() {
2550 use ratatui::Terminal;
2551 use ratatui::backend::TestBackend;
2552
2553 let backend = TestBackend::new(80, 24);
2554 let mut terminal = Terminal::new(backend).unwrap();
2555 let board = BoardState {
2556 columns: vec![
2557 BoardColumn {
2558 name: "todo".into(),
2559 cards: vec![],
2560 },
2561 BoardColumn {
2562 name: "done".into(),
2563 cards: vec![],
2564 },
2565 ],
2566 mismatch: BoardColumn {
2567 name: "Mismatched".into(),
2568 cards: vec![],
2569 },
2570 active_col: 0,
2571 selected: 0,
2572 filter: None,
2573 hover: None,
2574 hover_col: None,
2575 hidden: vec![],
2576 show_mismatch: true,
2577 };
2578 terminal
2579 .draw(|f| {
2580 render(
2581 &board,
2582 f,
2583 f.area(),
2584 &Some("test msg".into()),
2585 None,
2586 BoardDisplay::default(),
2587 &crate::ui::color::Theme::default(),
2588 );
2589 })
2590 .unwrap();
2591 }
2592
2593 #[test]
2594 fn render_selected_multiline_card_highlights_all_wrapped_lines() {
2595 use ratatui::Terminal;
2596 use ratatui::backend::TestBackend;
2597
2598 let title = "This is a deliberately long card title that will not fit on a single line inside the column and must wrap more than once";
2599 let card = crate::ui::board::Card {
2600 number: 7,
2601 title: title.into(),
2602 priority: crate::model::Priority::Medium,
2603 labels: vec![],
2604 assignee: None,
2605 filename: "x.md".into(),
2606 commit: None,
2607 created: "2026-01-01".into(),
2608 due_date: None,
2609 status: "todo".into(),
2610 category: None,
2611 category_color: None,
2612 done: false,
2613 cycle: None,
2614 milestone: None,
2615 blocked: None,
2616 children: None,
2617 parent: None,
2618 depth: 0,
2619 archived: false,
2620 related: 0,
2621 };
2622 let backend = TestBackend::new(80, 24);
2623 let mut terminal = Terminal::new(backend).unwrap();
2624 let board = BoardState {
2625 columns: vec![BoardColumn {
2626 name: "todo".into(),
2627 cards: vec![card],
2628 }],
2629 mismatch: BoardColumn {
2630 name: "Mismatched".into(),
2631 cards: vec![],
2632 },
2633 active_col: 0,
2634 selected: 0,
2635 filter: None,
2636 hover: None,
2637 hover_col: None,
2638 hidden: vec![],
2639 show_mismatch: false,
2640 };
2641 let display = BoardDisplay {
2642 wrap_titles: true,
2643 title_lines: 3,
2644 };
2645 terminal
2646 .draw(|f| {
2647 render(
2648 &board,
2649 f,
2650 f.area(),
2651 &None,
2652 None,
2653 display,
2654 &crate::ui::color::Theme::default(),
2655 );
2656 })
2657 .unwrap();
2658 let buffer = terminal.backend().buffer();
2659
2660 let mut highlighted = 0;
2664 for y in 1..buffer.area().height - 1 {
2665 let cell = &buffer[(2, y)];
2666 if cell.bg != Color::DarkGray {
2667 break;
2668 }
2669 assert!(
2670 cell.modifier.contains(Modifier::BOLD),
2671 "highlighted continuation line must stay bold: {cell:?}"
2672 );
2673 highlighted += 1;
2674 }
2675 assert!(
2676 highlighted >= 2,
2677 "wrapped card highlights every line, got {highlighted}"
2678 );
2679 assert!(
2680 highlighted <= display.title_lines,
2681 "highlight never exceeds the line limit: {highlighted}"
2682 );
2683 }
2684
2685 #[test]
2686 fn render_grouped_mode_shows_section_headers() {
2687 use ratatui::Terminal;
2688 use ratatui::backend::TestBackend;
2689
2690 let backend = TestBackend::new(80, 24);
2691 let mut terminal = Terminal::new(backend).unwrap();
2692 let board = BoardState {
2693 columns: vec![BoardColumn {
2694 name: "todo".into(),
2695 cards: vec![
2696 Card {
2697 number: 1,
2698 title: "A".into(),
2699 priority: crate::model::Priority::Medium,
2700 labels: vec![],
2701 assignee: None,
2702 filename: "a.md".into(),
2703 commit: None,
2704 created: "2026-01-01".into(),
2705 due_date: None,
2706 status: "todo".into(),
2707 category: None,
2708 category_color: None,
2709 done: false,
2710 cycle: Some("Cycle 1".into()),
2711 milestone: None,
2712 blocked: None,
2713 children: None,
2714 parent: None,
2715 depth: 0,
2716 archived: false,
2717 related: 0,
2718 },
2719 Card {
2720 number: 2,
2721 title: "B".into(),
2722 priority: crate::model::Priority::Medium,
2723 labels: vec![],
2724 assignee: None,
2725 filename: "b.md".into(),
2726 commit: None,
2727 created: "2026-01-01".into(),
2728 due_date: None,
2729 status: "todo".into(),
2730 category: None,
2731 category_color: None,
2732 done: true,
2733 cycle: Some("Cycle 1".into()),
2734 milestone: None,
2735 blocked: None,
2736 children: None,
2737 parent: None,
2738 depth: 0,
2739 archived: false,
2740 related: 0,
2741 },
2742 Card {
2743 number: 3,
2744 title: "C".into(),
2745 priority: crate::model::Priority::Medium,
2746 labels: vec![],
2747 assignee: None,
2748 filename: "c.md".into(),
2749 commit: None,
2750 created: "2026-01-01".into(),
2751 due_date: None,
2752 status: "todo".into(),
2753 category: None,
2754 category_color: None,
2755 done: false,
2756 cycle: Some("Cycle 2".into()),
2757 milestone: None,
2758 blocked: None,
2759 children: None,
2760 parent: None,
2761 depth: 0,
2762 archived: false,
2763 related: 0,
2764 },
2765 ],
2766 }],
2767 mismatch: BoardColumn {
2768 name: "Mismatched".into(),
2769 cards: vec![],
2770 },
2771 active_col: 0,
2772 selected: 0,
2773 filter: Some("group:cycle".into()),
2774 hover: None,
2775 hover_col: None,
2776 hidden: vec![],
2777 show_mismatch: false,
2778 };
2779 terminal
2780 .draw(|f| {
2781 render(
2782 &board,
2783 f,
2784 f.area(),
2785 &None,
2786 None,
2787 BoardDisplay::default(),
2788 &crate::ui::color::Theme::default(),
2789 );
2790 })
2791 .unwrap();
2792
2793 let buffer = terminal.backend().buffer();
2794 let mut lines = Vec::new();
2795 for y in 0..buffer.area.height {
2796 let line = (0..buffer.area.width)
2797 .map(|x| buffer[(x, y)].symbol())
2798 .collect::<String>();
2799 lines.push(line);
2800 }
2801 let rendered = lines.join("\n");
2802 assert!(rendered.contains("-- cycle: Cycle 1 (1/2 done, 50%) --"));
2803 assert!(rendered.contains("-- cycle: Cycle 2 (0/1 done, 0%) --"));
2804 }
2805
2806 #[test]
2807 fn build_filtered_keeps_only_fuzzy_matches() {
2808 use crate::model::Issue;
2809 use crate::search::Corpus;
2810
2811 let to_issue = |number: u32, title: &str| Issue {
2812 number,
2813 title: title.to_string(),
2814 status: crate::model::Status::new("todo"),
2815 labels: vec![],
2816 priority: crate::model::Priority::Medium,
2817 assignee: None,
2818 estimate: None,
2819 commit: None,
2820 gitlab_id: None,
2821 linear_id: None,
2822 mission: None,
2823 task: None,
2824 relationships: vec![],
2825 related_issues: vec![],
2826 files: vec![],
2827 cycles: None,
2828 milestones: None,
2829 reviewer: None,
2830 start_date: None,
2831 due_date: None,
2832 created: "2026-01-01".into(),
2833 type_: None,
2834 updated: "2026-01-01".into(),
2835 filename: format!("2026-01-01-{number}.md"),
2836 body: String::new(),
2837 history: crate::model::History::new(),
2838 has_retired_links_key: false,
2839 parent: None,
2840 extra_frontmatter: std::collections::BTreeMap::new(),
2841 archived: false,
2842 entered_at: None,
2843 source: None,
2844 };
2845 let corpus = Corpus::with_texts(
2846 vec![to_issue(1, "Fix login bug"), to_issue(2, "Polish exports")],
2847 vec!["Fix login bug".to_string(), "Polish exports".to_string()],
2848 );
2849 let config = Config::default();
2850 let (_, resolution) = crate::workflow::active_resolution(&config);
2851 let board_config = config.default_config();
2852
2853 let board = BoardState::build_filtered(
2854 board_config,
2855 &config,
2856 &corpus,
2857 "log",
2858 &StructuredFilter::default(),
2859 &resolution,
2860 );
2861 assert_eq!(board.columns.len(), board_config.columns.len());
2862 assert_eq!(board.column_cards(0).len(), 1);
2863 assert_eq!(board.column_cards(0)[0].title, "Fix login bug");
2864 assert_eq!(board.filter.as_deref(), Some("log"));
2865
2866 let none = BoardState::build_filtered(
2867 board_config,
2868 &config,
2869 &corpus,
2870 "zzw",
2871 &StructuredFilter::default(),
2872 &resolution,
2873 );
2874 assert!(none.is_empty());
2875
2876 let empty = BoardState::build_filtered(
2877 board_config,
2878 &config,
2879 &corpus,
2880 "",
2881 &StructuredFilter::default(),
2882 &resolution,
2883 );
2884 assert_eq!(empty.column_cards(0).len(), 2, "empty query keeps all");
2885 assert!(empty.filter.is_some());
2886 }
2887
2888 #[test]
2889 fn build_filtered_group_cycle_keeps_original_titles() {
2890 use crate::model::Issue;
2891 use crate::search::Corpus;
2892
2893 let issue = |number: u32, title: &str, status: &str, cycles: &[&str]| Issue {
2894 number,
2895 title: title.to_string(),
2896 status: crate::model::Status::new(status),
2897 labels: vec![],
2898 priority: crate::model::Priority::Medium,
2899 assignee: None,
2900 estimate: None,
2901 commit: None,
2902 gitlab_id: None,
2903 linear_id: None,
2904 mission: None,
2905 task: None,
2906 relationships: vec![],
2907 related_issues: vec![],
2908 files: vec![],
2909 cycles: Some(cycles.iter().map(|c| (*c).to_string()).collect()),
2910 milestones: None,
2911 reviewer: None,
2912 start_date: None,
2913 due_date: None,
2914 created: "2026-01-01".into(),
2915 type_: None,
2916 updated: "2026-01-01".into(),
2917 filename: format!("2026-01-01-{number}.md"),
2918 body: String::new(),
2919 history: crate::model::History::new(),
2920 has_retired_links_key: false,
2921 parent: None,
2922 extra_frontmatter: std::collections::BTreeMap::new(),
2923 archived: false,
2924 entered_at: None,
2925 source: None,
2926 };
2927
2928 let corpus = Corpus::with_texts(
2929 vec![
2930 issue(1, "A", "todo", &["Cycle 1"]),
2931 issue(2, "B", "done", &["Cycle 1"]),
2932 issue(3, "C", "todo", &["Cycle 2"]),
2933 ],
2934 vec![
2935 "cycle: Cycle 1\nstatus: todo".to_string(),
2936 "cycle: Cycle 1\nstatus: done".to_string(),
2937 "cycle: Cycle 2\nstatus: todo".to_string(),
2938 ],
2939 );
2940
2941 let config = Config::default();
2942 let (_, resolution) = crate::workflow::active_resolution(&config);
2943 let board = BoardState::build_filtered(
2944 config.default_config(),
2945 &config,
2946 &corpus,
2947 "group:cycle",
2948 &StructuredFilter::default(),
2949 &resolution,
2950 );
2951 let titles: Vec<String> = board
2952 .columns
2953 .iter()
2954 .flat_map(|col| col.cards.iter().map(|c| c.title.clone()))
2955 .collect();
2956 assert!(titles.iter().any(|t| t == "A"));
2957 assert!(titles.iter().any(|t| t == "B"));
2958 assert!(titles.iter().any(|t| t == "C"));
2959 }
2960
2961 #[test]
2962 fn group_footer_reports_done_progress() {
2963 let cards = vec![
2964 Card {
2965 number: 1,
2966 title: "A".into(),
2967 priority: crate::model::Priority::Medium,
2968 labels: vec![],
2969 assignee: None,
2970 filename: "a.md".into(),
2971 commit: None,
2972 created: "2026-01-01".into(),
2973 due_date: None,
2974 status: "todo".into(),
2975 category: None,
2976 category_color: None,
2977 done: false,
2978 cycle: Some("Cycle 1".into()),
2979 milestone: None,
2980 blocked: None,
2981 children: None,
2982 parent: None,
2983 depth: 0,
2984 archived: false,
2985 related: 0,
2986 },
2987 Card {
2988 number: 2,
2989 title: "B".into(),
2990 priority: crate::model::Priority::Medium,
2991 labels: vec![],
2992 assignee: None,
2993 filename: "b.md".into(),
2994 commit: None,
2995 created: "2026-01-01".into(),
2996 due_date: None,
2997 status: "done".into(),
2998 category: None,
2999 category_color: None,
3000 done: true,
3001 cycle: Some("Cycle 1".into()),
3002 milestone: None,
3003 blocked: None,
3004 children: None,
3005 parent: None,
3006 depth: 0,
3007 archived: false,
3008 related: 0,
3009 },
3010 ];
3011 let text = BoardState::group_footer(&cards, Some("cycle")).unwrap();
3012 assert!(text.contains("Cycle 1 1/2 50%"));
3013 }
3014
3015 #[test]
3016 fn build_filtered_bare_number_matches_exact_issue_only() {
3017 use crate::model::Issue;
3018 use crate::search::Corpus;
3019
3020 let to_issue = |number: u32, title: &str, status: &str| Issue {
3021 number,
3022 title: title.to_string(),
3023 status: crate::model::Status::new(status),
3024 labels: vec![],
3025 priority: crate::model::Priority::Medium,
3026 assignee: None,
3027 estimate: None,
3028 commit: None,
3029 gitlab_id: None,
3030 linear_id: None,
3031 mission: None,
3032 task: None,
3033 relationships: vec![],
3034 related_issues: vec![],
3035 files: vec![],
3036 cycles: None,
3037 milestones: None,
3038 reviewer: None,
3039 start_date: None,
3040 due_date: None,
3041 created: "2026-01-01".into(),
3042 type_: None,
3043 updated: "2026-01-01".into(),
3044 filename: format!("2026-01-01-{number}.md"),
3045 body: String::new(),
3046 history: crate::model::History::new(),
3047 has_retired_links_key: false,
3048 parent: None,
3049 extra_frontmatter: std::collections::BTreeMap::new(),
3050 archived: false,
3051 entered_at: None,
3052 source: None,
3053 };
3054 let corpus = Corpus::with_texts(
3055 vec![
3056 to_issue(55, "Concrete target", "todo"),
3057 to_issue(155, "Digit-rich title", "todo"),
3058 to_issue(3, "Login bug", "todo"),
3059 ],
3060 vec![
3061 "Concrete target".to_string(),
3062 "Digit-rich title".to_string(),
3063 "Login bug".to_string(),
3064 ],
3065 );
3066 let config = Config::default();
3067 let (_, resolution) = crate::workflow::active_resolution(&config);
3068 let board_config = config.default_config();
3069
3070 let exact = BoardState::build_filtered(
3071 board_config,
3072 &config,
3073 &corpus,
3074 "55",
3075 &StructuredFilter::default(),
3076 &resolution,
3077 );
3078 assert_eq!(exact.column_cards(0).len(), 1, "only issue 55 matches");
3079 assert_eq!(exact.column_cards(0)[0].title, "Concrete target");
3080 assert_eq!(
3081 exact.column_cards(0)[0].number,
3082 55,
3083 "issue 155's digit sequence must not qualify"
3084 );
3085 assert_eq!(exact.filter.as_deref(), Some("55"));
3086
3087 let miss = BoardState::build_filtered(
3089 board_config,
3090 &config,
3091 &corpus,
3092 "999",
3093 &StructuredFilter::default(),
3094 &resolution,
3095 );
3096 assert!(miss.is_empty(), "no issue 999 => zero matches");
3097
3098 let fuzzy = BoardState::build_filtered(
3100 board_config,
3101 &config,
3102 &corpus,
3103 "login",
3104 &StructuredFilter::default(),
3105 &resolution,
3106 );
3107 assert_eq!(fuzzy.column_cards(0).len(), 1);
3108 assert_eq!(fuzzy.column_cards(0)[0].title, "Login bug");
3109 }
3110
3111 #[test]
3112 fn build_filtered_mixed_query_keeps_fuzzy_not_number() {
3113 use crate::model::Issue;
3114 use crate::search::Corpus;
3115
3116 let to_issue = |number: u32, title: &str| Issue {
3117 number,
3118 title: title.to_string(),
3119 status: crate::model::Status::new("todo"),
3120 labels: vec![],
3121 priority: crate::model::Priority::Medium,
3122 assignee: None,
3123 estimate: None,
3124 commit: None,
3125 gitlab_id: None,
3126 linear_id: None,
3127 mission: None,
3128 task: None,
3129 relationships: vec![],
3130 related_issues: vec![],
3131 files: vec![],
3132 cycles: None,
3133 milestones: None,
3134 reviewer: None,
3135 start_date: None,
3136 due_date: None,
3137 created: "2026-01-01".into(),
3138 type_: None,
3139 updated: "2026-01-01".into(),
3140 filename: format!("2026-01-01-{number}.md"),
3141 body: String::new(),
3142 history: crate::model::History::new(),
3143 has_retired_links_key: false,
3144 parent: None,
3145 extra_frontmatter: std::collections::BTreeMap::new(),
3146 archived: false,
3147 entered_at: None,
3148 source: None,
3149 };
3150 let corpus = Corpus::with_texts(
3151 vec![
3152 to_issue(55, "Concrete target"),
3153 to_issue(155, "Digit-rich title"),
3154 ],
3155 vec![
3156 "Concrete target".to_string(),
3157 "Digit-rich title".to_string(),
3158 ],
3159 );
3160 let config = Config::default();
3161 let (_, resolution) = crate::workflow::active_resolution(&config);
3162 let board_config = config.default_config();
3163
3164 let mixed = BoardState::build_filtered(
3167 board_config,
3168 &config,
3169 &corpus,
3170 "55bug",
3171 &StructuredFilter::default(),
3172 &resolution,
3173 );
3174 assert!(mixed.is_empty(), "mixed query is not an exact-number query");
3175 }
3176
3177 #[test]
3178 fn build_filtered_number_composes_with_structured_filter_and() {
3179 use crate::model::Issue;
3180 use crate::search::Corpus;
3181
3182 let cases = [
3183 (55u32, "Issue 55 todo", "todo"),
3184 (156u32, "Issue 156 todo", "todo"),
3185 (57u32, "Issue 57 done", "done"),
3186 ];
3187 let corpus = Corpus::with_texts(
3188 cases
3189 .iter()
3190 .map(|&(number, title, status)| Issue {
3191 number,
3192 title: title.to_string(),
3193 status: crate::model::Status::new(status),
3194 labels: vec![],
3195 priority: crate::model::Priority::Medium,
3196 assignee: None,
3197 estimate: None,
3198 commit: None,
3199 gitlab_id: None,
3200 linear_id: None,
3201 mission: None,
3202 task: None,
3203 relationships: vec![],
3204 related_issues: vec![],
3205 files: vec![],
3206 cycles: None,
3207 milestones: None,
3208 reviewer: None,
3209 start_date: None,
3210 due_date: None,
3211 created: "2026-01-01".into(),
3212 type_: None,
3213 updated: "2026-01-01".into(),
3214 filename: format!("2026-01-01-{}.md", number),
3215 body: String::new(),
3216 history: crate::model::History::new(),
3217 has_retired_links_key: false,
3218 parent: None,
3219 extra_frontmatter: std::collections::BTreeMap::new(),
3220 archived: false,
3221 entered_at: None,
3222 source: None,
3223 })
3224 .collect(),
3225 cases
3226 .iter()
3227 .map(|(_, title, _)| title.to_string())
3228 .collect(),
3229 );
3230 let config = Config::default();
3231 let (_, resolution) = crate::workflow::active_resolution(&config);
3232 let board_config = config.default_config();
3233 let status_todo = StructuredFilter {
3234 status: Some("todo".to_string()),
3235 ..Default::default()
3236 };
3237 let status_done = StructuredFilter {
3238 status: Some("done".to_string()),
3239 ..Default::default()
3240 };
3241
3242 let matches = BoardState::build_filtered(
3245 board_config,
3246 &config,
3247 &corpus,
3248 "55",
3249 &status_todo,
3250 &resolution,
3251 );
3252 assert_eq!(matches.column_cards(0).len(), 1);
3253 assert_eq!(matches.column_cards(0)[0].title, "Issue 55 todo");
3254
3255 let done_criteria = BoardState::build_filtered(
3258 board_config,
3259 &config,
3260 &corpus,
3261 "55",
3262 &status_done,
3263 &resolution,
3264 );
3265 assert!(done_criteria.is_empty(), "criteria failure hides issue 55");
3266 }
3267
3268 #[test]
3269 fn is_empty_true_only_with_no_cards_anywhere() {
3270 let board = BoardState {
3271 columns: vec![BoardColumn {
3272 name: "a".into(),
3273 cards: vec![],
3274 }],
3275 mismatch: BoardColumn {
3276 name: "Mismatched".into(),
3277 cards: vec![],
3278 },
3279 active_col: 0,
3280 selected: 0,
3281 filter: None,
3282 hover: None,
3283 hover_col: None,
3284 hidden: vec![],
3285 show_mismatch: true,
3286 };
3287 assert!(board.is_empty());
3288
3289 let populated = BoardState {
3290 columns: vec![BoardColumn {
3291 name: "a".into(),
3292 cards: vec![Card {
3293 number: 1,
3294 title: "X".into(),
3295 priority: crate::model::Priority::Medium,
3296 labels: vec![],
3297 assignee: None,
3298 filename: "x.md".into(),
3299 commit: None,
3300 created: "2026-01-01".into(),
3301 due_date: None,
3302 status: "todo".into(),
3303 category: None,
3304 category_color: None,
3305 done: false,
3306 cycle: None,
3307 milestone: None,
3308 blocked: None,
3309 children: None,
3310 parent: None,
3311 depth: 0,
3312 archived: false,
3313 related: 0,
3314 }],
3315 }],
3316 mismatch: BoardColumn {
3317 name: "Mismatched".into(),
3318 cards: vec![],
3319 },
3320 active_col: 0,
3321 selected: 0,
3322 filter: None,
3323 hover: None,
3324 hover_col: None,
3325 hidden: vec![],
3326 show_mismatch: true,
3327 };
3328 assert!(!populated.is_empty());
3329 }
3330
3331 #[test]
3332 fn build_splits_hidden_columns_out_of_visible_surface() {
3333 use crate::config::{BoardConfiguration, Config};
3334
3335 let mut config = Config::default();
3336 config.kanban.configurations.insert(
3337 "default".to_string(),
3338 BoardConfiguration {
3339 columns: vec!["todo".into(), "done".into()],
3340 hidden_columns: vec!["done".into()],
3341 ..BoardConfiguration::default()
3342 },
3343 );
3344 let store = crate::storage::Store::open(&config).unwrap();
3345 let board = BoardState::build(&store, config.default_config());
3346 assert_eq!(board.columns.len(), 1);
3347 assert_eq!(board.columns[0].name, "todo");
3348 assert_eq!(board.hidden.len(), 1);
3349 assert_eq!(board.hidden[0].name, "done");
3350 assert_eq!(board.total_cols(), 1);
3351 }
3352
3353 #[test]
3354 fn move_left_skips_hidden_column() {
3355 let mut board = BoardState {
3356 columns: vec![BoardColumn {
3357 name: "visible-a".into(),
3358 cards: vec![],
3359 }],
3360 hidden: vec![BoardColumn {
3361 name: "hidden-b".into(),
3362 cards: vec![],
3363 }],
3364 mismatch: BoardColumn {
3365 name: "Mismatched".into(),
3366 cards: vec![],
3367 },
3368 active_col: 0,
3369 selected: 0,
3370 filter: None,
3371 hover: None,
3372 hover_col: None,
3373 show_mismatch: true,
3374 };
3375 board.move_left();
3376 assert_eq!(board.active_col, 0, "single visible column wraps to itself");
3377 }
3378
3379 #[test]
3380 fn move_right_skips_hidden_column() {
3381 let mut board = BoardState {
3382 columns: vec![BoardColumn {
3383 name: "visible-a".into(),
3384 cards: vec![],
3385 }],
3386 hidden: vec![BoardColumn {
3387 name: "hidden-b".into(),
3388 cards: vec![],
3389 }],
3390 mismatch: BoardColumn {
3391 name: "Mismatched".into(),
3392 cards: vec![],
3393 },
3394 active_col: 0,
3395 selected: 0,
3396 filter: None,
3397 hover: None,
3398 hover_col: None,
3399 show_mismatch: true,
3400 };
3401 board.move_right();
3402 assert_eq!(board.active_col, 0, "single visible column wraps to itself");
3403 }
3404
3405 #[test]
3406 fn all_columns_hidden_board_is_displayable_and_navigation_safe() {
3407 let mut board = BoardState {
3408 columns: vec![],
3409 hidden: vec![BoardColumn {
3410 name: "only-hidden".into(),
3411 cards: vec![],
3412 }],
3413 mismatch: BoardColumn {
3414 name: "Mismatched".into(),
3415 cards: vec![],
3416 },
3417 active_col: 0,
3418 selected: 0,
3419 filter: None,
3420 hover: None,
3421 hover_col: None,
3422 show_mismatch: false,
3423 };
3424 assert_eq!(board.total_cols(), 0);
3425 board.move_left();
3426 board.move_right();
3427 assert_eq!(board.active_col, 0, "zero-column board must not panic");
3428 let board2 = BoardState {
3429 columns: vec![],
3430 hidden: vec![],
3431 mismatch: BoardColumn {
3432 name: "Mismatched".into(),
3433 cards: vec![],
3434 },
3435 active_col: 0,
3436 selected: 0,
3437 filter: None,
3438 hover: None,
3439 hover_col: None,
3440 show_mismatch: false,
3441 };
3442 assert_eq!(board2.total_cols(), 0);
3443 }
3444
3445 fn write_issue_md(
3446 dir: &std::path::Path,
3447 name: &str,
3448 number: u32,
3449 title: &str,
3450 status: &str,
3451 created: &str,
3452 ) {
3453 let contents = format!(
3454 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"{created}\"\nupdated: \"{created}\"\n---\n\nBody text."
3455 );
3456 std::fs::write(dir.join(name), contents).unwrap();
3457 }
3458
3459 #[test]
3460 fn order_by_sort_desc_asc_none() {
3461 let mk = |created: &str| Card {
3462 number: 1,
3463 title: created.to_string(),
3464 priority: crate::model::Priority::Medium,
3465 labels: vec![],
3466 assignee: None,
3467 filename: "x.md".into(),
3468 commit: None,
3469 created: created.to_string(),
3470 due_date: None,
3471 status: "todo".into(),
3472 category: None,
3473 category_color: None,
3474 done: false,
3475 cycle: None,
3476 milestone: None,
3477 blocked: None,
3478 children: None,
3479 parent: None,
3480 depth: 0,
3481 archived: false,
3482 related: 0,
3483 };
3484 let dates =
3485 |cards: &[Card]| -> Vec<String> { cards.iter().map(|c| c.created.clone()).collect() };
3486
3487 let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
3488 crate::config::OrderBy::CreatedAtDesc.sort(&mut cards);
3489 assert_eq!(dates(&cards), ["2026-03-01", "2026-02-01", "2026-01-01"]);
3490
3491 let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
3492 crate::config::OrderBy::CreatedAtAsc.sort(&mut cards);
3493 assert_eq!(dates(&cards), ["2026-01-01", "2026-02-01", "2026-03-01"]);
3494
3495 let mut cards = vec![mk("2026-03-01"), mk("2026-01-01")];
3496 crate::config::OrderBy::None.sort(&mut cards);
3497 assert_eq!(
3498 dates(&cards),
3499 ["2026-03-01", "2026-01-01"],
3500 "none is a no-op"
3501 );
3502 }
3503
3504 #[test]
3505 fn build_orders_most_recent_first_by_default() {
3506 use crate::config::Config;
3507 use std::path::PathBuf;
3508 let tmp = tempfile::TempDir::new().unwrap();
3509 let dir = tmp.path().join("issues");
3510 std::fs::create_dir_all(&dir).unwrap();
3511 write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
3512 write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
3513 write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
3514 let config = Config {
3515 issues_dir: PathBuf::from(&dir),
3516 ..Config::default()
3517 };
3518 let store = crate::storage::Store::open(&config).unwrap();
3519 let board = BoardState::build(&store, config.default_config());
3520 let titles: Vec<&str> = board
3521 .column_cards(0)
3522 .iter()
3523 .map(|c| c.title.as_str())
3524 .collect();
3525 assert_eq!(titles, ["new", "mid", "old"], "default desc: newest first");
3526 }
3527
3528 #[test]
3529 fn build_applies_created_at_asc_and_none() {
3530 use crate::config::OrderBy;
3531 use std::path::PathBuf;
3532 let tmp = tempfile::TempDir::new().unwrap();
3533 let dir = tmp.path().join("issues");
3534 std::fs::create_dir_all(&dir).unwrap();
3535 write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
3536 write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
3537 write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
3538 let config = crate::config::Config {
3539 issues_dir: PathBuf::from(&dir),
3540 ..crate::config::Config::default()
3541 };
3542 let store = crate::storage::Store::open(&config).unwrap();
3543
3544 let mut cfg_asc = config.clone();
3545 cfg_asc
3546 .kanban
3547 .configurations
3548 .get_mut("default")
3549 .unwrap()
3550 .order_by = OrderBy::CreatedAtAsc;
3551 let board_asc = BoardState::build(&store, cfg_asc.default_config());
3552 let titles: Vec<&str> = board_asc
3553 .column_cards(0)
3554 .iter()
3555 .map(|c| c.title.as_str())
3556 .collect();
3557 assert_eq!(titles, ["old", "mid", "new"]);
3558
3559 let mut cfg_none = config;
3560 cfg_none
3561 .kanban
3562 .configurations
3563 .get_mut("default")
3564 .unwrap()
3565 .order_by = OrderBy::None;
3566 let board_none = BoardState::build(&store, cfg_none.default_config());
3567 let titles: Vec<&str> = board_none
3568 .column_cards(0)
3569 .iter()
3570 .map(|c| c.title.as_str())
3571 .collect();
3572 assert_eq!(
3573 titles,
3574 ["old", "mid", "new"],
3575 "none keeps the store's filename-ascending order"
3576 );
3577 }
3578
3579 #[test]
3580 fn build_applies_sparse_manual_card_order_when_present() {
3581 use crate::config::Config;
3582 use std::path::PathBuf;
3583 let tmp = tempfile::TempDir::new().unwrap();
3584 let dir = tmp.path().join("issues");
3585 std::fs::create_dir_all(&dir).unwrap();
3586 write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
3587 write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
3588 write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
3589 let config = Config {
3590 issues_dir: PathBuf::from(&dir),
3591 ..Config::default()
3592 };
3593 let store = crate::storage::Store::open(&config).unwrap();
3594 let mut board_cfg = config.default_config().clone();
3595 board_cfg.card_order = vec!["2026-02-01-mid.md".into(), "2026-03-01-new.md".into()];
3596 let board = BoardState::build(&store, &board_cfg);
3597 let titles: Vec<&str> = board
3598 .column_cards(0)
3599 .iter()
3600 .map(|c| c.title.as_str())
3601 .collect();
3602 assert_eq!(titles, ["mid", "new", "old"], "manual order wins");
3603 }
3604
3605 #[test]
3606 fn sparse_card_order_only_returns_changed_cards() {
3607 use crate::config::Config;
3608 use std::path::PathBuf;
3609
3610 let tmp = tempfile::TempDir::new().unwrap();
3611 let dir = tmp.path().join("issues");
3612 std::fs::create_dir_all(&dir).unwrap();
3613 write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
3614 write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
3615 write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
3616 let config = Config {
3617 issues_dir: PathBuf::from(&dir),
3618 ..Config::default()
3619 };
3620 let store = crate::storage::Store::open(&config).unwrap();
3621 let board_cfg = config.default_config().clone();
3622 let mut cards = BoardState::build(&store, &board_cfg)
3623 .column_cards(0)
3624 .to_vec();
3625 cards.swap(0, 1);
3626 let sparse = sparse_card_order(&board_cfg, &cards);
3627 assert_eq!(sparse, ["2026-02-01-mid.md", "2026-03-01-new.md"]);
3628 }
3629
3630 #[test]
3631 fn build_filtered_applies_order_by() {
3632 use crate::model::Issue;
3633 use crate::search::{Corpus, StructuredFilter};
3634
3635 let to_issue = |number: u32, title: &str, created: &str| Issue {
3636 number,
3637 title: title.to_string(),
3638 status: crate::model::Status::new("todo"),
3639 labels: vec![],
3640 priority: crate::model::Priority::Medium,
3641 assignee: None,
3642 estimate: None,
3643 commit: None,
3644 gitlab_id: None,
3645 linear_id: None,
3646 mission: None,
3647 task: None,
3648 relationships: vec![],
3649 related_issues: vec![],
3650 files: vec![],
3651 cycles: None,
3652 milestones: None,
3653 reviewer: None,
3654 start_date: None,
3655 due_date: None,
3656 created: created.into(),
3657 type_: None,
3658 updated: "2026-01-01".into(),
3659 filename: format!("2026-01-01-{number}.md"),
3660 body: String::new(),
3661 history: crate::model::History::new(),
3662 has_retired_links_key: false,
3663 parent: None,
3664 extra_frontmatter: std::collections::BTreeMap::new(),
3665 archived: false,
3666 entered_at: None,
3667 source: None,
3668 };
3669 let corpus = Corpus::with_texts(
3670 vec![
3671 to_issue(1, "old", "2026-01-01"),
3672 to_issue(2, "new", "2026-03-01"),
3673 to_issue(3, "mid", "2026-02-01"),
3674 ],
3675 vec!["old".into(), "new".into(), "mid".into()],
3676 );
3677 let config = crate::config::Config::default();
3678 let (_, resolution) = crate::workflow::active_resolution(&config);
3679 let board = BoardState::build_filtered(
3680 config.default_config(),
3681 &config,
3682 &corpus,
3683 "",
3684 &StructuredFilter::default(),
3685 &resolution,
3686 );
3687 let titles: Vec<&str> = board
3688 .column_cards(0)
3689 .iter()
3690 .map(|c| c.title.as_str())
3691 .collect();
3692 assert_eq!(
3693 titles,
3694 ["new", "mid", "old"],
3695 "default desc applies to filtered results too"
3696 );
3697 }
3698
3699 fn tree_card(number: u32, parent: Option<u32>) -> Card {
3702 Card {
3703 number,
3704 title: format!("Issue {number}"),
3705 priority: Priority::Medium,
3706 labels: vec![],
3707 assignee: None,
3708 filename: format!("{number}.md"),
3709 commit: None,
3710 created: "2026-01-01".into(),
3711 due_date: None,
3712 status: "todo".into(),
3713 category: None,
3714 category_color: None,
3715 done: false,
3716 cycle: None,
3717 milestone: None,
3718 blocked: None,
3719 children: None,
3720 parent,
3721 depth: 0,
3722 archived: false,
3723 related: 0,
3724 }
3725 }
3726
3727 #[test]
3728 fn nest_children_places_a_child_after_its_parent() {
3729 let mut cards = vec![
3730 tree_card(2, Some(1)),
3731 tree_card(1, None),
3732 tree_card(3, None),
3733 ];
3734 nest_children(&mut cards);
3735 let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
3736 assert_eq!(
3737 shape,
3738 vec![(1, 0), (2, 1), (3, 0)],
3739 "the child moves up under its parent and is marked depth 1"
3740 );
3741 }
3742
3743 #[test]
3744 fn nest_children_keeps_adopted_siblings_in_their_existing_order() {
3745 let mut cards = vec![
3746 tree_card(1, None),
3747 tree_card(3, Some(1)),
3748 tree_card(2, Some(1)),
3749 ];
3750 nest_children(&mut cards);
3751 let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
3752 assert_eq!(shape, vec![(1, 0), (3, 1), (2, 1)]);
3753 }
3754
3755 #[test]
3756 fn nest_children_does_not_nest_a_grandchild() {
3757 let mut cards = vec![
3760 tree_card(1, None),
3761 tree_card(2, Some(1)),
3762 tree_card(3, Some(2)),
3763 ];
3764 nest_children(&mut cards);
3765 let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
3766 assert_eq!(shape, vec![(1, 0), (2, 1), (3, 0)]);
3767 }
3768
3769 #[test]
3770 fn nest_children_leaves_a_child_whose_parent_is_in_another_column() {
3771 let mut cards = vec![tree_card(2, Some(1)), tree_card(5, None)];
3773 nest_children(&mut cards);
3774 let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
3775 assert_eq!(shape, vec![(2, 0), (5, 0)]);
3776 }
3777
3778 #[test]
3779 fn nest_children_keeps_every_card_exactly_once() {
3780 let mut cards = vec![
3781 tree_card(4, None),
3782 tree_card(3, Some(1)),
3783 tree_card(1, None),
3784 tree_card(2, Some(1)),
3785 ];
3786 nest_children(&mut cards);
3787 let mut numbers: Vec<u32> = cards.iter().map(|c| c.number).collect();
3788 numbers.sort_unstable();
3789 assert_eq!(
3790 numbers,
3791 vec![1, 2, 3, 4],
3792 "no card is duplicated or dropped"
3793 );
3794 }
3795
3796 #[test]
3797 fn render_indents_a_sub_issue_without_changing_its_line_count() {
3798 use ratatui::Terminal;
3799 use ratatui::backend::TestBackend;
3800
3801 let width = 80u16;
3802 let height = 30u16;
3803 let mut child = tree_card(2, Some(1));
3804 child.title = "a sub-issue whose deliberately long title wraps across more \
3805 than a single rendered row inside the column"
3806 .into();
3807 child.depth = 1;
3808 let parent = tree_card(1, None);
3809
3810 let mut flat_child = child.clone();
3811 flat_child.depth = 0;
3812 assert_eq!(
3813 widgets::card_line_count(&child, width as usize, true, 3),
3814 widgets::card_line_count(&flat_child, width as usize, true, 3),
3815 "the indent must not change the card's rendered line count"
3816 );
3817
3818 let board = BoardState {
3819 columns: vec![BoardColumn {
3820 name: "todo".into(),
3821 cards: vec![parent, child],
3822 }],
3823 hidden: vec![],
3824 mismatch: BoardColumn {
3825 name: "Mismatched".into(),
3826 cards: vec![],
3827 },
3828 active_col: 0,
3829 selected: 0,
3830 filter: None,
3831 hover: None,
3832 hover_col: None,
3833 show_mismatch: false,
3834 };
3835 let display = BoardDisplay {
3836 wrap_titles: true,
3837 title_lines: 3,
3838 };
3839 let backend = TestBackend::new(width, height);
3840 let mut terminal = Terminal::new(backend).unwrap();
3841 terminal
3842 .draw(|f| {
3843 render(
3844 &board,
3845 f,
3846 f.area(),
3847 &None,
3848 None,
3849 display,
3850 &crate::ui::color::Theme::default(),
3851 );
3852 })
3853 .unwrap();
3854 let buffer = terminal.backend().buffer();
3855 let row = |y: u16| -> String { (0..width).map(|x| buffer[(x, y)].symbol()).collect() };
3856
3857 let symbol = |x: u16, y: u16| buffer[(x, y)].symbol().to_string();
3858 assert!(
3859 row(1).contains("#1"),
3860 "the parent draws first: {:?}",
3861 row(1)
3862 );
3863 assert_eq!(
3864 symbol(1, 1),
3865 "#",
3866 "a top-level card is flush with the column border: {:?}",
3867 row(1)
3868 );
3869 assert_eq!(
3870 (symbol(1, 2), symbol(2, 2)),
3871 (" ".to_string(), "2".to_string()),
3872 "the sub-issue draws a one-character ASCII whitespace margin and drops \
3873 the `#` (tix #1536): {:?}",
3874 row(2)
3875 );
3876 assert_eq!(
3880 (symbol(2, 1), symbol(2, 2)),
3881 ("1".to_string(), "2".to_string()),
3882 "the parent's and sub-issue's digits align at the same column: {:?} / {:?}",
3883 row(1),
3884 row(2)
3885 );
3886
3887 let area = Rect::new(0, 0, width, height);
3890 assert_eq!(
3891 board.hit_test(area, display, false, 3, 1),
3892 Some(HitTarget::Card { col: 0, row: 0 })
3893 );
3894 assert_eq!(
3895 board.hit_test(area, display, false, 3, 2),
3896 Some(HitTarget::Card { col: 0, row: 1 })
3897 );
3898 }
3899
3900 fn write_issue_with_parent_md(
3903 dir: &std::path::Path,
3904 name: &str,
3905 number: u32,
3906 title: &str,
3907 status: &str,
3908 created: &str,
3909 parent: Option<u32>,
3910 ) {
3911 let parent_line = parent.map(|p| format!("parent: {p}\n")).unwrap_or_default();
3912 let contents = format!(
3913 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"{created}\"\nupdated: \"{created}\"\n{parent_line}---\n\nBody text."
3914 );
3915 std::fs::write(dir.join(name), contents).unwrap();
3916 }
3917
3918 #[test]
3919 fn build_with_show_children_nests_a_sub_issue_below_its_parent() {
3920 use crate::config::Config;
3921 use std::path::PathBuf;
3922
3923 let tmp = tempfile::TempDir::new().unwrap();
3924 let dir = tmp.path().join("issues");
3925 std::fs::create_dir_all(&dir).unwrap();
3926 write_issue_with_parent_md(
3927 &dir,
3928 "2026-01-01-parent.md",
3929 1,
3930 "parent",
3931 "todo",
3932 "2026-01-01",
3933 None,
3934 );
3935 write_issue_with_parent_md(
3936 &dir,
3937 "2026-01-02-child.md",
3938 2,
3939 "child",
3940 "todo",
3941 "2026-01-02",
3942 Some(1),
3943 );
3944 let config = Config {
3945 issues_dir: PathBuf::from(&dir),
3946 ..Config::default()
3947 };
3948 let store = crate::storage::Store::open(&config).unwrap();
3949 let board_cfg = config.default_config().clone();
3950
3951 let flat = BoardState::build(&store, &board_cfg);
3954 let flat_shape: Vec<(u32, u8)> = flat
3955 .column_cards(0)
3956 .iter()
3957 .map(|c| (c.number, c.depth))
3958 .collect();
3959 assert_eq!(flat_shape, vec![(2, 0), (1, 0)]);
3960
3961 let tree = BoardState::build_with(&store, &board_cfg, false, true);
3962 let tree_shape: Vec<(u32, u8)> = tree
3963 .column_cards(0)
3964 .iter()
3965 .map(|c| (c.number, c.depth))
3966 .collect();
3967 assert_eq!(
3968 tree_shape,
3969 vec![(1, 0), (2, 1)],
3970 "the sub-issue follows its parent, one level deep"
3971 );
3972 assert_eq!(tree.column_cards(0)[1].parent, Some(1));
3973 }
3974
3975 #[test]
3978 fn build_from_issues_matches_build_with() {
3979 use crate::config::Config;
3980
3981 let tmp = tempfile::TempDir::new().unwrap();
3982 let dir = tmp.path().join("issues");
3983 std::fs::create_dir_all(&dir).unwrap();
3984 write_issue_with_parent_md(
3985 &dir,
3986 "2026-01-01-parent.md",
3987 1,
3988 "parent",
3989 "todo",
3990 "2026-01-01",
3991 None,
3992 );
3993 write_issue_with_parent_md(
3994 &dir,
3995 "2026-01-02-child.md",
3996 2,
3997 "child",
3998 "todo",
3999 "2026-01-02",
4000 Some(1),
4001 );
4002 write_issue_md(
4003 &dir,
4004 "2026-01-03-other.md",
4005 3,
4006 "other",
4007 "in progress",
4008 "2026-01-03",
4009 );
4010 let config = Config {
4011 issues_dir: dir.clone(),
4012 ..Config::default()
4013 };
4014 let store = crate::storage::Store::open(&config).unwrap();
4015 let board_cfg = config.default_config().clone();
4016
4017 let shape = |state: &BoardState| -> Vec<(String, Vec<(String, u8)>)> {
4018 state
4019 .columns
4020 .iter()
4021 .chain(state.hidden.iter())
4022 .chain(std::iter::once(&state.mismatch))
4023 .map(|col| {
4024 (
4025 col.name.clone(),
4026 col.cards
4027 .iter()
4028 .map(|c| (c.filename.clone(), c.depth))
4029 .collect(),
4030 )
4031 })
4032 .collect()
4033 };
4034
4035 for show_children in [false, true] {
4036 let from_store = BoardState::build_with(&store, &board_cfg, false, show_children);
4037 let issues = store.list().unwrap();
4038 let resolution = BoardState::resolution_for(&store, &board_cfg);
4039 let from_issues = BoardState::build_from_issues(
4040 &issues,
4041 &board_cfg,
4042 &config,
4043 &resolution,
4044 show_children,
4045 );
4046 assert_eq!(
4047 shape(&from_issues),
4048 shape(&from_store),
4049 "show_children={show_children}"
4050 );
4051 }
4052 }
4053}