Skip to main content

tix/ui/
board.rs

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