Skip to main content

tix/ui/board/
state.rs

1use super::filter::{epic_bucket_label, nest_children, project_bucket_label, sort_cards};
2#[allow(clippy::wildcard_imports)]
3use super::*;
4
5#[derive(Debug, Clone)]
6pub(super) struct GroupSection {
7    pub(super) name: String,
8    pub(super) start: usize,
9    pub(super) end: usize,
10    pub(super) done: usize,
11    pub(super) total: usize,
12}
13
14/// The grouped board's direct-navigation affordance (WP06 T030 step 5, FR-004).
15///
16/// A grouped board must still open an issue's own detail and edit without passing
17/// through the epic layer, so the footer states it rather than leaving it implied by
18/// the card text. One constant, so the render and any test that names it cannot
19/// drift.
20pub(super) const GROUP_OPEN_AFFORDANCE: &str = "Enter: open issue";
21
22/// One badge per issue that records health, keyed by number (WP06 T030).
23///
24/// Built once per board build, like `blocked_reasons` and `child_markers`: the badge
25/// comes from the shared index and rollup, and an issue with neither a recorded value
26/// nor contributors gets no entry at all.
27pub(super) fn health_badges(issues: &[Issue]) -> BTreeMap<u32, String> {
28    let policy = crate::health::HealthPolicy::v1();
29    let index = crate::health::HealthIndex::build(issues, &[], &[], &policy);
30    let mut out = BTreeMap::new();
31    for issue in issues {
32        let scope = crate::health::HealthScope::of_issue(issue.number);
33        let Some((state, recorded)) = index.state_of(issue.number) else {
34            continue;
35        };
36        let derived = crate::health::rollup(&index, &scope, &policy);
37        // Nothing recorded and nobody contributing: nothing to say. A legacy issue is
38        // reported as `unknown` by the detail view and by the CLI, which is where a
39        // reader who asked about it is looking.
40        if recorded.is_none() && derived.contributions.is_empty() {
41            continue;
42        }
43        let current = state.as_str();
44        let badge = if derived.is_derived() && derived.state.as_str() != current {
45            format!("{current}\u{2192}{}", derived.state.as_str())
46        } else {
47            current.to_string()
48        };
49        out.insert(issue.number, badge);
50    }
51    out
52}
53
54#[derive(Debug, Clone)]
55pub struct Card {
56    /// Canonical identity of the issue this card displays.
57    pub reference: crate::id::IssueRef,
58    pub number: u32,
59    pub title: String,
60    pub priority: Priority,
61    pub labels: Vec<String>,
62    pub assignee: Option<String>,
63    pub filename: String,
64    pub commit: Option<String>,
65    pub created: String,
66    pub due_date: Option<String>,
67    pub status: String,
68    /// The issue's resolved category label, from the service's own `label()`
69    /// (WP05 T022, FR-004).
70    ///
71    /// `Some` for a column the board configures, `None` for one it does not — a
72    /// configuration error the user should be able to *see*, rather than a
73    /// transition-policy default silently applied to a display. The renderer shows
74    /// `(unmapped)` for `None`.
75    ///
76    /// **The label carries the meaning, not the color.** A board or terminal with no
77    /// color must still convey the category, so the label is always rendered and the
78    /// `color_hint` only ever tints it (T022 step 3, research R6).
79    pub category: Option<String>,
80    /// The category's color hint, for tinting. Advisory only: dropping it changes
81    /// the display's color and nothing else.
82    pub category_color: Option<crate::workflow::ColorHint>,
83    /// Whether the service considers this issue **complete** (WP05, C-004).
84    ///
85    /// Was `issue.status.is(done_column)` — a column-*name* comparison. On a board
86    /// whose `category_map` puts `completed` somewhere other than its `done_column`
87    /// that is a different question from the one the service answers, and this flag
88    /// drives the card's completion styling and the group's rollup.
89    pub done: bool,
90    pub cycle: Option<String>,
91    pub milestone: Option<String>,
92    /// The issue's blocked reason, from the relationship service, or `None`.
93    ///
94    /// Derived, never stored (data-model §4, C-003): recomputed from the graph
95    /// on every board build, so a card cannot show a stale blocked state after
96    /// its blocker moved.
97    pub blocked: Option<String>,
98    /// The issue's hierarchy marker: `3` for three children, `3 1/3` when one is
99    /// complete, or `None` for a leaf.
100    ///
101    /// `None` for a leaf rather than `Some("0")`, so a card cannot show a child
102    /// count for an issue that is not a parent. Built from the service's
103    /// [`crate::commands::parent::HierarchyFacts`] on every board build, so it
104    /// cannot go stale when a child is created or moved — the same discipline
105    /// `blocked` follows.
106    pub children: Option<String>,
107    /// The issue's decomposition parent, when it has one (tix #1399).
108    ///
109    /// Carried on the card rather than looked up at render time, so the board can
110    /// place a sub-issue beneath its parent without a second pass over the issues.
111    pub parent: Option<crate::id::IssueRef>,
112    /// How deeply the card is nested in the board's tree rendering (tix #1399).
113    ///
114    /// `0` for a top-level card, `1` for a sub-issue shown directly under its
115    /// parent. Never above `1`: only one level is rendered, so a grandchild stays
116    /// at `0` and is drawn as a normal top-level card.
117    pub depth: u8,
118    /// Whether this issue is **archived** — its file is dot-prefixed (issue #1492).
119    ///
120    /// A card only ever carries `true` on a board built with archived issues
121    /// included (the TUI's archive toggle); the default board is enumerated from
122    /// the active set and never sees one. Rendered as the `[archived]` token, in the
123    /// same bracketed language as `[~]` and `[N]`.
124    pub archived: bool,
125
126    /// The issue's compact health badge (mission `project-issue-health-status-01M3C8HX`,
127    /// WP06 T030): the state the issue records, and the derived value beside it when
128    /// the two differ (`at_risk->needs_attention`).
129    ///
130    /// Built from the **shared** index and rollup — never from a surface-local reading
131    /// of the `health:` block, and never from a comparison of statuses (C-006). The
132    /// spelling is the state's stable `as_str()`, the same word the CLI's
133    /// `--columns health` cell prints, so the two surfaces cannot diverge. `None` when
134    /// the issue records no health at all: a legacy card carries no badge, exactly as
135    /// a leaf carries no child marker, so the board stays scannable.
136    pub health: Option<String>,
137    /// How many `related_issues` entries the issue carries (tix #1513).
138    ///
139    /// A plain count, computed from the issue set the board was built over, so the
140    /// card cannot go stale when the field is edited — the same discipline `blocked`
141    /// and `children` follow. `0` means no marker at all, which keeps a column of
142    /// cards scannable.
143    pub related: usize,
144    /// The issue's **resolved project ids**, in registry declaration order
145    /// (WP06 T028).
146    ///
147    /// Resolved once per board build from the shared membership index
148    /// ([`crate::projects::ProjectIndex::build`]), so the board cannot disagree with
149    /// `tix project list` about who is a member, and an association the registry does
150    /// not know is not a project here either. Empty means the issue is in no project,
151    /// which is the bucket the partition labels `(no project)`.
152    ///
153    /// Carried on the card rather than looked up at render time for the same reason
154    /// `blocked`, `children` and `related` are: the renderer has no store, and a
155    /// second read of the issue set per frame is work the build already did.
156    pub projects: Vec<String>,
157    /// The issue's **resolved epic ids**, in registry declaration order (WP06
158    /// T030).
159    ///
160    /// Resolved once per board build from the shared membership index
161    /// ([`crate::epics::EpicIndex::build`]) — only references the registry can
162    /// resolve — so the board cannot disagree with `tix epic list` about who is a
163    /// member, and a reference the registry does not know is not an epic here
164    /// either. Empty means the issue is in no epic, which is the bucket the
165    /// partition labels `(no epic)`.
166    ///
167    /// Carried on the card rather than looked up at render time for the same reason
168    /// `projects` is: the renderer has no store, and a second read of the issue set
169    /// per frame is work the build already did.
170    pub epics: Vec<String>,
171}
172
173/// The project partition of one board build (WP06 T028).
174///
175/// Built once from the **shared** membership index
176/// ([`crate::projects::ProjectIndex::build`]), so the board cannot disagree with
177/// `tix project list` about who is a member: the resolved ids are the registry's,
178/// and an association the registry does not know is not a project here either — it
179/// is reported by `tix lint` instead, and its issue is bucketed `(no project)`.
180///
181/// That last point is a **chosen asymmetry** with the criterion rather than a
182/// disagreement to be papered over: `StructuredFilter::project_matches` matches a
183/// stored association **verbatim**, so `project:<stale-id>` still *finds* the issues
184/// carrying a deleted project's id (the repair affordance `tix project remove`
185/// needs), while this partition — which answers "which project owns this issue?" —
186/// has no bucket for an id nothing resolves to. See `project_matches`'s doc.
187///
188/// The bucket rules are the design decision T027 step 4 records:
189///
190/// - an issue in **no** project is its own clearly labelled `(no project)` bucket;
191/// - an issue in exactly **one** project sits in that project's bucket;
192/// - an issue in **several** projects sits in a single `(multiple projects)`
193///   bucket rather than being duplicated into each project's section.
194///
195/// Duplication is the road not taken, and deliberately: one issue would become two
196/// cards, and the board's cursor, hit-testing and per-column counts are all
197/// index-into-one-list arithmetic — a card that appeared twice would need a second
198/// identity the renderer does not have. The **filter** still matches an issue in
199/// two projects under either id (that is [`crate::search::StructuredFilter::project_matches`]),
200/// so the union semantics are not lost where they matter.
201pub(super) struct ProjectPartition {
202    /// Issue number -> its resolved project ids, registry declaration order.
203    pub(super) memberships: HashMap<u32, Vec<String>>,
204    /// Project id -> its declaration index, for the sort rank.
205    pub(super) order: HashMap<String, usize>,
206}
207
208#[derive(Debug, Clone)]
209pub struct BoardColumn {
210    pub name: String,
211    pub cards: Vec<Card>,
212}
213
214/// What a pointer position over the board resolves to (D3). `row` is the
215/// card's index inside its column — the same index [`BoardState::selected`]
216/// uses — so the caller can compare it directly against the selection.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum HitTarget {
219    Card { col: usize, row: usize },
220    Column { col: usize },
221}
222
223/// Compact capacity cells the board's cycle group headers append (WP06 T032).
224///
225/// One already-rendered cell per configured cycle scope, keyed by **both** the cycle
226/// reference a card names and the scope's resolved display label, so either spelling
227/// of one scope finds it. Built by [`crate::app::App`] from the shared calculation —
228/// never here, and never from a second pass over the issues.
229pub type CapacitySummaries = std::collections::BTreeMap<String, String>;
230
231#[derive(Debug, Clone)]
232pub struct BoardState {
233    /// Visible columns only — configured columns not listed in the active
234    pub columns: Vec<BoardColumn>,
235    /// Columns hidden from the active board configuration. Their cards stay
236    /// in board state (keyed by column name) and remain reachable via
237    /// filters/search (DM-01M2MHJB884WCTVCDHPD2T3GE7); they never render.
238    pub hidden: Vec<BoardColumn>,
239    pub mismatch: BoardColumn,
240    /// Whether the computed "Mismatched" pseudo-column is part of the
241    /// navigation/render surface (C-002 / FR-003). Cards in unknown statuses
242    /// are always collected into [`BoardState::mismatch`]; the column is only
243    /// shown when the effective configuration sets `show_mismatch`.
244    pub show_mismatch: bool,
245    pub active_col: usize,
246    pub selected: usize,
247    /// The `(col, card index)` the pointer is hovering (D8), set by `Moved`
248    /// events and cleared on `Up` / when the pointer leaves the board.
249    /// Rendered as a highlight only — it never changes the selection.
250    pub hover: Option<(usize, usize)>,
251    /// The column the pointer is over, set for **any** position inside a column —
252    /// including empty space below the last card, where `hover` is `None`. The
253    /// renderer marks the hovered column from this, so an empty column lights up
254    /// too. Cleared with `hover`.
255    pub hover_col: Option<usize>,
256    pub filter: Option<String>,
257}
258
259/// Shorten `msg` to at most `budget` **display cells**, appending an ellipsis
260/// when anything was dropped.
261///
262/// The marker is appended *within* the budget rather than after it, so the result
263/// is never wider than the area it is rendered into. A combining mark or a
264/// zero-width control contributes no width, so it is kept without consuming
265/// budget rather than being dropped.
266pub fn truncate_to_cells(msg: &str, budget: usize) -> String {
267    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
268
269    const ELLIPSIS: char = '\u{2026}';
270    if budget == 0 {
271        return String::new();
272    }
273    if UnicodeWidthStr::width(msg) <= budget {
274        return msg.to_string();
275    }
276
277    // Reserve one cell for the marker. A double-width character cannot fit in
278    // the last cell, so the loop stops on a width overflow rather than on a
279    // character count.
280    let marker_width = UnicodeWidthChar::width(ELLIPSIS).unwrap_or(1);
281    let mut out = String::new();
282    let mut used = 0usize;
283    for ch in msg.chars() {
284        let w = UnicodeWidthChar::width(ch).unwrap_or(0);
285        if used + w + marker_width > budget {
286            break;
287        }
288        out.push(ch);
289        used += w;
290    }
291    out.push(ELLIPSIS);
292    out
293}
294
295/// The blocked reason for every issue, read from the relationship service.
296///
297/// One graph over the **whole** set, so the answer does not depend on which
298/// issues the board happens to be showing (see [`BoardState::blocked`]).
299pub(super) fn blocked_reasons(
300    issues: &[crate::model::Issue],
301    done_column: &str,
302) -> std::collections::BTreeMap<u32, String> {
303    let views: Vec<crate::relationships::IssueView<'_>> =
304        issues.iter().map(crate::model::Issue::issue_view).collect();
305    let graph = crate::relationships::RelationshipGraph::new(&views, done_column);
306    issues
307        .iter()
308        .filter_map(|issue| {
309            crate::commands::blocked_note(&graph.blocked_state(issue.number))
310                .map(|note| (issue.number, note))
311        })
312        .collect()
313}
314
315/// The child-count marker for every issue that has children, keyed by number.
316///
317/// One index over the whole set, so the marker does not depend on which issues the
318/// board is showing. The text comes from the shared `HierarchyFacts`, which is what
319/// `tix parent show`, `tix show` and `tix list --show progress` read, so the four
320/// surfaces cannot report different figures.
321pub(super) fn child_markers(
322    issues: &[crate::model::Issue],
323    done_column: &str,
324) -> std::collections::BTreeMap<u32, String> {
325    let views: Vec<crate::relationships::IssueView<'_>> =
326        issues.iter().map(crate::model::Issue::issue_view).collect();
327    let index = crate::hierarchy::HierarchyIndex::build(&views);
328    issues
329        .iter()
330        .filter_map(|issue| {
331            let facts =
332                crate::commands::parent::facts_for(&index, issues, issue.number, done_column);
333            facts.has_children().then(|| {
334                (
335                    issue.number,
336                    format!(
337                        "{} {}/{}",
338                        facts.rollup.direct_children,
339                        facts.rollup.completed_children,
340                        facts.rollup.progress.total
341                    ),
342                )
343            })
344        })
345        .collect()
346}
347
348impl BoardState {
349    /// WP05: the resolution must be for the board this call is building, not for
350    /// whichever board happens to be `default`.
351    ///
352    /// `board_config` can be any of the configured boards, and a board selected in
353    /// the TUI is not necessarily the default one. Resolving against the default
354    /// would give a card on the `staging` board the categories configured for
355    /// `default` — a wrong answer rather than a missing one, which is worse.
356    ///
357    /// The board is identified by **pointer identity** against the config's boards,
358    /// because `build` receives a `&BoardConfiguration` and not a name. That is exact
359    /// and needs no name plumbing; a board not found in the config falls back to its
360    /// own columns with the global vocabulary, which is the honest reading of a
361    /// configuration `tix` did not load.
362    /// Public so the startup path can build a board from a single shared
363    /// enumeration (tix #1518) instead of each caller re-deriving it.
364    pub fn resolution_for(
365        store: &Store,
366        board_config: &BoardConfiguration,
367    ) -> crate::workflow::CategoryResolution {
368        let name = store
369            .config
370            .configs()
371            .find(|(_, board)| std::ptr::eq(*board, board_config))
372            .map(|(name, _)| name)
373            .unwrap_or("default");
374        crate::workflow::resolution(&store.config, board_config, name)
375    }
376
377    pub fn build(store: &Store, board_config: &BoardConfiguration) -> Self {
378        Self::build_with(store, board_config, false, false)
379    }
380
381    /// [`BoardState::build`], optionally including **archived** issues (issue #1492).
382    ///
383    /// `include_archived == false` is the default and the fast path: enumeration reads
384    /// [`Store::list`], which drops a dot-prefixed filename from the directory entry
385    /// without opening the file. `true` reads [`Store::list_all`], which is what the
386    /// TUI's archive toggle asks for *on demand* — the point being that a repository
387    /// with thousands of archived issues is never walked for a board that does not
388    /// show them.
389    pub fn build_with_archived(
390        store: &Store,
391        board_config: &BoardConfiguration,
392        include_archived: bool,
393    ) -> Self {
394        Self::build_with(store, board_config, include_archived, false)
395    }
396
397    /// [`BoardState::build_with_archived`], optionally rendering sub-issues under
398    /// their parents (tix #1399).
399    ///
400    /// `show_children == true` reorders every column so a card's direct sub-issues
401    /// follow it, marked `depth = 1` (see [`nest_children`]). The reorder happens at
402    /// **build** time, not at draw time, because the selection index and the pointer
403    /// hit-test both address cards by their index inside a column — a tree only the
404    /// renderer knew about would make a click or a `j` land on the wrong card.
405    pub fn build_with(
406        store: &Store,
407        board_config: &BoardConfiguration,
408        include_archived: bool,
409        show_children: bool,
410    ) -> Self {
411        let issues = if include_archived {
412            store.list_all()
413        } else {
414            store.list()
415        }
416        .unwrap_or_default();
417        let resolution = Self::resolution_for(store, board_config);
418        Self::build_from_issues(
419            &issues,
420            board_config,
421            &store.config,
422            &resolution,
423            show_children,
424        )
425    }
426
427    /// Build a board from an in-memory slice of issues (issue #10 follow-up).
428    ///
429    /// The single build path: [`BoardState::build_with`] reads the store and
430    /// delegates here, and the TUI's incremental refresh rebuilds from its
431    /// in-memory corpus without re-reading the tree. `config` supplies the
432    /// completion column; `resolution` is the category resolution of the board
433    /// being built (see [`BoardState::resolution_for`]).
434    pub fn build_from_issues(
435        issues: &[Issue],
436        board_config: &BoardConfiguration,
437        config: &crate::config::Config,
438        resolution: &crate::workflow::CategoryResolution,
439        show_children: bool,
440    ) -> Self {
441        // WP09: the **service's** completion column, not `done_column`. The
442        // `blocked_reasons` and `child_markers` helpers take a column *name* — that is
443        // their existing signature and it is not what makes them wrong. What was
444        // wrong is which name they were handed: on a board whose `category_map` puts
445        // `completed` on `shipped` while `done_column` is `done`, the TUI's blocked
446        // marker was computed against `done_column` while `tix list`'s was computed
447        // against the service, so the same issue read as blocked in the TUI and clear
448        // in the CLI. The name is now resolved once, by the service.
449        let done_column = crate::workflow::completion_column_or_default(board_config, config);
450        let done_column = done_column.as_str();
451        let blocked = blocked_reasons(issues, done_column);
452        let children = child_markers(issues, done_column);
453        let show_mismatch = board_config.show_mismatch;
454        let hidden_names: Vec<&str> = board_config
455            .hidden_columns
456            .iter()
457            .map(String::as_str)
458            .collect();
459        let mut column_map: Vec<Vec<Card>> =
460            board_config.columns.iter().map(|_| Vec::new()).collect();
461        let mut mismatch_cards = Vec::new();
462
463        let health_badges = health_badges(issues);
464        // The same partition the filtered build uses, from the same shared index:
465        // an unfiltered board and a project-filtered one must label a card's
466        // project identically (WP06 T028).
467        let projects = ProjectPartition::build(config, issues);
468        // The epic partition, from the same shared membership index, so an
469        // unfiltered board and an epic-filtered one label a card's epic identically
470        // (WP06 T030).
471        let epics = EpicPartition::build(config, issues);
472        for issue in issues {
473            let card = Card {
474                reference: crate::id::canonical_ref(issue),
475                number: issue.number,
476                title: issue.title.clone(),
477                priority: issue.priority.clone(),
478                labels: issue.labels.clone(),
479                assignee: issue.assignee.clone(),
480                filename: issue.filename.clone(),
481                commit: issue.commit.clone(),
482                created: issue.created.clone(),
483                due_date: issue.due_date.clone(),
484                status: issue.status.display().to_string(),
485                category: crate::workflow::category_of(issue, resolution)
486                    .map(|c| c.label().to_string()),
487                category_color: crate::workflow::category_of(issue, resolution)
488                    .and_then(|c| c.color_hint()),
489                done: crate::workflow::is_completed(issue, resolution),
490                cycle: issue.cycles.as_ref().and_then(|v| v.first().cloned()),
491                milestone: issue.milestones.as_ref().and_then(|v| v.first().cloned()),
492                blocked: blocked.get(&issue.number).cloned(),
493                children: children.get(&issue.number).cloned(),
494                parent: issue.parent,
495                depth: 0,
496                archived: issue.archived,
497                related: issue.related_issues.len(),
498                health: health_badges.get(&issue.number).cloned(),
499                projects: projects.memberships(issue.number),
500                epics: epics.memberships(issue.number),
501            };
502            if let Some(idx) = board_config
503                .columns
504                .iter()
505                .position(|c| c == issue.status.display())
506            {
507                column_map[idx].push(card);
508            } else {
509                mismatch_cards.push(card);
510            }
511        }
512
513        for cards in column_map.iter_mut() {
514            sort_cards(board_config, cards);
515        }
516        sort_cards(board_config, &mut mismatch_cards);
517
518        let mut columns: Vec<BoardColumn> = Vec::new();
519        let mut hidden: Vec<BoardColumn> = Vec::new();
520        for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
521            let col = BoardColumn {
522                name: name.clone(),
523                cards,
524            };
525            if hidden_names.contains(&name.as_str()) {
526                hidden.push(col);
527            } else {
528                columns.push(col);
529            }
530        }
531
532        let mismatch = BoardColumn {
533            name: "Mismatched".to_string(),
534            cards: mismatch_cards,
535        };
536
537        let mut state = BoardState {
538            columns,
539            hidden,
540            mismatch,
541            show_mismatch,
542            active_col: 0,
543            selected: 0,
544            filter: None,
545            hover: None,
546            hover_col: None,
547        };
548        state.apply_child_order(show_children);
549        state
550    }
551
552    /// Nest each column's sub-issues beneath their parents when `show_children` is
553    /// on (tix #1399). A no-op when it is off, so the default build is untouched.
554    ///
555    /// Applied to the visible columns and the "Mismatched" pseudo-column — every
556    /// surface `render` and `hit_test` actually address. Hidden columns have no
557    /// rendered presence, so they are left in their flat order.
558    pub(super) fn apply_child_order(&mut self, show_children: bool) {
559        if !show_children {
560            return;
561        }
562        for column in &mut self.columns {
563            nest_children(&mut column.cards);
564        }
565        nest_children(&mut self.mismatch.cards);
566    }
567
568    /// True when no card anywhere is visible (used for zero-match feedback).
569    pub fn is_empty(&self) -> bool {
570        self.columns.iter().all(|c| c.cards.is_empty())
571            && (!self.show_mismatch || self.mismatch.cards.is_empty())
572    }
573
574    pub fn total_cols(&self) -> usize {
575        if self.show_mismatch {
576            self.columns.len() + 1
577        } else {
578            self.columns.len()
579        }
580    }
581
582    /// Columns a card can be moved into: the configured columns plus (when
583    /// shown) the "Mismatched" sentinel, which is never a move target. The
584    /// `</>` move keys navigate *among movable columns* only.
585    pub fn real_cols(&self) -> usize {
586        self.columns.len()
587    }
588
589    pub fn column_name(&self, idx: usize) -> &str {
590        if idx < self.columns.len() {
591            &self.columns[idx].name
592        } else if idx == self.columns.len() && self.show_mismatch {
593            &self.mismatch.name
594        } else {
595            "Unknown"
596        }
597    }
598
599    /// The name under the active cursor, resolving **hidden** columns too
600    /// (visible region first, then hidden, then Unknown). Lets `toggle` keep a
601    /// logical cursor on a just-hidden column so a second press re-shows it
602    /// (T017: "a second press on the same column re-shows it").
603    pub fn active_column_name(&self) -> &str {
604        let visible = self.columns.len();
605        if self.active_col < visible {
606            &self.columns[self.active_col].name
607        } else {
608            let hidden_idx = self.active_col - visible;
609            if hidden_idx < self.hidden.len() {
610                &self.hidden[hidden_idx].name
611            } else {
612                "Unknown"
613            }
614        }
615    }
616
617    pub fn column_cards(&self, idx: usize) -> &[Card] {
618        if idx < self.columns.len() {
619            &self.columns[idx].cards
620        } else if idx == self.columns.len() && self.show_mismatch {
621            &self.mismatch.cards
622        } else {
623            &[]
624        }
625    }
626
627    pub fn get_selected_card(&self) -> Option<&Card> {
628        self.column_cards(self.active_col).get(self.selected)
629    }
630
631    /// The `(column, row)` of the only card the board is displaying, if there is
632    /// exactly one (tix #1744).
633    ///
634    /// "Displayed" is what `render` and `hit_test` address: the active board's
635    /// visible columns plus the "Mismatched" pseudo-column when it is shown.
636    /// Hidden columns never render, so their cards do not count. `None` when the
637    /// board shows zero cards or more than one.
638    pub fn sole_visible_card(&self) -> Option<(usize, usize)> {
639        let mut found = None;
640        for (col, column) in self.columns.iter().enumerate() {
641            for row in 0..column.cards.len() {
642                if found.is_some() {
643                    return None;
644                }
645                found = Some((col, row));
646            }
647        }
648        if self.show_mismatch {
649            for row in 0..self.mismatch.cards.len() {
650                if found.is_some() {
651                    return None;
652                }
653                found = Some((self.columns.len(), row));
654            }
655        }
656        found
657    }
658
659    pub fn move_left(&mut self) {
660        let total = self.real_cols();
661        self.active_col = if total == 0 {
662            0
663        } else if self.active_col >= total {
664            total - 1
665        } else {
666            (self.active_col + total - 1) % total
667        };
668        self.clamp_selection();
669    }
670
671    pub fn move_right(&mut self) {
672        let total = self.real_cols();
673        self.active_col = if total == 0 || self.active_col >= total {
674            0
675        } else {
676            (self.active_col + 1) % total
677        };
678        self.clamp_selection();
679    }
680
681    pub fn move_up(&mut self) {
682        if self.selected > 0 {
683            self.selected -= 1;
684        }
685    }
686
687    pub fn move_down(&mut self) {
688        let cards = self.column_cards(self.active_col);
689        if self.selected + 1 < cards.len() {
690            self.selected += 1;
691        }
692    }
693
694    /// Move the selection by `cards` (negative moves up), clamped to the column
695    /// (tix #1442 follow-up).
696    ///
697    /// The page step: `move_up`/`move_down` are the one-card steps, this is the same
698    /// move by a screenful. It clamps for the reason the detail pane's scroll does — a
699    /// jump past the end should land on the last card, not walk off it.
700    pub fn move_selection_by(&mut self, cards: i32) {
701        let len = self.column_cards(self.active_col).len();
702        if len == 0 {
703            self.selected = 0;
704            return;
705        }
706        let next = i32::try_from(self.selected)
707            .unwrap_or(i32::MAX)
708            .saturating_add(cards);
709        self.selected = next.clamp(0, i32::try_from(len).unwrap_or(i32::MAX) - 1) as usize;
710    }
711
712    pub fn clamp_selection(&mut self) {
713        let cards = self.column_cards(self.active_col);
714        if cards.is_empty() {
715            self.selected = 0;
716        } else if self.selected >= cards.len() {
717            self.selected = cards.len() - 1;
718        }
719    }
720
721    pub fn select_column(&mut self, col: usize) {
722        let total = self.real_cols();
723        if total > 0 {
724            self.active_col = col % total;
725        }
726        self.clamp_selection();
727    }
728
729    pub(super) fn group_footer(cards: &[Card], mode: Option<&str>) -> Option<String> {
730        let sections = Self::group_sections(cards, mode)?;
731        let text = sections
732            .into_iter()
733            .map(|section| {
734                let pct = (section.done * 100).checked_div(section.total).unwrap_or(0);
735                format!("{} {}/{} {pct}%", section.name, section.done, section.total)
736            })
737            .collect::<Vec<_>>()
738            .join(" | ");
739        // The grouped board keeps **direct issue navigation** (FR-004): the card
740        // cursor and `Enter` open the issue's own detail and edit, with the group
741        // label only as the section above it. The route is stated beside the
742        // partition rather than implied by the card text.
743        Some(format!("{text}  ·  {GROUP_OPEN_AFFORDANCE}"))
744    }
745
746    pub(super) fn card_group(card: &Card, key: &str) -> String {
747        match key {
748            "cycle" => card.cycle.clone().unwrap_or_else(|| "(none)".to_string()),
749            "milestone" => card
750                .milestone
751                .clone()
752                .unwrap_or_else(|| "(none)".to_string()),
753            // WP06 T028: the project bucket is read off the card's resolved
754            // memberships, so a `group:project` board shows one section per project
755            // in declaration order, then `(multiple projects)`, then the labelled
756            // `(no project)` bucket last.
757            "project" => project_bucket_label(&card.projects),
758            // WP06 T030: the epic bucket, read off the card's resolved references
759            // — one section per epic in declaration order, then `(multiple epics)`,
760            // then the labelled `(no epic)` bucket last.
761            "epic" => epic_bucket_label(&card.epics),
762            _ => "(none)".to_string(),
763        }
764    }
765
766    pub(super) fn group_sections(cards: &[Card], mode: Option<&str>) -> Option<Vec<GroupSection>> {
767        let key =
768            mode.filter(|m| *m == "cycle" || *m == "milestone" || *m == "project" || *m == "epic")?;
769        if cards.is_empty() {
770            return None;
771        }
772        let mut sections = Vec::new();
773        let mut current = String::new();
774        let mut start = 0usize;
775        let mut done = 0usize;
776        let mut total = 0usize;
777        for (idx, card) in cards.iter().enumerate() {
778            let group = Self::card_group(card, key);
779            if idx == 0 {
780                current = group;
781            } else if group != current {
782                sections.push(GroupSection {
783                    name: std::mem::take(&mut current),
784                    start,
785                    end: idx,
786                    done,
787                    total,
788                });
789                current = group;
790                start = idx;
791                done = 0;
792                total = 0;
793            }
794            total += 1;
795            if card.done {
796                done += 1;
797            }
798        }
799        sections.push(GroupSection {
800            name: current,
801            start,
802            end: cards.len(),
803            done,
804            total,
805        });
806        Some(sections)
807    }
808}
809
810/// Render options carried from the active board configuration into the board
811/// draw. Kept tiny and copyable so tests and call sites stay light.
812#[derive(Debug, Clone, Copy)]
813pub struct BoardDisplay {
814    pub wrap_titles: bool,
815    pub title_lines: usize,
816}
817
818impl Default for BoardDisplay {
819    fn default() -> Self {
820        BoardDisplay {
821            wrap_titles: true,
822            title_lines: 3,
823        }
824    }
825}
826
827impl From<&crate::config::BoardConfiguration> for BoardDisplay {
828    fn from(config: &crate::config::BoardConfiguration) -> Self {
829        BoardDisplay {
830            wrap_titles: config.wrap_titles,
831            title_lines: config.title_lines,
832        }
833    }
834}