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::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 archived: bool,
82}
83
84impl crate::config::OrderBy {
85 pub fn sort(&self, cards: &mut [Card]) {
91 match self {
92 crate::config::OrderBy::CreatedAtDesc => {
93 cards.sort_by(|a, b| b.created.cmp(&a.created))
94 }
95 crate::config::OrderBy::CreatedAtAsc => cards.sort_by(|a, b| a.created.cmp(&b.created)),
96 crate::config::OrderBy::None => {}
97 }
98 }
99}
100
101fn sort_by_default_order(board_config: &BoardConfiguration, cards: &mut [Card]) {
102 match board_config.order_by {
103 crate::config::OrderBy::CreatedAtDesc => {
104 cards.sort_by(|a, b| {
105 b.created
106 .cmp(&a.created)
107 .then_with(|| a.filename.cmp(&b.filename))
108 });
109 }
110 crate::config::OrderBy::CreatedAtAsc => {
111 cards.sort_by(|a, b| {
112 a.created
113 .cmp(&b.created)
114 .then_with(|| a.filename.cmp(&b.filename))
115 });
116 }
117 crate::config::OrderBy::None => {
118 cards.sort_by(|a, b| a.filename.cmp(&b.filename));
119 }
120 }
121}
122
123fn sort_cards(board_config: &BoardConfiguration, cards: &mut [Card]) {
124 if board_config.card_order.is_empty() {
125 sort_by_default_order(board_config, cards);
126 return;
127 }
128
129 let mut baseline = cards.to_vec();
130 sort_by_default_order(board_config, &mut baseline);
131
132 let manual_cards: Vec<Card> = board_config
133 .card_order
134 .iter()
135 .filter_map(|filename| {
136 baseline
137 .iter()
138 .find(|card| card.filename == *filename)
139 .cloned()
140 })
141 .collect();
142 let touched: HashSet<String> = manual_cards
143 .iter()
144 .map(|card| card.filename.clone())
145 .collect();
146 let mut manual_iter = manual_cards.into_iter();
147 let mut ordered = Vec::with_capacity(baseline.len());
148
149 for card in baseline {
150 if touched.contains(card.filename.as_str()) {
151 if let Some(manual_card) = manual_iter.next() {
152 ordered.push(manual_card);
153 } else {
154 ordered.push(card);
155 }
156 } else {
157 ordered.push(card);
158 }
159 }
160
161 cards.clone_from_slice(&ordered);
162}
163
164pub(crate) fn sparse_card_order(board_config: &BoardConfiguration, cards: &[Card]) -> Vec<String> {
165 let mut baseline = cards.to_vec();
166 sort_by_default_order(board_config, &mut baseline);
167 baseline
168 .iter()
169 .zip(cards.iter())
170 .filter(|(baseline_card, current_card)| baseline_card.filename != current_card.filename)
171 .map(|(_, current_card)| current_card.filename.clone())
172 .collect()
173}
174
175#[derive(Debug, Clone)]
176pub struct BoardColumn {
177 pub name: String,
178 pub cards: Vec<Card>,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum HitTarget {
186 Card { col: usize, row: usize },
187 Column { col: usize },
188}
189
190#[derive(Debug, Clone)]
191pub struct BoardState {
192 pub columns: Vec<BoardColumn>,
196 pub hidden: Vec<BoardColumn>,
200 pub mismatch: BoardColumn,
201 pub show_mismatch: bool,
206 pub active_col: usize,
207 pub selected: usize,
208 pub hover: Option<(usize, usize)>,
212 pub filter: Option<String>,
213}
214
215pub fn truncate_to_cells(msg: &str, budget: usize) -> String {
223 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
224
225 const ELLIPSIS: char = '\u{2026}';
226 if budget == 0 {
227 return String::new();
228 }
229 if UnicodeWidthStr::width(msg) <= budget {
230 return msg.to_string();
231 }
232
233 let marker_width = UnicodeWidthChar::width(ELLIPSIS).unwrap_or(1);
237 let mut out = String::new();
238 let mut used = 0usize;
239 for ch in msg.chars() {
240 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
241 if used + w + marker_width > budget {
242 break;
243 }
244 out.push(ch);
245 used += w;
246 }
247 out.push(ELLIPSIS);
248 out
249}
250
251fn blocked_reasons(
256 issues: &[crate::model::Issue],
257 done_column: &str,
258) -> std::collections::BTreeMap<u32, String> {
259 let views: Vec<crate::relationships::IssueView<'_>> =
260 issues.iter().map(crate::model::Issue::issue_view).collect();
261 let graph = crate::relationships::RelationshipGraph::new(&views, done_column);
262 issues
263 .iter()
264 .filter_map(|issue| {
265 crate::commands::blocked_note(&graph.blocked_state(issue.number))
266 .map(|note| (issue.number, note))
267 })
268 .collect()
269}
270
271fn child_markers(
278 issues: &[crate::model::Issue],
279 done_column: &str,
280) -> std::collections::BTreeMap<u32, String> {
281 let views: Vec<crate::relationships::IssueView<'_>> =
282 issues.iter().map(crate::model::Issue::issue_view).collect();
283 let index = crate::hierarchy::HierarchyIndex::build(&views);
284 issues
285 .iter()
286 .filter_map(|issue| {
287 let facts =
288 crate::commands::parent::facts_for(&index, issues, issue.number, done_column);
289 facts.has_children().then(|| {
290 (
291 issue.number,
292 format!(
293 "{} {}/{}",
294 facts.rollup.direct_children,
295 facts.rollup.completed_children,
296 facts.rollup.progress.total
297 ),
298 )
299 })
300 })
301 .collect()
302}
303
304impl BoardState {
305 fn resolution_for(
319 store: &Store,
320 board_config: &BoardConfiguration,
321 ) -> crate::workflow::CategoryResolution {
322 let name = store
323 .config
324 .configs()
325 .find(|(_, board)| std::ptr::eq(*board, board_config))
326 .map(|(name, _)| name)
327 .unwrap_or("default");
328 crate::workflow::resolution(&store.config, board_config, name)
329 }
330
331 pub fn build(store: &Store, board_config: &BoardConfiguration) -> Self {
332 Self::build_with_archived(store, board_config, false)
333 }
334
335 pub fn build_with_archived(
344 store: &Store,
345 board_config: &BoardConfiguration,
346 include_archived: bool,
347 ) -> Self {
348 let issues = if include_archived {
349 store.list_all()
350 } else {
351 store.list()
352 }
353 .unwrap_or_default();
354 let done_column =
363 crate::workflow::completion_column_or_default(board_config, &store.config);
364 let done_column = done_column.as_str();
365 let resolution = Self::resolution_for(store, board_config);
366 let blocked = blocked_reasons(&issues, done_column);
367 let children = child_markers(&issues, done_column);
368 let show_mismatch = board_config.show_mismatch;
369 let hidden_names: Vec<&str> = board_config
370 .hidden_columns
371 .iter()
372 .map(String::as_str)
373 .collect();
374 let mut column_map: Vec<Vec<Card>> =
375 board_config.columns.iter().map(|_| Vec::new()).collect();
376 let mut mismatch_cards = Vec::new();
377
378 for issue in issues {
379 let card = Card {
380 number: issue.number,
381 title: issue.title.clone(),
382 priority: issue.priority.clone(),
383 labels: issue.labels.clone(),
384 assignee: issue.assignee.clone(),
385 filename: issue.filename.clone(),
386 commit: issue.commit.clone(),
387 created: issue.created.clone(),
388 due_date: issue.due_date.clone(),
389 status: issue.status.display().to_string(),
390 category: crate::workflow::category_of(&issue, &resolution)
391 .map(|c| c.label().to_string()),
392 category_color: crate::workflow::category_of(&issue, &resolution)
393 .and_then(|c| c.color_hint()),
394 done: crate::workflow::is_completed(&issue, &resolution),
395 cycle: issue.cycles.as_ref().and_then(|v| v.first().cloned()),
396 milestone: issue.milestones.as_ref().and_then(|v| v.first().cloned()),
397 blocked: blocked.get(&issue.number).cloned(),
398 children: children.get(&issue.number).cloned(),
399 archived: issue.archived,
400 };
401 if let Some(idx) = board_config
402 .columns
403 .iter()
404 .position(|c| c == issue.status.display())
405 {
406 column_map[idx].push(card);
407 } else {
408 mismatch_cards.push(card);
409 }
410 }
411
412 for cards in column_map.iter_mut() {
413 sort_cards(board_config, cards);
414 }
415 sort_cards(board_config, &mut mismatch_cards);
416
417 let mut columns: Vec<BoardColumn> = Vec::new();
418 let mut hidden: Vec<BoardColumn> = Vec::new();
419 for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
420 let col = BoardColumn {
421 name: name.clone(),
422 cards,
423 };
424 if hidden_names.contains(&name.as_str()) {
425 hidden.push(col);
426 } else {
427 columns.push(col);
428 }
429 }
430
431 let mismatch = BoardColumn {
432 name: "Mismatched".to_string(),
433 cards: mismatch_cards,
434 };
435
436 BoardState {
437 columns,
438 hidden,
439 mismatch,
440 show_mismatch,
441 active_col: 0,
442 selected: 0,
443 filter: None,
444 hover: None,
445 }
446 }
447
448 pub fn build_filtered(
459 board_config: &BoardConfiguration,
460 config: &crate::config::Config,
461 corpus: &Corpus,
462 query: &str,
463 filter: &StructuredFilter,
464 resolution: &crate::workflow::CategoryResolution,
465 ) -> Self {
466 let show_mismatch = board_config.show_mismatch;
467 let done_column = crate::workflow::completion_column_or_default(board_config, config);
470 let done_column = done_column.as_str();
471 let blocked = blocked_reasons(&corpus.issues, done_column);
474 let children = child_markers(&corpus.issues, done_column);
478 let filtering = !query.is_empty() || filter.has_active_criteria();
479 let sort_key = crate::search::control_token(query, "sort");
480 let number = crate::search::number_query(query);
486 let hidden_names: Vec<&str> = board_config
487 .hidden_columns
488 .iter()
489 .map(String::as_str)
490 .collect();
491 let mut column_map: Vec<Vec<Card>> =
492 board_config.columns.iter().map(|_| Vec::new()).collect();
493 let mut mismatch_cards = Vec::new();
494 let mut matched_issues: Vec<&crate::model::Issue> = Vec::new();
495
496 for (issue, text) in corpus.issues.iter().zip(corpus.texts.iter()) {
497 let keep = if let Some(n) = number {
508 issue.number == n && filter.evaluate_issue(issue, text, resolution)
509 } else {
510 crate::search::matches_issue(query, filter, issue, text, resolution)
511 };
512 if !keep {
513 continue;
514 }
515 matched_issues.push(issue);
516 }
517
518 Self::sort_filtered_issues(&mut matched_issues, sort_key.as_deref());
519
520 for issue in matched_issues {
521 let card = Card {
522 number: issue.number,
523 title: issue.title.clone(),
524 priority: issue.priority.clone(),
525 labels: issue.labels.clone(),
526 assignee: issue.assignee.clone(),
527 filename: issue.filename.clone(),
528 commit: issue.commit.clone(),
529 created: issue.created.clone(),
530 due_date: issue.due_date.clone(),
531 status: issue.status.display().to_string(),
532 category: crate::workflow::category_of(issue, resolution)
533 .map(|c| c.label().to_string()),
534 category_color: crate::workflow::category_of(issue, resolution)
535 .and_then(|c| c.color_hint()),
536 done: crate::workflow::is_completed(issue, resolution),
537 cycle: issue.cycles.as_ref().and_then(|v| v.first().cloned()),
538 milestone: issue.milestones.as_ref().and_then(|v| v.first().cloned()),
539 blocked: blocked.get(&issue.number).cloned(),
540 children: children.get(&issue.number).cloned(),
541 archived: issue.archived,
542 };
543 if let Some(idx) = board_config
544 .columns
545 .iter()
546 .position(|c| c == issue.status.display())
547 {
548 column_map[idx].push(card);
549 } else {
550 mismatch_cards.push(card);
551 }
552 }
553
554 if sort_key.is_none() {
555 for cards in column_map.iter_mut() {
556 sort_cards(board_config, cards);
557 }
558 sort_cards(board_config, &mut mismatch_cards);
559 }
560
561 let mut columns: Vec<BoardColumn> = Vec::new();
562 let mut hidden: Vec<BoardColumn> = Vec::new();
563 for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
564 let col = BoardColumn {
565 name: name.clone(),
566 cards,
567 };
568 if hidden_names.contains(&name.as_str()) {
569 if filtering {
570 columns.push(col);
574 } else {
575 hidden.push(col);
576 }
577 } else {
578 columns.push(col);
579 }
580 }
581
582 BoardState {
583 columns,
584 hidden,
585 mismatch: BoardColumn {
586 name: "Mismatched".to_string(),
587 cards: mismatch_cards,
588 },
589 show_mismatch,
590 active_col: 0,
591 selected: 0,
592 filter: Some(query.to_string()),
593 hover: None,
594 }
595 }
596
597 fn sort_filtered_issues(issues: &mut [&crate::model::Issue], sort: Option<&str>) {
598 match sort.unwrap_or("created") {
599 "due" => {
600 issues.sort_by(|a, b| a.due_date.cmp(&b.due_date).then(a.number.cmp(&b.number)))
601 }
602 "start" => issues.sort_by(|a, b| {
603 a.start_date
604 .cmp(&b.start_date)
605 .then(a.number.cmp(&b.number))
606 }),
607 "reviewer" => {
608 issues.sort_by(|a, b| a.reviewer.cmp(&b.reviewer).then(a.number.cmp(&b.number)))
609 }
610 "cycle" => issues.sort_by(|a, b| {
611 a.cycles
612 .as_ref()
613 .and_then(|v| v.first())
614 .cmp(&b.cycles.as_ref().and_then(|v| v.first()))
615 .then(a.number.cmp(&b.number))
616 }),
617 "milestone" => issues.sort_by(|a, b| {
618 a.milestones
619 .as_ref()
620 .and_then(|v| v.first())
621 .cmp(&b.milestones.as_ref().and_then(|v| v.first()))
622 .then(a.number.cmp(&b.number))
623 }),
624 "status" => issues.sort_by(|a, b| {
625 a.status
626 .display()
627 .cmp(b.status.display())
628 .then(a.number.cmp(&b.number))
629 }),
630 "priority" => issues.sort_by(|a, b| {
631 a.priority
632 .to_string()
633 .cmp(&b.priority.to_string())
634 .then(a.number.cmp(&b.number))
635 }),
636 _ => issues.sort_by(|a, b| a.created.cmp(&b.created).then(a.number.cmp(&b.number))),
637 }
638 }
639
640 pub fn is_empty(&self) -> bool {
642 self.columns.iter().all(|c| c.cards.is_empty())
643 && (!self.show_mismatch || self.mismatch.cards.is_empty())
644 }
645
646 pub fn total_cols(&self) -> usize {
647 if self.show_mismatch {
648 self.columns.len() + 1
649 } else {
650 self.columns.len()
651 }
652 }
653
654 pub fn hit_test(
663 &self,
664 area: Rect,
665 display: BoardDisplay,
666 status: bool,
667 x: u16,
668 y: u16,
669 ) -> Option<HitTarget> {
670 if area.height < 2 || area.width < 4 || self.total_cols() == 0 {
671 return None;
672 }
673 let board_height = area.height.saturating_sub(u16::from(status));
674 if y >= area.y + board_height {
675 return None;
676 }
677 let board_area = Rect {
678 height: board_height,
679 ..area
680 };
681
682 let rects = compute_column_rects(board_area, self.total_cols());
683 let (col, col_rect) = rects
684 .iter()
685 .enumerate()
686 .find(|(_, r)| r.contains(Position::new(x, y)))?;
687
688 let cards: &[Card] = if col < self.columns.len() {
689 &self.columns[col].cards
690 } else {
691 &self.mismatch.cards
692 };
693
694 let group_mode = self
695 .filter
696 .as_deref()
697 .and_then(|q| crate::search::control_token(q, "group"));
698
699 let mut items: Vec<Option<usize>> = Vec::new();
702 if let Some(mode) = group_mode.as_deref() {
703 if let Some(sections) = Self::group_sections(cards, Some(mode)) {
704 for section in sections {
705 items.push(None);
706 for (j, card) in cards
707 .iter()
708 .enumerate()
709 .take(section.end)
710 .skip(section.start)
711 {
712 let count = widgets::card_line_count(
713 card,
714 col_rect.width as usize,
715 display.wrap_titles,
716 display.title_lines,
717 );
718 for _ in 0..count {
719 items.push(Some(j));
720 }
721 }
722 }
723 }
724 } else {
725 for (j, card) in cards.iter().enumerate() {
726 let count = widgets::card_line_count(
727 card,
728 col_rect.width as usize,
729 display.wrap_titles,
730 display.title_lines,
731 );
732 for _ in 0..count {
733 items.push(Some(j));
734 }
735 }
736 }
737
738 let has_footer = BoardState::group_footer(cards, group_mode.as_deref()).is_some();
739 let list_height = col_rect.height.saturating_sub(u16::from(has_footer));
740 let content_top = col_rect.y + 1;
742 let content_end = col_rect.y + list_height.saturating_sub(1);
743 if y >= content_top && y < content_end {
744 let offset = usize::from(y - content_top);
745 if let Some(Some(row)) = items.get(offset) {
746 return Some(HitTarget::Card { col, row: *row });
747 }
748 }
749 Some(HitTarget::Column { col })
750 }
751
752 pub fn real_cols(&self) -> usize {
756 self.columns.len()
757 }
758
759 pub fn column_name(&self, idx: usize) -> &str {
760 if idx < self.columns.len() {
761 &self.columns[idx].name
762 } else if idx == self.columns.len() && self.show_mismatch {
763 &self.mismatch.name
764 } else {
765 "Unknown"
766 }
767 }
768
769 pub fn active_column_name(&self) -> &str {
774 let visible = self.columns.len();
775 if self.active_col < visible {
776 &self.columns[self.active_col].name
777 } else {
778 let hidden_idx = self.active_col - visible;
779 if hidden_idx < self.hidden.len() {
780 &self.hidden[hidden_idx].name
781 } else {
782 "Unknown"
783 }
784 }
785 }
786
787 pub fn column_cards(&self, idx: usize) -> &[Card] {
788 if idx < self.columns.len() {
789 &self.columns[idx].cards
790 } else if idx == self.columns.len() && self.show_mismatch {
791 &self.mismatch.cards
792 } else {
793 &[]
794 }
795 }
796
797 pub fn get_selected_card(&self) -> Option<&Card> {
798 self.column_cards(self.active_col).get(self.selected)
799 }
800
801 pub fn move_left(&mut self) {
802 let total = self.real_cols();
803 self.active_col = if total == 0 {
804 0
805 } else if self.active_col >= total {
806 total - 1
807 } else {
808 (self.active_col + total - 1) % total
809 };
810 self.clamp_selection();
811 }
812
813 pub fn move_right(&mut self) {
814 let total = self.real_cols();
815 self.active_col = if total == 0 || self.active_col >= total {
816 0
817 } else {
818 (self.active_col + 1) % total
819 };
820 self.clamp_selection();
821 }
822
823 pub fn move_up(&mut self) {
824 if self.selected > 0 {
825 self.selected -= 1;
826 }
827 }
828
829 pub fn move_down(&mut self) {
830 let cards = self.column_cards(self.active_col);
831 if self.selected + 1 < cards.len() {
832 self.selected += 1;
833 }
834 }
835
836 pub fn move_selection_by(&mut self, cards: i32) {
843 let len = self.column_cards(self.active_col).len();
844 if len == 0 {
845 self.selected = 0;
846 return;
847 }
848 let next = i32::try_from(self.selected)
849 .unwrap_or(i32::MAX)
850 .saturating_add(cards);
851 self.selected = next.clamp(0, i32::try_from(len).unwrap_or(i32::MAX) - 1) as usize;
852 }
853
854 pub fn clamp_selection(&mut self) {
855 let cards = self.column_cards(self.active_col);
856 if cards.is_empty() {
857 self.selected = 0;
858 } else if self.selected >= cards.len() {
859 self.selected = cards.len() - 1;
860 }
861 }
862
863 pub fn select_column(&mut self, col: usize) {
864 let total = self.real_cols();
865 if total > 0 {
866 self.active_col = col % total;
867 }
868 self.clamp_selection();
869 }
870
871 fn group_footer(cards: &[Card], mode: Option<&str>) -> Option<String> {
872 let sections = Self::group_sections(cards, mode)?;
873 let text = sections
874 .into_iter()
875 .map(|section| {
876 let pct = if section.total == 0 {
877 0
878 } else {
879 (section.done * 100) / section.total
880 };
881 format!("{} {}/{} {pct}%", section.name, section.done, section.total)
882 })
883 .collect::<Vec<_>>()
884 .join(" | ");
885 Some(text)
886 }
887
888 fn card_group(card: &Card, key: &str) -> String {
889 match key {
890 "cycle" => card.cycle.clone().unwrap_or_else(|| "(none)".to_string()),
891 "milestone" => card
892 .milestone
893 .clone()
894 .unwrap_or_else(|| "(none)".to_string()),
895 _ => "(none)".to_string(),
896 }
897 }
898
899 fn group_sections(cards: &[Card], mode: Option<&str>) -> Option<Vec<GroupSection>> {
900 let key = mode.filter(|m| *m == "cycle" || *m == "milestone")?;
901 if cards.is_empty() {
902 return None;
903 }
904 let mut sections = Vec::new();
905 let mut current = String::new();
906 let mut start = 0usize;
907 let mut done = 0usize;
908 let mut total = 0usize;
909 for (idx, card) in cards.iter().enumerate() {
910 let group = Self::card_group(card, key);
911 if idx == 0 {
912 current = group;
913 } else if group != current {
914 sections.push(GroupSection {
915 name: std::mem::take(&mut current),
916 start,
917 end: idx,
918 done,
919 total,
920 });
921 current = group;
922 start = idx;
923 done = 0;
924 total = 0;
925 }
926 total += 1;
927 if card.done {
928 done += 1;
929 }
930 }
931 sections.push(GroupSection {
932 name: current,
933 start,
934 end: cards.len(),
935 done,
936 total,
937 });
938 Some(sections)
939 }
940}
941
942#[derive(Debug, Clone, Copy)]
945pub struct BoardDisplay {
946 pub wrap_titles: bool,
947 pub title_lines: usize,
948}
949
950impl Default for BoardDisplay {
951 fn default() -> Self {
952 BoardDisplay {
953 wrap_titles: true,
954 title_lines: 3,
955 }
956 }
957}
958
959impl From<&crate::config::BoardConfiguration> for BoardDisplay {
960 fn from(config: &crate::config::BoardConfiguration) -> Self {
961 BoardDisplay {
962 wrap_titles: config.wrap_titles,
963 title_lines: config.title_lines,
964 }
965 }
966}
967
968pub fn compute_column_rects(area: Rect, total_cols: usize) -> Vec<Rect> {
972 if total_cols == 0 {
973 return Vec::new();
974 }
975 Layout::default()
976 .direction(Direction::Horizontal)
977 .constraints(vec![Constraint::Ratio(1, total_cols as u32); total_cols])
978 .split(area)
979 .to_vec()
980}
981
982pub fn render(
983 board: &BoardState,
984 f: &mut Frame,
985 area: Rect,
986 status_msg: &Option<String>,
987 drag_preview_col: Option<usize>,
988 display: BoardDisplay,
989) -> usize {
990 let mut page_cards = 0usize;
997
998 if area.height < 2 || area.width < 4 {
999 return 0;
1000 }
1001
1002 let status_height: u16 = if status_msg.is_some() { 1 } else { 0 };
1003 let board_height = area.height.saturating_sub(status_height);
1004 let board_area = Rect {
1005 height: board_height,
1006 ..area
1007 };
1008 let status_area = Rect {
1009 y: area.y + board_height,
1010 height: status_height,
1011 ..area
1012 };
1013
1014 let total = board.total_cols();
1015 if total == 0 {
1016 return 0;
1017 }
1018
1019 let chunks = compute_column_rects(board_area, total);
1020
1021 for (i, chunk) in chunks.iter().enumerate() {
1022 let col = if i < board.columns.len() {
1023 &board.columns[i]
1024 } else {
1025 &board.mismatch
1026 };
1027 let group_mode = board
1028 .filter
1029 .as_deref()
1030 .and_then(|q| crate::search::control_token(q, "group"));
1031
1032 let is_active = i == board.active_col;
1033 let is_mismatch_col = i == board.columns.len();
1034 let is_mismatch_highlight = is_mismatch_col && !col.cards.is_empty();
1035
1036 let mut selected_row: Option<usize> = None;
1037 let items: Vec<ListItem> = if let Some(mode) = group_mode.as_deref() {
1038 if let Some(sections) = BoardState::group_sections(&col.cards, Some(mode)) {
1039 let mut items = Vec::new();
1040 for section in sections {
1041 let pct = if section.total == 0 {
1042 0
1043 } else {
1044 (section.done * 100) / section.total
1045 };
1046 let header = format!(
1047 "-- {mode}: {} ({}/{} done, {pct}%) --",
1048 section.name, section.done, section.total
1049 );
1050 items.push(ListItem::new(Line::from(Span::styled(
1051 header,
1052 Style::default()
1053 .fg(Color::Yellow)
1054 .add_modifier(Modifier::BOLD),
1055 ))));
1056 for j in section.start..section.end {
1057 let card = &col.cards[j];
1058 let lines = widgets::card_lines(
1059 card,
1060 chunk.width as usize,
1061 display.wrap_titles,
1062 display.title_lines,
1063 );
1064 let style = if is_active && j == board.selected {
1065 selected_row = Some(items.len());
1066 Style::default()
1067 .bg(Color::DarkGray)
1068 .add_modifier(Modifier::BOLD)
1069 } else if board.hover == Some((i, j)) {
1070 if drag_preview_col == Some(i) {
1072 Style::default()
1073 .fg(Color::Yellow)
1074 .add_modifier(Modifier::BOLD)
1075 } else {
1076 Style::default().fg(Color::Cyan)
1077 }
1078 } else if is_mismatch_highlight {
1079 Style::default().fg(Color::Red)
1080 } else {
1081 Style::default()
1082 };
1083 items.push(ListItem::new(lines).style(style));
1084 }
1085 }
1086 items
1087 } else {
1088 Vec::new()
1089 }
1090 } else {
1091 col.cards
1092 .iter()
1093 .enumerate()
1094 .map(|(j, card)| {
1095 let lines = widgets::card_lines(
1096 card,
1097 chunk.width as usize,
1098 display.wrap_titles,
1099 display.title_lines,
1100 );
1101 let style = if is_active && j == board.selected {
1102 selected_row = Some(j);
1103 Style::default()
1104 .bg(Color::DarkGray)
1105 .add_modifier(Modifier::BOLD)
1106 } else if board.hover == Some((i, j)) {
1107 if drag_preview_col == Some(i) {
1108 Style::default()
1109 .fg(Color::Yellow)
1110 .add_modifier(Modifier::BOLD)
1111 } else {
1112 Style::default().fg(Color::Cyan)
1113 }
1114 } else if is_mismatch_highlight {
1115 Style::default().fg(Color::Red)
1116 } else {
1117 Style::default()
1118 };
1119 ListItem::new(lines).style(style)
1120 })
1121 .collect()
1122 };
1123
1124 let title = format!("{} ({})", col.name, col.cards.len());
1125 let border_style = if drag_preview_col == Some(i) {
1126 Style::default().fg(Color::Yellow)
1127 } else if is_active {
1128 Style::default().fg(Color::Cyan)
1129 } else if is_mismatch_highlight {
1130 Style::default().fg(Color::Red)
1131 } else {
1132 Style::default()
1133 };
1134
1135 let block = Block::default()
1136 .borders(Borders::ALL)
1137 .title(title)
1138 .border_style(border_style);
1139
1140 let col_chunks = if BoardState::group_footer(&col.cards, group_mode.as_deref()).is_some() {
1141 Layout::default()
1142 .direction(Direction::Vertical)
1143 .constraints([Constraint::Min(1), Constraint::Length(1)])
1144 .split(*chunk)
1145 } else {
1146 Layout::default()
1147 .direction(Direction::Vertical)
1148 .constraints([Constraint::Min(1), Constraint::Length(0)])
1149 .split(*chunk)
1150 };
1151
1152 let list = List::new(items).block(block);
1153
1154 if is_active {
1155 page_cards = cards_per_screen(
1158 &col.cards,
1159 chunk.width as usize,
1160 col_chunks[0].height.saturating_sub(2),
1161 display,
1162 );
1163 let mut state = ListState::default();
1164 if let Some(row) = selected_row {
1165 state.select(Some(row));
1166 }
1167 f.render_stateful_widget(list, col_chunks[0], &mut state);
1168 } else {
1169 f.render_widget(list, col_chunks[0]);
1170 }
1171 if let Some(footer) = BoardState::group_footer(&col.cards, group_mode.as_deref()) {
1172 let para = Paragraph::new(Line::from(Span::styled(
1173 footer,
1174 Style::default().fg(Color::DarkGray),
1175 )));
1176 f.render_widget(para, col_chunks[1]);
1177 }
1178 }
1179
1180 if let Some(msg) = status_msg {
1181 let budget = (status_area.width as usize).saturating_sub(1);
1198 let text = truncate_to_cells(msg, budget);
1199 let para =
1200 Paragraph::new(Line::from(Span::raw(text))).style(Style::default().fg(Color::Yellow));
1201 f.render_widget(para, status_area);
1202 }
1203
1204 page_cards
1205}
1206
1207fn cards_per_screen(cards: &[Card], width: usize, rows: u16, display: BoardDisplay) -> usize {
1218 if cards.is_empty() {
1219 return 0;
1220 }
1221 let mut used: u16 = 0;
1222 let mut fits = 0usize;
1223 for card in cards {
1224 let height = u16::try_from(
1225 widgets::card_lines(card, width, display.wrap_titles, display.title_lines).len(),
1226 )
1227 .unwrap_or(u16::MAX);
1228 if fits > 0 && used.saturating_add(height) > rows {
1229 break;
1230 }
1231 used = used.saturating_add(height);
1232 fits += 1;
1233 if used >= rows {
1234 break;
1235 }
1236 }
1237 fits.max(1)
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242 use super::*;
1243 use crate::config::Config;
1244
1245 #[test]
1246 fn compute_column_rects_splits_into_equal_columns() {
1247 let area = Rect::new(5, 7, 90, 30);
1248 let rects = compute_column_rects(area, 3);
1249 assert_eq!(rects.len(), 3);
1250 assert_eq!((rects[0].x, rects[0].width), (5, 30));
1251 assert_eq!((rects[1].x, rects[1].width), (35, 30));
1252 assert_eq!((rects[2].x, rects[2].width), (65, 30));
1253 assert_eq!((rects[0].y, rects[0].height), (7, 30));
1254 assert_eq!((rects[2].y, rects[2].height), (7, 30));
1255 }
1256
1257 #[test]
1258 fn compute_column_rects_zero_columns_is_empty() {
1259 assert!(compute_column_rects(Rect::new(0, 0, 10, 10), 0).is_empty());
1260 }
1261
1262 #[test]
1263 fn card_line_count_matches_rendered_card_heights() {
1264 use ratatui::Terminal;
1265 use ratatui::backend::TestBackend;
1266
1267 let wrapped = "A deliberately long card title that cannot fit on a single line inside the narrow column and must wrap over several rows";
1268 let cards = vec![
1269 Card {
1270 number: 1,
1271 title: "Short".into(),
1272 priority: crate::model::Priority::Medium,
1273 labels: vec![],
1274 assignee: None,
1275 filename: "1.md".into(),
1276 commit: None,
1277 created: "2026-01-01".into(),
1278 due_date: None,
1279 status: "todo".into(),
1280 category: None,
1281 category_color: None,
1282 done: false,
1283 cycle: None,
1284 milestone: None,
1285 blocked: None,
1286 children: None,
1287 archived: false,
1288 },
1289 Card {
1290 number: 2,
1291 title: wrapped.into(),
1292 priority: crate::model::Priority::Medium,
1293 labels: vec![],
1294 assignee: None,
1295 filename: "2.md".into(),
1296 commit: None,
1297 created: "2026-01-01".into(),
1298 due_date: None,
1299 status: "todo".into(),
1300 category: None,
1301 category_color: None,
1302 done: false,
1303 cycle: None,
1304 milestone: None,
1305 blocked: None,
1306 children: None,
1307 archived: false,
1308 },
1309 Card {
1310 number: 3,
1311 title: "Another long title card that also wraps".into(),
1312 priority: crate::model::Priority::Medium,
1313 labels: vec![],
1314 assignee: None,
1315 filename: "3.md".into(),
1316 commit: None,
1317 created: "2026-01-01".into(),
1318 due_date: None,
1319 status: "todo".into(),
1320 category: None,
1321 category_color: None,
1322 done: false,
1323 cycle: None,
1324 milestone: None,
1325 blocked: None,
1326 children: None,
1327 archived: false,
1328 },
1329 ];
1330 let board = BoardState {
1331 columns: vec![BoardColumn {
1332 name: "todo".into(),
1333 cards,
1334 }],
1335 hidden: vec![],
1336 mismatch: BoardColumn {
1337 name: "Mismatched".into(),
1338 cards: vec![],
1339 },
1340 active_col: 0,
1341 selected: 0,
1342 filter: None,
1343 hover: None,
1344 show_mismatch: false,
1345 };
1346 let display = BoardDisplay {
1347 wrap_titles: true,
1348 title_lines: 3,
1349 };
1350 let width = 80u16;
1351 let height = 30u16;
1352 let backend = TestBackend::new(width, height);
1353 let mut terminal = Terminal::new(backend).unwrap();
1354 terminal
1355 .draw(|f| {
1356 render(&board, f, f.area(), &None, None, display);
1357 })
1358 .unwrap();
1359 let buffer = terminal.backend().buffer();
1360
1361 let col_width = compute_column_rects(Rect::new(0, 0, width, height), 1)[0].width as usize;
1362
1363 let mut starts: Vec<(u32, u16)> = Vec::new();
1364 for y in 0..height {
1365 let line: String = (1..width.min(24))
1366 .map(|x| buffer[(x, y)].symbol())
1367 .collect();
1368 for card in &board.columns[0].cards {
1369 if line.starts_with(&format!("#{} ", card.number))
1370 && !starts.iter().any(|(n, _)| *n == card.number)
1371 {
1372 starts.push((card.number, y));
1373 break;
1374 }
1375 }
1376 }
1377 starts.sort_by_key(|(_, y)| *y);
1378 assert_eq!(
1379 starts.len(),
1380 3,
1381 "every card found in the buffer: {starts:?}"
1382 );
1383 for (idx, (number, y)) in starts.iter().enumerate() {
1384 let span: u16 = if idx + 1 < starts.len() {
1385 starts[idx + 1].1 - y
1386 } else {
1387 u16::try_from(
1388 (*y..height)
1389 .filter(|&rw| {
1390 rw != height - 1
1391 && (1..(width - 1)).any(|x| buffer[(x, rw)].symbol() != " ")
1392 })
1393 .count(),
1394 )
1395 .unwrap()
1396 };
1397 let card = board.columns[0]
1398 .cards
1399 .iter()
1400 .find(|c| c.number == *number)
1401 .expect("card on the board");
1402 let expected =
1403 widgets::card_line_count(card, col_width, display.wrap_titles, display.title_lines);
1404 assert_eq!(
1405 span, expected,
1406 "rendered height of card #{number} must match card_line_count"
1407 );
1408 }
1409 }
1410
1411 #[test]
1412 fn hit_test_cards_and_column_fallback() {
1413 let tall = "A deliberately long card title that cannot fit on a single line inside the narrow column and wraps over several rows";
1414 let cards = vec![
1415 Card {
1416 number: 1,
1417 title: "one".into(),
1418 priority: Priority::Medium,
1419 labels: vec![],
1420 assignee: None,
1421 filename: "1.md".into(),
1422 commit: None,
1423 created: "2026-01-01".into(),
1424 due_date: None,
1425 status: "todo".into(),
1426 category: None,
1427 category_color: None,
1428 done: false,
1429 cycle: None,
1430 milestone: None,
1431 blocked: None,
1432 children: None,
1433 archived: false,
1434 },
1435 Card {
1436 number: 2,
1437 title: tall.into(),
1438 priority: Priority::Medium,
1439 labels: vec![],
1440 assignee: None,
1441 filename: "2.md".into(),
1442 commit: None,
1443 created: "2026-01-02".into(),
1444 due_date: None,
1445 status: "todo".into(),
1446 category: None,
1447 category_color: None,
1448 done: false,
1449 cycle: None,
1450 milestone: None,
1451 blocked: None,
1452 children: None,
1453 archived: false,
1454 },
1455 ];
1456 let board = BoardState {
1457 columns: vec![
1458 BoardColumn {
1459 name: "todo".into(),
1460 cards,
1461 },
1462 BoardColumn {
1463 name: "done".into(),
1464 cards: vec![Card {
1465 number: 3,
1466 title: "three".into(),
1467 priority: Priority::Medium,
1468 labels: vec![],
1469 assignee: None,
1470 filename: "3.md".into(),
1471 commit: None,
1472 created: "2026-01-03".into(),
1473 due_date: None,
1474 status: "done".into(),
1475 category: None,
1476 category_color: None,
1477 done: true,
1478 cycle: None,
1479 milestone: None,
1480 blocked: None,
1481 children: None,
1482 archived: false,
1483 }],
1484 },
1485 ],
1486 hidden: vec![],
1487 mismatch: BoardColumn {
1488 name: "Mismatched".into(),
1489 cards: vec![],
1490 },
1491 active_col: 0,
1492 selected: 0,
1493 filter: None,
1494 hover: None,
1495 show_mismatch: false,
1496 };
1497 let display = BoardDisplay::default();
1498 let area = Rect::new(0, 0, 80, 30);
1499 let widths = compute_column_rects(area, 2);
1500 let col0 = widths[0].width as usize;
1501 let card1_height = widgets::card_line_count(&board.columns[0].cards[0], col0, true, 3);
1502
1503 assert_eq!(
1505 board.hit_test(area, display, false, 3, 1),
1506 Some(HitTarget::Card { col: 0, row: 0 })
1507 );
1508 assert_eq!(
1510 board.hit_test(area, display, false, 3, 1 + card1_height),
1511 Some(HitTarget::Card { col: 0, row: 1 })
1512 );
1513 assert_eq!(
1515 board.hit_test(area, display, false, 3, 10),
1516 Some(HitTarget::Column { col: 0 })
1517 );
1518 assert_eq!(
1520 board.hit_test(area, display, false, 0, 5),
1521 Some(HitTarget::Column { col: 0 })
1522 );
1523 assert_eq!(
1525 board.hit_test(area, display, false, 55, 0),
1526 Some(HitTarget::Column { col: 1 })
1527 );
1528 assert_eq!(
1529 board.hit_test(area, display, false, 55, 1),
1530 Some(HitTarget::Card { col: 1, row: 0 })
1531 );
1532 assert_eq!(
1534 board.hit_test(area, display, false, 3, 29),
1535 Some(HitTarget::Column { col: 0 })
1536 );
1537 }
1538
1539 #[test]
1540 fn hit_test_outside_area_is_none() {
1541 let board = BoardState {
1542 columns: vec![BoardColumn {
1543 name: "todo".into(),
1544 cards: vec![Card {
1545 number: 1,
1546 title: "one".into(),
1547 priority: Priority::Medium,
1548 labels: vec![],
1549 assignee: None,
1550 filename: "1.md".into(),
1551 commit: None,
1552 created: "2026-01-01".into(),
1553 due_date: None,
1554 status: "todo".into(),
1555 category: None,
1556 category_color: None,
1557 done: false,
1558 cycle: None,
1559 milestone: None,
1560 blocked: None,
1561 children: None,
1562 archived: false,
1563 }],
1564 }],
1565 hidden: vec![],
1566 mismatch: BoardColumn {
1567 name: "Mismatched".into(),
1568 cards: vec![],
1569 },
1570 active_col: 0,
1571 selected: 0,
1572 filter: None,
1573 hover: None,
1574 show_mismatch: false,
1575 };
1576 let display = BoardDisplay::default();
1577 let area = Rect::new(0, 5, 40, 12);
1578 assert_eq!(board.hit_test(area, display, false, 3, 3), None);
1580 assert_eq!(
1582 board.hit_test(area, display, false, 3, 6),
1583 Some(HitTarget::Card { col: 0, row: 0 })
1584 );
1585 assert_eq!(board.hit_test(area, display, false, 44, 7), None);
1587 }
1588
1589 #[test]
1590 fn hit_test_respects_status_row() {
1591 let board = BoardState {
1592 columns: vec![BoardColumn {
1593 name: "todo".into(),
1594 cards: vec![Card {
1595 number: 1,
1596 title: "one".into(),
1597 priority: Priority::Medium,
1598 labels: vec![],
1599 assignee: None,
1600 filename: "1.md".into(),
1601 commit: None,
1602 created: "2026-01-01".into(),
1603 due_date: None,
1604 status: "todo".into(),
1605 category: None,
1606 category_color: None,
1607 done: false,
1608 cycle: None,
1609 milestone: None,
1610 blocked: None,
1611 children: None,
1612 archived: false,
1613 }],
1614 }],
1615 hidden: vec![],
1616 mismatch: BoardColumn {
1617 name: "Mismatched".into(),
1618 cards: vec![],
1619 },
1620 active_col: 0,
1621 selected: 0,
1622 filter: None,
1623 hover: None,
1624 show_mismatch: false,
1625 };
1626 let display = BoardDisplay::default();
1627 let area = Rect::new(0, 0, 80, 30);
1628 assert_eq!(board.hit_test(area, display, true, 3, 29), None);
1631 assert_eq!(
1632 board.hit_test(area, display, true, 3, 1),
1633 Some(HitTarget::Card { col: 0, row: 0 })
1634 );
1635 }
1636
1637 #[test]
1638 fn hit_test_group_header_is_not_a_card() {
1639 let cycle_card = |number: u32, cycle: &str| Card {
1640 number,
1641 title: format!("task {number}"),
1642 priority: Priority::Medium,
1643 labels: vec![],
1644 assignee: None,
1645 filename: format!("{number}.md"),
1646 commit: None,
1647 created: format!("2026-01-{number:02}"),
1648 due_date: None,
1649 status: "todo".into(),
1650 category: None,
1651 category_color: None,
1652 done: false,
1653 cycle: Some(cycle.into()),
1654 milestone: None,
1655 blocked: None,
1656 children: None,
1657 archived: false,
1658 };
1659 let board = BoardState {
1660 columns: vec![
1661 BoardColumn {
1662 name: "todo".into(),
1663 cards: vec![cycle_card(1, "alpha"), cycle_card(2, "beta")],
1664 },
1665 BoardColumn {
1666 name: "done".into(),
1667 cards: vec![],
1668 },
1669 ],
1670 hidden: vec![],
1671 mismatch: BoardColumn {
1672 name: "Mismatched".into(),
1673 cards: vec![],
1674 },
1675 active_col: 0,
1676 selected: 0,
1677 filter: Some("group:cycle".into()),
1678 hover: None,
1679 show_mismatch: false,
1680 };
1681 let display = BoardDisplay::default();
1682 let area = Rect::new(0, 0, 80, 30);
1683 let col0 = area.width / 2;
1684 let first_height =
1685 widgets::card_line_count(&board.columns[0].cards[0], col0 as usize, true, 3);
1686
1687 assert_eq!(
1689 board.hit_test(area, display, false, 5, 1),
1690 Some(HitTarget::Column { col: 0 })
1691 );
1692 assert_eq!(
1694 board.hit_test(area, display, false, 5, 2),
1695 Some(HitTarget::Card { col: 0, row: 0 })
1696 );
1697 assert_eq!(
1699 board.hit_test(area, display, false, 5, 2 + first_height),
1700 Some(HitTarget::Column { col: 0 })
1701 );
1702 assert_eq!(
1703 board.hit_test(area, display, false, 5, 3 + first_height),
1704 Some(HitTarget::Card { col: 0, row: 1 })
1705 );
1706 }
1707
1708 #[test]
1709 fn hit_test_mismatch_column_resolves() {
1710 let board = BoardState {
1711 columns: vec![BoardColumn {
1712 name: "todo".into(),
1713 cards: vec![],
1714 }],
1715 hidden: vec![],
1716 mismatch: BoardColumn {
1717 name: "Mismatched".into(),
1718 cards: vec![Card {
1719 number: 9,
1720 title: "oddball".into(),
1721 priority: Priority::Medium,
1722 labels: vec![],
1723 assignee: None,
1724 filename: "9.md".into(),
1725 commit: None,
1726 created: "2026-01-09".into(),
1727 due_date: None,
1728 status: "weird".into(),
1729 category: None,
1730 category_color: None,
1731 done: false,
1732 cycle: None,
1733 milestone: None,
1734 blocked: None,
1735 children: None,
1736 archived: false,
1737 }],
1738 },
1739 active_col: 0,
1740 selected: 0,
1741 filter: None,
1742 hover: None,
1743 show_mismatch: true,
1744 };
1745 let display = BoardDisplay::default();
1746 let area = Rect::new(0, 0, 90, 30);
1747 let mismatch_col = board.columns.len();
1748 assert_eq!(
1750 board.hit_test(area, display, false, 70, 1),
1751 Some(HitTarget::Card {
1752 col: mismatch_col,
1753 row: 0
1754 })
1755 );
1756 }
1757
1758 #[test]
1759 fn render_highlights_hovered_card_without_selecting() {
1760 use ratatui::Terminal;
1761 use ratatui::backend::TestBackend;
1762 let board = BoardState {
1763 columns: vec![BoardColumn {
1764 name: "todo".into(),
1765 cards: vec![
1766 Card {
1767 number: 1,
1768 title: "Alpha".into(),
1769 priority: Priority::Medium,
1770 labels: vec![],
1771 assignee: None,
1772 filename: "1.md".into(),
1773 commit: None,
1774 created: "2026-01-01".into(),
1775 due_date: None,
1776 status: "todo".into(),
1777 category: None,
1778 category_color: None,
1779 done: false,
1780 cycle: None,
1781 milestone: None,
1782 blocked: None,
1783 children: None,
1784 archived: false,
1785 },
1786 Card {
1787 number: 2,
1788 title: "Beta".into(),
1789 priority: Priority::Medium,
1790 labels: vec![],
1791 assignee: None,
1792 filename: "2.md".into(),
1793 commit: None,
1794 created: "2026-01-02".into(),
1795 due_date: None,
1796 status: "todo".into(),
1797 category: None,
1798 category_color: None,
1799 done: false,
1800 cycle: None,
1801 milestone: None,
1802 blocked: None,
1803 children: None,
1804 archived: false,
1805 },
1806 ],
1807 }],
1808 hidden: vec![],
1809 mismatch: BoardColumn {
1810 name: "Mismatched".into(),
1811 cards: vec![],
1812 },
1813 show_mismatch: false,
1814 active_col: 0,
1815 selected: 0,
1816 hover: Some((0, 1)),
1817 filter: None,
1818 };
1819 let display = BoardDisplay::default();
1820 let backend = TestBackend::new(80, 30);
1821 let mut terminal = Terminal::new(backend).unwrap();
1822 terminal
1823 .draw(|f| {
1824 render(&board, f, f.area(), &None, None, display);
1825 })
1826 .unwrap();
1827 let buffer = terminal.backend().buffer();
1828
1829 let mut rows = [None, None];
1830 for y in 0..30u16 {
1831 let line: String = (1..12).map(|x| buffer[(x, y)].symbol()).collect();
1832 if line.starts_with("#1") {
1833 rows[0] = Some(y);
1834 }
1835 if line.starts_with("#2") {
1836 rows[1] = Some(y);
1837 }
1838 }
1839 let y1 = rows[0].expect("first card rendered");
1840 let y2 = rows[1].expect("second card rendered");
1841
1842 assert_eq!(buffer[(3, y1)].style().bg, Some(Color::DarkGray));
1844 assert_eq!(buffer[(3, y2)].style().fg, Some(Color::Cyan));
1846 assert_eq!((board.active_col, board.selected), (0, 0));
1848 }
1849
1850 #[test]
1851 fn total_cols_includes_mismatch() {
1852 let board = BoardState {
1853 columns: vec![
1854 BoardColumn {
1855 name: "a".into(),
1856 cards: vec![],
1857 },
1858 BoardColumn {
1859 name: "b".into(),
1860 cards: vec![],
1861 },
1862 ],
1863 mismatch: BoardColumn {
1864 name: "Mismatched".into(),
1865 cards: vec![],
1866 },
1867 active_col: 0,
1868 selected: 0,
1869 filter: None,
1870 hover: None,
1871 hidden: vec![],
1872 show_mismatch: true,
1873 };
1874 assert_eq!(board.total_cols(), 3);
1875 }
1876
1877 #[test]
1878 fn column_name_mismatch_index() {
1879 let board = BoardState {
1880 columns: vec![BoardColumn {
1881 name: "todo".into(),
1882 cards: vec![],
1883 }],
1884 mismatch: BoardColumn {
1885 name: "Mismatched".into(),
1886 cards: vec![],
1887 },
1888 active_col: 0,
1889 selected: 0,
1890 filter: None,
1891 hover: None,
1892 hidden: vec![],
1893 show_mismatch: true,
1894 };
1895 assert_eq!(board.column_name(0), "todo");
1896 assert_eq!(board.column_name(1), "Mismatched");
1897 }
1898
1899 #[test]
1900 fn move_left_wraps_around() {
1901 let mut board = BoardState {
1902 columns: vec![
1903 BoardColumn {
1904 name: "a".into(),
1905 cards: vec![],
1906 },
1907 BoardColumn {
1908 name: "b".into(),
1909 cards: vec![],
1910 },
1911 ],
1912 hidden: vec![],
1913 mismatch: BoardColumn {
1914 name: "Mismatched".into(),
1915 cards: vec![],
1916 },
1917 active_col: 0,
1918 selected: 0,
1919 filter: None,
1920 hover: None,
1921 show_mismatch: true,
1922 };
1923 board.move_left();
1924 assert_eq!(board.active_col, 1);
1925 }
1926
1927 #[test]
1928 fn move_right_wraps_around() {
1929 let mut board = BoardState {
1930 columns: vec![
1931 BoardColumn {
1932 name: "a".into(),
1933 cards: vec![],
1934 },
1935 BoardColumn {
1936 name: "b".into(),
1937 cards: vec![],
1938 },
1939 ],
1940 mismatch: BoardColumn {
1941 name: "Mismatched".into(),
1942 cards: vec![],
1943 },
1944 active_col: 2,
1945 selected: 0,
1946 filter: None,
1947 hover: None,
1948 hidden: vec![],
1949 show_mismatch: true,
1950 };
1951 board.move_right();
1952 assert_eq!(board.active_col, 0);
1953 }
1954
1955 #[test]
1956 fn clamp_selection_empty_column() {
1957 let mut board = BoardState {
1958 columns: vec![BoardColumn {
1959 name: "a".into(),
1960 cards: vec![],
1961 }],
1962 mismatch: BoardColumn {
1963 name: "Mismatched".into(),
1964 cards: vec![],
1965 },
1966 active_col: 0,
1967 selected: 5,
1968 filter: None,
1969 hover: None,
1970 hidden: vec![],
1971 show_mismatch: true,
1972 };
1973 board.clamp_selection();
1974 assert_eq!(board.selected, 0);
1975 }
1976
1977 #[test]
1978 fn clamp_selection_single_card() {
1979 let mut board = BoardState {
1980 columns: vec![BoardColumn {
1981 name: "a".into(),
1982 cards: vec![Card {
1983 number: 1,
1984 title: "X".into(),
1985 priority: crate::model::Priority::Medium,
1986 labels: vec![],
1987 assignee: None,
1988 filename: "x.md".into(),
1989 commit: None,
1990 created: "2026-01-01".into(),
1991 due_date: None,
1992 status: "todo".into(),
1993 category: None,
1994 category_color: None,
1995 done: false,
1996 cycle: None,
1997 milestone: None,
1998 blocked: None,
1999 children: None,
2000 archived: false,
2001 }],
2002 }],
2003 mismatch: BoardColumn {
2004 name: "Mismatched".into(),
2005 cards: vec![],
2006 },
2007 active_col: 0,
2008 selected: 5,
2009 filter: None,
2010 hover: None,
2011 hidden: vec![],
2012 show_mismatch: true,
2013 };
2014 board.clamp_selection();
2015 assert_eq!(board.selected, 0);
2016 }
2017
2018 #[test]
2019 fn move_up_clamps_at_top() {
2020 let mut board = BoardState {
2021 columns: vec![BoardColumn {
2022 name: "a".into(),
2023 cards: vec![Card {
2024 number: 1,
2025 title: "X".into(),
2026 priority: crate::model::Priority::Medium,
2027 labels: vec![],
2028 assignee: None,
2029 filename: "x.md".into(),
2030 commit: None,
2031 created: "2026-01-01".into(),
2032 due_date: None,
2033 status: "todo".into(),
2034 category: None,
2035 category_color: None,
2036 done: false,
2037 cycle: None,
2038 milestone: None,
2039 blocked: None,
2040 children: None,
2041 archived: false,
2042 }],
2043 }],
2044 mismatch: BoardColumn {
2045 name: "Mismatched".into(),
2046 cards: vec![],
2047 },
2048 active_col: 0,
2049 selected: 0,
2050 filter: None,
2051 hover: None,
2052 hidden: vec![],
2053 show_mismatch: true,
2054 };
2055 board.move_up();
2056 assert_eq!(board.selected, 0);
2057 }
2058
2059 #[test]
2060 fn move_down_clamps_at_bottom() {
2061 let mut board = BoardState {
2062 columns: vec![BoardColumn {
2063 name: "a".into(),
2064 cards: vec![Card {
2065 number: 1,
2066 title: "X".into(),
2067 priority: crate::model::Priority::Medium,
2068 labels: vec![],
2069 assignee: None,
2070 filename: "x.md".into(),
2071 commit: None,
2072 created: "2026-01-01".into(),
2073 due_date: None,
2074 status: "todo".into(),
2075 category: None,
2076 category_color: None,
2077 done: false,
2078 cycle: None,
2079 milestone: None,
2080 blocked: None,
2081 children: None,
2082 archived: false,
2083 }],
2084 }],
2085 mismatch: BoardColumn {
2086 name: "Mismatched".into(),
2087 cards: vec![],
2088 },
2089 active_col: 0,
2090 selected: 0,
2091 filter: None,
2092 hover: None,
2093 hidden: vec![],
2094 show_mismatch: true,
2095 };
2096 board.move_down();
2097 assert_eq!(board.selected, 0);
2098 }
2099
2100 #[test]
2101 fn render_smoke_does_not_panic_on_tiny_frame() {
2102 use ratatui::Terminal;
2103 use ratatui::backend::TestBackend;
2104
2105 let backend = TestBackend::new(5, 3);
2106 let mut terminal = Terminal::new(backend).unwrap();
2107 let board = BoardState {
2108 columns: vec![],
2109 mismatch: BoardColumn {
2110 name: "Mismatched".into(),
2111 cards: vec![],
2112 },
2113 active_col: 0,
2114 selected: 0,
2115 filter: None,
2116 hover: None,
2117 hidden: vec![],
2118 show_mismatch: true,
2119 };
2120 terminal
2121 .draw(|f| {
2122 render(&board, f, f.area(), &None, None, BoardDisplay::default());
2123 })
2124 .unwrap();
2125 }
2126
2127 #[test]
2128 fn render_smoke_full_board() {
2129 use ratatui::Terminal;
2130 use ratatui::backend::TestBackend;
2131
2132 let backend = TestBackend::new(80, 24);
2133 let mut terminal = Terminal::new(backend).unwrap();
2134 let board = BoardState {
2135 columns: vec![
2136 BoardColumn {
2137 name: "todo".into(),
2138 cards: vec![],
2139 },
2140 BoardColumn {
2141 name: "done".into(),
2142 cards: vec![],
2143 },
2144 ],
2145 mismatch: BoardColumn {
2146 name: "Mismatched".into(),
2147 cards: vec![],
2148 },
2149 active_col: 0,
2150 selected: 0,
2151 filter: None,
2152 hover: None,
2153 hidden: vec![],
2154 show_mismatch: true,
2155 };
2156 terminal
2157 .draw(|f| {
2158 render(
2159 &board,
2160 f,
2161 f.area(),
2162 &Some("test msg".into()),
2163 None,
2164 BoardDisplay::default(),
2165 );
2166 })
2167 .unwrap();
2168 }
2169
2170 #[test]
2171 fn render_selected_multiline_card_highlights_all_wrapped_lines() {
2172 use ratatui::Terminal;
2173 use ratatui::backend::TestBackend;
2174
2175 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";
2176 let card = crate::ui::board::Card {
2177 number: 7,
2178 title: title.into(),
2179 priority: crate::model::Priority::Medium,
2180 labels: vec![],
2181 assignee: None,
2182 filename: "x.md".into(),
2183 commit: None,
2184 created: "2026-01-01".into(),
2185 due_date: None,
2186 status: "todo".into(),
2187 category: None,
2188 category_color: None,
2189 done: false,
2190 cycle: None,
2191 milestone: None,
2192 blocked: None,
2193 children: None,
2194 archived: false,
2195 };
2196 let backend = TestBackend::new(80, 24);
2197 let mut terminal = Terminal::new(backend).unwrap();
2198 let board = BoardState {
2199 columns: vec![BoardColumn {
2200 name: "todo".into(),
2201 cards: vec![card],
2202 }],
2203 mismatch: BoardColumn {
2204 name: "Mismatched".into(),
2205 cards: vec![],
2206 },
2207 active_col: 0,
2208 selected: 0,
2209 filter: None,
2210 hover: None,
2211 hidden: vec![],
2212 show_mismatch: false,
2213 };
2214 let display = BoardDisplay {
2215 wrap_titles: true,
2216 title_lines: 3,
2217 };
2218 terminal
2219 .draw(|f| {
2220 render(&board, f, f.area(), &None, None, display);
2221 })
2222 .unwrap();
2223 let buffer = terminal.backend().buffer();
2224
2225 let mut highlighted = 0;
2229 for y in 1..buffer.area().height - 1 {
2230 let cell = &buffer[(2, y)];
2231 if cell.bg != Color::DarkGray {
2232 break;
2233 }
2234 assert!(
2235 cell.modifier.contains(Modifier::BOLD),
2236 "highlighted continuation line must stay bold: {cell:?}"
2237 );
2238 highlighted += 1;
2239 }
2240 assert!(
2241 highlighted >= 2,
2242 "wrapped card highlights every line, got {highlighted}"
2243 );
2244 assert!(
2245 highlighted <= display.title_lines,
2246 "highlight never exceeds the line limit: {highlighted}"
2247 );
2248 }
2249
2250 #[test]
2251 fn render_grouped_mode_shows_section_headers() {
2252 use ratatui::Terminal;
2253 use ratatui::backend::TestBackend;
2254
2255 let backend = TestBackend::new(80, 24);
2256 let mut terminal = Terminal::new(backend).unwrap();
2257 let board = BoardState {
2258 columns: vec![BoardColumn {
2259 name: "todo".into(),
2260 cards: vec![
2261 Card {
2262 number: 1,
2263 title: "A".into(),
2264 priority: crate::model::Priority::Medium,
2265 labels: vec![],
2266 assignee: None,
2267 filename: "a.md".into(),
2268 commit: None,
2269 created: "2026-01-01".into(),
2270 due_date: None,
2271 status: "todo".into(),
2272 category: None,
2273 category_color: None,
2274 done: false,
2275 cycle: Some("Cycle 1".into()),
2276 milestone: None,
2277 blocked: None,
2278 children: None,
2279 archived: false,
2280 },
2281 Card {
2282 number: 2,
2283 title: "B".into(),
2284 priority: crate::model::Priority::Medium,
2285 labels: vec![],
2286 assignee: None,
2287 filename: "b.md".into(),
2288 commit: None,
2289 created: "2026-01-01".into(),
2290 due_date: None,
2291 status: "todo".into(),
2292 category: None,
2293 category_color: None,
2294 done: true,
2295 cycle: Some("Cycle 1".into()),
2296 milestone: None,
2297 blocked: None,
2298 children: None,
2299 archived: false,
2300 },
2301 Card {
2302 number: 3,
2303 title: "C".into(),
2304 priority: crate::model::Priority::Medium,
2305 labels: vec![],
2306 assignee: None,
2307 filename: "c.md".into(),
2308 commit: None,
2309 created: "2026-01-01".into(),
2310 due_date: None,
2311 status: "todo".into(),
2312 category: None,
2313 category_color: None,
2314 done: false,
2315 cycle: Some("Cycle 2".into()),
2316 milestone: None,
2317 blocked: None,
2318 children: None,
2319 archived: false,
2320 },
2321 ],
2322 }],
2323 mismatch: BoardColumn {
2324 name: "Mismatched".into(),
2325 cards: vec![],
2326 },
2327 active_col: 0,
2328 selected: 0,
2329 filter: Some("group:cycle".into()),
2330 hover: None,
2331 hidden: vec![],
2332 show_mismatch: false,
2333 };
2334 terminal
2335 .draw(|f| {
2336 render(&board, f, f.area(), &None, None, BoardDisplay::default());
2337 })
2338 .unwrap();
2339
2340 let buffer = terminal.backend().buffer();
2341 let mut lines = Vec::new();
2342 for y in 0..buffer.area.height {
2343 let line = (0..buffer.area.width)
2344 .map(|x| buffer[(x, y)].symbol())
2345 .collect::<String>();
2346 lines.push(line);
2347 }
2348 let rendered = lines.join("\n");
2349 assert!(rendered.contains("-- cycle: Cycle 1 (1/2 done, 50%) --"));
2350 assert!(rendered.contains("-- cycle: Cycle 2 (0/1 done, 0%) --"));
2351 }
2352
2353 #[test]
2354 fn build_filtered_keeps_only_fuzzy_matches() {
2355 use crate::model::Issue;
2356 use crate::search::Corpus;
2357
2358 let to_issue = |number: u32, title: &str| Issue {
2359 number,
2360 title: title.to_string(),
2361 status: crate::model::Status::new("todo"),
2362 labels: vec![],
2363 priority: crate::model::Priority::Medium,
2364 assignee: None,
2365 estimate: None,
2366 commit: None,
2367 gitlab_id: None,
2368 linear_id: None,
2369 mission: None,
2370 task: None,
2371 relationships: vec![],
2372 files: vec![],
2373 cycles: None,
2374 milestones: None,
2375 reviewer: None,
2376 start_date: None,
2377 due_date: None,
2378 created: "2026-01-01".into(),
2379 updated: "2026-01-01".into(),
2380 filename: format!("2026-01-01-{number}.md"),
2381 body: String::new(),
2382 history: crate::model::History::new(),
2383 has_retired_links_key: false,
2384 parent: None,
2385 extra_frontmatter: std::collections::BTreeMap::new(),
2386 archived: false,
2387 entered_at: None,
2388 source: None,
2389 };
2390 let corpus = Corpus {
2391 issues: vec![to_issue(1, "Fix login bug"), to_issue(2, "Polish exports")],
2392 texts: vec!["Fix login bug".to_string(), "Polish exports".to_string()],
2393 };
2394 let config = Config::default();
2395 let (_, resolution) = crate::workflow::active_resolution(&config);
2396 let board_config = config.default_config();
2397
2398 let board = BoardState::build_filtered(
2399 board_config,
2400 &config,
2401 &corpus,
2402 "log",
2403 &StructuredFilter::default(),
2404 &resolution,
2405 );
2406 assert_eq!(board.columns.len(), board_config.columns.len());
2407 assert_eq!(board.column_cards(0).len(), 1);
2408 assert_eq!(board.column_cards(0)[0].title, "Fix login bug");
2409 assert_eq!(board.filter.as_deref(), Some("log"));
2410
2411 let none = BoardState::build_filtered(
2412 board_config,
2413 &config,
2414 &corpus,
2415 "zzw",
2416 &StructuredFilter::default(),
2417 &resolution,
2418 );
2419 assert!(none.is_empty());
2420
2421 let empty = BoardState::build_filtered(
2422 board_config,
2423 &config,
2424 &corpus,
2425 "",
2426 &StructuredFilter::default(),
2427 &resolution,
2428 );
2429 assert_eq!(empty.column_cards(0).len(), 2, "empty query keeps all");
2430 assert!(empty.filter.is_some());
2431 }
2432
2433 #[test]
2434 fn build_filtered_group_cycle_keeps_original_titles() {
2435 use crate::model::Issue;
2436 use crate::search::Corpus;
2437
2438 let issue = |number: u32, title: &str, status: &str, cycles: &[&str]| Issue {
2439 number,
2440 title: title.to_string(),
2441 status: crate::model::Status::new(status),
2442 labels: vec![],
2443 priority: crate::model::Priority::Medium,
2444 assignee: None,
2445 estimate: None,
2446 commit: None,
2447 gitlab_id: None,
2448 linear_id: None,
2449 mission: None,
2450 task: None,
2451 relationships: vec![],
2452 files: vec![],
2453 cycles: Some(cycles.iter().map(|c| (*c).to_string()).collect()),
2454 milestones: None,
2455 reviewer: None,
2456 start_date: None,
2457 due_date: None,
2458 created: "2026-01-01".into(),
2459 updated: "2026-01-01".into(),
2460 filename: format!("2026-01-01-{number}.md"),
2461 body: String::new(),
2462 history: crate::model::History::new(),
2463 has_retired_links_key: false,
2464 parent: None,
2465 extra_frontmatter: std::collections::BTreeMap::new(),
2466 archived: false,
2467 entered_at: None,
2468 source: None,
2469 };
2470
2471 let corpus = Corpus {
2472 issues: vec![
2473 issue(1, "A", "todo", &["Cycle 1"]),
2474 issue(2, "B", "done", &["Cycle 1"]),
2475 issue(3, "C", "todo", &["Cycle 2"]),
2476 ],
2477 texts: vec![
2478 "cycle: Cycle 1\nstatus: todo".to_string(),
2479 "cycle: Cycle 1\nstatus: done".to_string(),
2480 "cycle: Cycle 2\nstatus: todo".to_string(),
2481 ],
2482 };
2483
2484 let config = Config::default();
2485 let (_, resolution) = crate::workflow::active_resolution(&config);
2486 let board = BoardState::build_filtered(
2487 config.default_config(),
2488 &config,
2489 &corpus,
2490 "group:cycle",
2491 &StructuredFilter::default(),
2492 &resolution,
2493 );
2494 let titles: Vec<String> = board
2495 .columns
2496 .iter()
2497 .flat_map(|col| col.cards.iter().map(|c| c.title.clone()))
2498 .collect();
2499 assert!(titles.iter().any(|t| t == "A"));
2500 assert!(titles.iter().any(|t| t == "B"));
2501 assert!(titles.iter().any(|t| t == "C"));
2502 }
2503
2504 #[test]
2505 fn group_footer_reports_done_progress() {
2506 let cards = vec![
2507 Card {
2508 number: 1,
2509 title: "A".into(),
2510 priority: crate::model::Priority::Medium,
2511 labels: vec![],
2512 assignee: None,
2513 filename: "a.md".into(),
2514 commit: None,
2515 created: "2026-01-01".into(),
2516 due_date: None,
2517 status: "todo".into(),
2518 category: None,
2519 category_color: None,
2520 done: false,
2521 cycle: Some("Cycle 1".into()),
2522 milestone: None,
2523 blocked: None,
2524 children: None,
2525 archived: false,
2526 },
2527 Card {
2528 number: 2,
2529 title: "B".into(),
2530 priority: crate::model::Priority::Medium,
2531 labels: vec![],
2532 assignee: None,
2533 filename: "b.md".into(),
2534 commit: None,
2535 created: "2026-01-01".into(),
2536 due_date: None,
2537 status: "done".into(),
2538 category: None,
2539 category_color: None,
2540 done: true,
2541 cycle: Some("Cycle 1".into()),
2542 milestone: None,
2543 blocked: None,
2544 children: None,
2545 archived: false,
2546 },
2547 ];
2548 let text = BoardState::group_footer(&cards, Some("cycle")).unwrap();
2549 assert!(text.contains("Cycle 1 1/2 50%"));
2550 }
2551
2552 #[test]
2553 fn build_filtered_bare_number_matches_exact_issue_only() {
2554 use crate::model::Issue;
2555 use crate::search::Corpus;
2556
2557 let to_issue = |number: u32, title: &str, status: &str| Issue {
2558 number,
2559 title: title.to_string(),
2560 status: crate::model::Status::new(status),
2561 labels: vec![],
2562 priority: crate::model::Priority::Medium,
2563 assignee: None,
2564 estimate: None,
2565 commit: None,
2566 gitlab_id: None,
2567 linear_id: None,
2568 mission: None,
2569 task: None,
2570 relationships: vec![],
2571 files: vec![],
2572 cycles: None,
2573 milestones: None,
2574 reviewer: None,
2575 start_date: None,
2576 due_date: None,
2577 created: "2026-01-01".into(),
2578 updated: "2026-01-01".into(),
2579 filename: format!("2026-01-01-{number}.md"),
2580 body: String::new(),
2581 history: crate::model::History::new(),
2582 has_retired_links_key: false,
2583 parent: None,
2584 extra_frontmatter: std::collections::BTreeMap::new(),
2585 archived: false,
2586 entered_at: None,
2587 source: None,
2588 };
2589 let corpus = Corpus {
2590 issues: vec![
2591 to_issue(55, "Concrete target", "todo"),
2592 to_issue(155, "Digit-rich title", "todo"),
2593 to_issue(3, "Login bug", "todo"),
2594 ],
2595 texts: vec![
2596 "Concrete target".to_string(),
2597 "Digit-rich title".to_string(),
2598 "Login bug".to_string(),
2599 ],
2600 };
2601 let config = Config::default();
2602 let (_, resolution) = crate::workflow::active_resolution(&config);
2603 let board_config = config.default_config();
2604
2605 let exact = BoardState::build_filtered(
2606 board_config,
2607 &config,
2608 &corpus,
2609 "55",
2610 &StructuredFilter::default(),
2611 &resolution,
2612 );
2613 assert_eq!(exact.column_cards(0).len(), 1, "only issue 55 matches");
2614 assert_eq!(exact.column_cards(0)[0].title, "Concrete target");
2615 assert_eq!(
2616 exact.column_cards(0)[0].number,
2617 55,
2618 "issue 155's digit sequence must not qualify"
2619 );
2620 assert_eq!(exact.filter.as_deref(), Some("55"));
2621
2622 let miss = BoardState::build_filtered(
2624 board_config,
2625 &config,
2626 &corpus,
2627 "999",
2628 &StructuredFilter::default(),
2629 &resolution,
2630 );
2631 assert!(miss.is_empty(), "no issue 999 => zero matches");
2632
2633 let fuzzy = BoardState::build_filtered(
2635 board_config,
2636 &config,
2637 &corpus,
2638 "login",
2639 &StructuredFilter::default(),
2640 &resolution,
2641 );
2642 assert_eq!(fuzzy.column_cards(0).len(), 1);
2643 assert_eq!(fuzzy.column_cards(0)[0].title, "Login bug");
2644 }
2645
2646 #[test]
2647 fn build_filtered_mixed_query_keeps_fuzzy_not_number() {
2648 use crate::model::Issue;
2649 use crate::search::Corpus;
2650
2651 let to_issue = |number: u32, title: &str| Issue {
2652 number,
2653 title: title.to_string(),
2654 status: crate::model::Status::new("todo"),
2655 labels: vec![],
2656 priority: crate::model::Priority::Medium,
2657 assignee: None,
2658 estimate: None,
2659 commit: None,
2660 gitlab_id: None,
2661 linear_id: None,
2662 mission: None,
2663 task: None,
2664 relationships: vec![],
2665 files: vec![],
2666 cycles: None,
2667 milestones: None,
2668 reviewer: None,
2669 start_date: None,
2670 due_date: None,
2671 created: "2026-01-01".into(),
2672 updated: "2026-01-01".into(),
2673 filename: format!("2026-01-01-{number}.md"),
2674 body: String::new(),
2675 history: crate::model::History::new(),
2676 has_retired_links_key: false,
2677 parent: None,
2678 extra_frontmatter: std::collections::BTreeMap::new(),
2679 archived: false,
2680 entered_at: None,
2681 source: None,
2682 };
2683 let corpus = Corpus {
2684 issues: vec![
2685 to_issue(55, "Concrete target"),
2686 to_issue(155, "Digit-rich title"),
2687 ],
2688 texts: vec![
2689 "Concrete target".to_string(),
2690 "Digit-rich title".to_string(),
2691 ],
2692 };
2693 let config = Config::default();
2694 let (_, resolution) = crate::workflow::active_resolution(&config);
2695 let board_config = config.default_config();
2696
2697 let mixed = BoardState::build_filtered(
2700 board_config,
2701 &config,
2702 &corpus,
2703 "55bug",
2704 &StructuredFilter::default(),
2705 &resolution,
2706 );
2707 assert!(mixed.is_empty(), "mixed query is not an exact-number query");
2708 }
2709
2710 #[test]
2711 fn build_filtered_number_composes_with_structured_filter_and() {
2712 use crate::model::Issue;
2713 use crate::search::Corpus;
2714
2715 let cases = [
2716 (55u32, "Issue 55 todo", "todo"),
2717 (156u32, "Issue 156 todo", "todo"),
2718 (57u32, "Issue 57 done", "done"),
2719 ];
2720 let corpus = Corpus {
2721 issues: cases
2722 .iter()
2723 .map(|&(number, title, status)| Issue {
2724 number,
2725 title: title.to_string(),
2726 status: crate::model::Status::new(status),
2727 labels: vec![],
2728 priority: crate::model::Priority::Medium,
2729 assignee: None,
2730 estimate: None,
2731 commit: None,
2732 gitlab_id: None,
2733 linear_id: None,
2734 mission: None,
2735 task: None,
2736 relationships: vec![],
2737 files: vec![],
2738 cycles: None,
2739 milestones: None,
2740 reviewer: None,
2741 start_date: None,
2742 due_date: None,
2743 created: "2026-01-01".into(),
2744 updated: "2026-01-01".into(),
2745 filename: format!("2026-01-01-{}.md", number),
2746 body: String::new(),
2747 history: crate::model::History::new(),
2748 has_retired_links_key: false,
2749 parent: None,
2750 extra_frontmatter: std::collections::BTreeMap::new(),
2751 archived: false,
2752 entered_at: None,
2753 source: None,
2754 })
2755 .collect(),
2756 texts: cases
2757 .iter()
2758 .map(|(_, title, _)| title.to_string())
2759 .collect(),
2760 };
2761 let config = Config::default();
2762 let (_, resolution) = crate::workflow::active_resolution(&config);
2763 let board_config = config.default_config();
2764 let status_todo = StructuredFilter {
2765 status: Some("todo".to_string()),
2766 ..Default::default()
2767 };
2768 let status_done = StructuredFilter {
2769 status: Some("done".to_string()),
2770 ..Default::default()
2771 };
2772
2773 let matches = BoardState::build_filtered(
2776 board_config,
2777 &config,
2778 &corpus,
2779 "55",
2780 &status_todo,
2781 &resolution,
2782 );
2783 assert_eq!(matches.column_cards(0).len(), 1);
2784 assert_eq!(matches.column_cards(0)[0].title, "Issue 55 todo");
2785
2786 let done_criteria = BoardState::build_filtered(
2789 board_config,
2790 &config,
2791 &corpus,
2792 "55",
2793 &status_done,
2794 &resolution,
2795 );
2796 assert!(done_criteria.is_empty(), "criteria failure hides issue 55");
2797 }
2798
2799 #[test]
2800 fn is_empty_true_only_with_no_cards_anywhere() {
2801 let board = BoardState {
2802 columns: vec![BoardColumn {
2803 name: "a".into(),
2804 cards: vec![],
2805 }],
2806 mismatch: BoardColumn {
2807 name: "Mismatched".into(),
2808 cards: vec![],
2809 },
2810 active_col: 0,
2811 selected: 0,
2812 filter: None,
2813 hover: None,
2814 hidden: vec![],
2815 show_mismatch: true,
2816 };
2817 assert!(board.is_empty());
2818
2819 let populated = BoardState {
2820 columns: vec![BoardColumn {
2821 name: "a".into(),
2822 cards: vec![Card {
2823 number: 1,
2824 title: "X".into(),
2825 priority: crate::model::Priority::Medium,
2826 labels: vec![],
2827 assignee: None,
2828 filename: "x.md".into(),
2829 commit: None,
2830 created: "2026-01-01".into(),
2831 due_date: None,
2832 status: "todo".into(),
2833 category: None,
2834 category_color: None,
2835 done: false,
2836 cycle: None,
2837 milestone: None,
2838 blocked: None,
2839 children: None,
2840 archived: false,
2841 }],
2842 }],
2843 mismatch: BoardColumn {
2844 name: "Mismatched".into(),
2845 cards: vec![],
2846 },
2847 active_col: 0,
2848 selected: 0,
2849 filter: None,
2850 hover: None,
2851 hidden: vec![],
2852 show_mismatch: true,
2853 };
2854 assert!(!populated.is_empty());
2855 }
2856
2857 #[test]
2858 fn build_splits_hidden_columns_out_of_visible_surface() {
2859 use crate::config::{BoardConfiguration, Config};
2860
2861 let mut config = Config::default();
2862 config.kanban.configurations.insert(
2863 "default".to_string(),
2864 BoardConfiguration {
2865 columns: vec!["todo".into(), "done".into()],
2866 hidden_columns: vec!["done".into()],
2867 ..BoardConfiguration::default()
2868 },
2869 );
2870 let store = crate::storage::Store::open(&config).unwrap();
2871 let board = BoardState::build(&store, config.default_config());
2872 assert_eq!(board.columns.len(), 1);
2873 assert_eq!(board.columns[0].name, "todo");
2874 assert_eq!(board.hidden.len(), 1);
2875 assert_eq!(board.hidden[0].name, "done");
2876 assert_eq!(board.total_cols(), 1);
2877 }
2878
2879 #[test]
2880 fn move_left_skips_hidden_column() {
2881 let mut board = BoardState {
2882 columns: vec![BoardColumn {
2883 name: "visible-a".into(),
2884 cards: vec![],
2885 }],
2886 hidden: vec![BoardColumn {
2887 name: "hidden-b".into(),
2888 cards: vec![],
2889 }],
2890 mismatch: BoardColumn {
2891 name: "Mismatched".into(),
2892 cards: vec![],
2893 },
2894 active_col: 0,
2895 selected: 0,
2896 filter: None,
2897 hover: None,
2898 show_mismatch: true,
2899 };
2900 board.move_left();
2901 assert_eq!(board.active_col, 0, "single visible column wraps to itself");
2902 }
2903
2904 #[test]
2905 fn move_right_skips_hidden_column() {
2906 let mut board = BoardState {
2907 columns: vec![BoardColumn {
2908 name: "visible-a".into(),
2909 cards: vec![],
2910 }],
2911 hidden: vec![BoardColumn {
2912 name: "hidden-b".into(),
2913 cards: vec![],
2914 }],
2915 mismatch: BoardColumn {
2916 name: "Mismatched".into(),
2917 cards: vec![],
2918 },
2919 active_col: 0,
2920 selected: 0,
2921 filter: None,
2922 hover: None,
2923 show_mismatch: true,
2924 };
2925 board.move_right();
2926 assert_eq!(board.active_col, 0, "single visible column wraps to itself");
2927 }
2928
2929 #[test]
2930 fn all_columns_hidden_board_is_displayable_and_navigation_safe() {
2931 let mut board = BoardState {
2932 columns: vec![],
2933 hidden: vec![BoardColumn {
2934 name: "only-hidden".into(),
2935 cards: vec![],
2936 }],
2937 mismatch: BoardColumn {
2938 name: "Mismatched".into(),
2939 cards: vec![],
2940 },
2941 active_col: 0,
2942 selected: 0,
2943 filter: None,
2944 hover: None,
2945 show_mismatch: false,
2946 };
2947 assert_eq!(board.total_cols(), 0);
2948 board.move_left();
2949 board.move_right();
2950 assert_eq!(board.active_col, 0, "zero-column board must not panic");
2951 let board2 = BoardState {
2952 columns: vec![],
2953 hidden: vec![],
2954 mismatch: BoardColumn {
2955 name: "Mismatched".into(),
2956 cards: vec![],
2957 },
2958 active_col: 0,
2959 selected: 0,
2960 filter: None,
2961 hover: None,
2962 show_mismatch: false,
2963 };
2964 assert_eq!(board2.total_cols(), 0);
2965 }
2966
2967 fn write_issue_md(
2968 dir: &std::path::Path,
2969 name: &str,
2970 number: u32,
2971 title: &str,
2972 status: &str,
2973 created: &str,
2974 ) {
2975 let contents = format!(
2976 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"{created}\"\nupdated: \"{created}\"\n---\n\nBody text."
2977 );
2978 std::fs::write(dir.join(name), contents).unwrap();
2979 }
2980
2981 #[test]
2982 fn order_by_sort_desc_asc_none() {
2983 let mk = |created: &str| Card {
2984 number: 1,
2985 title: created.to_string(),
2986 priority: crate::model::Priority::Medium,
2987 labels: vec![],
2988 assignee: None,
2989 filename: "x.md".into(),
2990 commit: None,
2991 created: created.to_string(),
2992 due_date: None,
2993 status: "todo".into(),
2994 category: None,
2995 category_color: None,
2996 done: false,
2997 cycle: None,
2998 milestone: None,
2999 blocked: None,
3000 children: None,
3001 archived: false,
3002 };
3003 let dates =
3004 |cards: &[Card]| -> Vec<String> { cards.iter().map(|c| c.created.clone()).collect() };
3005
3006 let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
3007 crate::config::OrderBy::CreatedAtDesc.sort(&mut cards);
3008 assert_eq!(dates(&cards), ["2026-03-01", "2026-02-01", "2026-01-01"]);
3009
3010 let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
3011 crate::config::OrderBy::CreatedAtAsc.sort(&mut cards);
3012 assert_eq!(dates(&cards), ["2026-01-01", "2026-02-01", "2026-03-01"]);
3013
3014 let mut cards = vec![mk("2026-03-01"), mk("2026-01-01")];
3015 crate::config::OrderBy::None.sort(&mut cards);
3016 assert_eq!(
3017 dates(&cards),
3018 ["2026-03-01", "2026-01-01"],
3019 "none is a no-op"
3020 );
3021 }
3022
3023 #[test]
3024 fn build_orders_most_recent_first_by_default() {
3025 use crate::config::Config;
3026 use std::path::PathBuf;
3027 let tmp = tempfile::TempDir::new().unwrap();
3028 let dir = tmp.path().join("issues");
3029 std::fs::create_dir_all(&dir).unwrap();
3030 write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
3031 write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
3032 write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
3033 let config = Config {
3034 issues_dir: PathBuf::from(&dir),
3035 ..Config::default()
3036 };
3037 let store = crate::storage::Store::open(&config).unwrap();
3038 let board = BoardState::build(&store, config.default_config());
3039 let titles: Vec<&str> = board
3040 .column_cards(0)
3041 .iter()
3042 .map(|c| c.title.as_str())
3043 .collect();
3044 assert_eq!(titles, ["new", "mid", "old"], "default desc: newest first");
3045 }
3046
3047 #[test]
3048 fn build_applies_created_at_asc_and_none() {
3049 use crate::config::OrderBy;
3050 use std::path::PathBuf;
3051 let tmp = tempfile::TempDir::new().unwrap();
3052 let dir = tmp.path().join("issues");
3053 std::fs::create_dir_all(&dir).unwrap();
3054 write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
3055 write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
3056 write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
3057 let config = crate::config::Config {
3058 issues_dir: PathBuf::from(&dir),
3059 ..crate::config::Config::default()
3060 };
3061 let store = crate::storage::Store::open(&config).unwrap();
3062
3063 let mut cfg_asc = config.clone();
3064 cfg_asc
3065 .kanban
3066 .configurations
3067 .get_mut("default")
3068 .unwrap()
3069 .order_by = OrderBy::CreatedAtAsc;
3070 let board_asc = BoardState::build(&store, cfg_asc.default_config());
3071 let titles: Vec<&str> = board_asc
3072 .column_cards(0)
3073 .iter()
3074 .map(|c| c.title.as_str())
3075 .collect();
3076 assert_eq!(titles, ["old", "mid", "new"]);
3077
3078 let mut cfg_none = config;
3079 cfg_none
3080 .kanban
3081 .configurations
3082 .get_mut("default")
3083 .unwrap()
3084 .order_by = OrderBy::None;
3085 let board_none = BoardState::build(&store, cfg_none.default_config());
3086 let titles: Vec<&str> = board_none
3087 .column_cards(0)
3088 .iter()
3089 .map(|c| c.title.as_str())
3090 .collect();
3091 assert_eq!(
3092 titles,
3093 ["old", "mid", "new"],
3094 "none keeps the store's filename-ascending order"
3095 );
3096 }
3097
3098 #[test]
3099 fn build_applies_sparse_manual_card_order_when_present() {
3100 use crate::config::Config;
3101 use std::path::PathBuf;
3102 let tmp = tempfile::TempDir::new().unwrap();
3103 let dir = tmp.path().join("issues");
3104 std::fs::create_dir_all(&dir).unwrap();
3105 write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
3106 write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
3107 write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
3108 let config = Config {
3109 issues_dir: PathBuf::from(&dir),
3110 ..Config::default()
3111 };
3112 let store = crate::storage::Store::open(&config).unwrap();
3113 let mut board_cfg = config.default_config().clone();
3114 board_cfg.card_order = vec!["2026-02-01-mid.md".into(), "2026-03-01-new.md".into()];
3115 let board = BoardState::build(&store, &board_cfg);
3116 let titles: Vec<&str> = board
3117 .column_cards(0)
3118 .iter()
3119 .map(|c| c.title.as_str())
3120 .collect();
3121 assert_eq!(titles, ["mid", "new", "old"], "manual order wins");
3122 }
3123
3124 #[test]
3125 fn sparse_card_order_only_returns_changed_cards() {
3126 use crate::config::Config;
3127 use std::path::PathBuf;
3128
3129 let tmp = tempfile::TempDir::new().unwrap();
3130 let dir = tmp.path().join("issues");
3131 std::fs::create_dir_all(&dir).unwrap();
3132 write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
3133 write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
3134 write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
3135 let config = Config {
3136 issues_dir: PathBuf::from(&dir),
3137 ..Config::default()
3138 };
3139 let store = crate::storage::Store::open(&config).unwrap();
3140 let board_cfg = config.default_config().clone();
3141 let mut cards = BoardState::build(&store, &board_cfg)
3142 .column_cards(0)
3143 .to_vec();
3144 cards.swap(0, 1);
3145 let sparse = sparse_card_order(&board_cfg, &cards);
3146 assert_eq!(sparse, ["2026-02-01-mid.md", "2026-03-01-new.md"]);
3147 }
3148
3149 #[test]
3150 fn build_filtered_applies_order_by() {
3151 use crate::model::Issue;
3152 use crate::search::{Corpus, StructuredFilter};
3153
3154 let to_issue = |number: u32, title: &str, created: &str| Issue {
3155 number,
3156 title: title.to_string(),
3157 status: crate::model::Status::new("todo"),
3158 labels: vec![],
3159 priority: crate::model::Priority::Medium,
3160 assignee: None,
3161 estimate: None,
3162 commit: None,
3163 gitlab_id: None,
3164 linear_id: None,
3165 mission: None,
3166 task: None,
3167 relationships: vec![],
3168 files: vec![],
3169 cycles: None,
3170 milestones: None,
3171 reviewer: None,
3172 start_date: None,
3173 due_date: None,
3174 created: created.into(),
3175 updated: "2026-01-01".into(),
3176 filename: format!("2026-01-01-{number}.md"),
3177 body: String::new(),
3178 history: crate::model::History::new(),
3179 has_retired_links_key: false,
3180 parent: None,
3181 extra_frontmatter: std::collections::BTreeMap::new(),
3182 archived: false,
3183 entered_at: None,
3184 source: None,
3185 };
3186 let corpus = Corpus {
3187 issues: vec![
3188 to_issue(1, "old", "2026-01-01"),
3189 to_issue(2, "new", "2026-03-01"),
3190 to_issue(3, "mid", "2026-02-01"),
3191 ],
3192 texts: vec!["old".into(), "new".into(), "mid".into()],
3193 };
3194 let config = crate::config::Config::default();
3195 let (_, resolution) = crate::workflow::active_resolution(&config);
3196 let board = BoardState::build_filtered(
3197 config.default_config(),
3198 &config,
3199 &corpus,
3200 "",
3201 &StructuredFilter::default(),
3202 &resolution,
3203 );
3204 let titles: Vec<&str> = board
3205 .column_cards(0)
3206 .iter()
3207 .map(|c| c.title.as_str())
3208 .collect();
3209 assert_eq!(
3210 titles,
3211 ["new", "mid", "old"],
3212 "default desc applies to filtered results too"
3213 );
3214 }
3215}