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}