Skip to main content

tix/ui/
board.rs

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