tix/ui/detail/state.rs
1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4#[derive(Clone)]
5pub struct DetailState {
6 pub issue: Issue,
7 /// The issue's column usage against its WIP limit, from the shared snapshot
8 /// (WP05 T025): `3/2 max_items 2, block` or similar, or `None` for a column with
9 /// no limit. Filled in by the caller, so the detail pane cannot count a column
10 /// differently from the board's header.
11 pub wip: Option<String>,
12 /// The issue's resolved category label and color hint, from the service
13 /// (WP05 T023, FR-004).
14 ///
15 /// `None` for a column the board does not configure, which the chip renders as
16 /// `(unmapped)` — a statement about the file rather than a silent default. Filled
17 /// in by the caller from the service, so the detail view cannot spell a category
18 /// differently from the board card (T024).
19 pub category: Option<(String, Option<crate::workflow::ColorHint>)>,
20 /// The issue's relationships as the **service** resolves them, not
21 /// `issue.relationships`.
22 ///
23 /// An edge is stored once in its canonical source's file, so the issue's own
24 /// field holds only the edges it happens to own — for `2 related 1` it is
25 /// empty, so rendering it showed a related issue with no relationships. The
26 /// retired symmetric model mirrored the link on both sides by construction,
27 /// so reading the raw field here was a regression, not a simplification.
28 /// Populated by the caller from [`crate::storage::Store::relationships_for`].
29 pub relationships: Vec<crate::relationships::Relationship>,
30 /// The issue's dependency chain, exactly as the service ordered it, and
31 /// whether a traversal bound truncated it.
32 ///
33 /// Taken from the service rather than derived here: re-deriving the walk in
34 /// the renderer is how a detail pane ends up disagreeing with `tix chain`
35 /// about the same snapshot (`contracts/surface-parity.md` §3).
36 pub chain: crate::relationships::DependencyChain,
37 /// The service's blocked reason, or `None` when the issue is not blocked.
38 pub blocked: Option<String>,
39 /// The issue's hierarchy facts, from the shared
40 /// [`crate::commands::parent::HierarchyFacts`].
41 ///
42 /// Taken whole rather than as pre-rendered strings, so the detail pane and
43 /// `tix parent show` are guaranteed to describe the same snapshot and the
44 /// pane can lay them out its own way — which the parity contract allows:
45 /// same facts, same order, presentation may differ.
46 pub hierarchy: crate::commands::parent::HierarchyFacts,
47 /// The scope's health, exactly as the shared document describes it (WP06 T031):
48 /// the state, its layer, the policy version, the History entries, and — when the
49 /// scope has contributors — the derived rollup with them.
50 ///
51 /// Taken whole from [`crate::health::PortableHealth`] rather than as a rendered
52 /// string, so the detail view and the CLI describe the same snapshot and the pane
53 /// can lay it out its own way. `None` when the issue is not in the document.
54 pub health: Option<crate::health::PortableScopeHealth>,
55 /// The health diagnostics about this scope (WP06 T033): a legacy `unknown`, a
56 /// missing reference, a value this build cannot read. Rendered with their remedy,
57 /// so a reader is told what to do rather than only that something is wrong.
58 pub health_diagnostics: Vec<crate::health::HealthDiagnostic>,
59 /// The pane's scroll offset, in content rows. A `Cell` so the renderer can
60 /// apply a one-shot focus jump ([`DetailState::focus_pending`]) at the moment
61 /// it knows the body's offset and the viewport height — the same reason
62 /// `viewport` and `max_scroll` are `Cell`s (tix #1442).
63 pub scroll_offset: std::cell::Cell<u16>,
64 /// The content row where the issue body begins, recorded during the draw.
65 ///
66 /// The mention jump needs it to put a body line at the top of the pane, and only
67 /// the renderer knows how many metadata rows sit above the body — the metadata
68 /// block's height depends on which optional sections the issue has.
69 pub body_start: std::cell::Cell<u16>,
70 /// The mention body token (`alice`, not `@alice`) the reader jumped to, if any.
71 /// The renderer highlights the body line carrying `@<body>`; `None` the rest of
72 /// the time, so the highlight is per-jump and never persists across panes.
73 pub mention_body: Option<String>,
74 /// Whether the next draw should scroll the highlighted mention to the top.
75 ///
76 /// Set by the jump and consumed (cleared) by the render pass, so the reader can
77 /// scroll away afterwards without being yanked back on every frame. A `Cell` for
78 /// the same reason `viewport`/`max_scroll` are: the renderer consumes it
79 /// immutably.
80 pub focus_pending: std::cell::Cell<bool>,
81 /// The pane's last-rendered viewport height, in rows (tix #1442).
82 ///
83 /// Recorded during the draw because the *key* handler is what needs it: "scroll
84 /// one page" has to mean the page the reader is looking at, and only the renderer
85 /// knows how tall that is. A `Cell` for the same reason `App::frame_hit_regions`
86 /// is a `RefCell` — the renderer holds this state immutably.
87 pub viewport: std::cell::Cell<u16>,
88 /// The largest `scroll_offset` that still shows content (tix #1442).
89 ///
90 /// Scroll steps used to be unclamped, so paging past the end of a long issue left
91 /// a blank pane with no way back except scrolling up the same distance. A page
92 /// step makes that much easier to hit, so the bound travels with the step.
93 pub max_scroll: std::cell::Cell<u16>,
94 /// Which relationship `E` (re-kind) and `d` (remove) act on, as an index into
95 /// `relationships` (#1359).
96 ///
97 /// An index and not an id, because the pane does not display ids — it shows
98 /// `r.display`, which carries the kind and the target but not the stable
99 /// `rel_…` token. A user picks an edge by looking at it.
100 ///
101 /// Always within `0..relationships.len()`; [`DetailState::clamp_rel_cursor`] is
102 /// the only thing that writes it, so a removal that shrinks the list cannot leave
103 /// the cursor pointing past the end.
104 pub rel_cursor: usize,
105 /// The issue's classified `related_issues` entries (tix #1513).
106 ///
107 /// Classified by [`crate::related::classify_all`] at open/refresh time, not at
108 /// render time: the classification is stable for a given string and doing it once
109 /// keeps the renderer a pure function of the pane's state. The list is empty for
110 /// an issue with no `related_issues:` field, and the block is then not drawn.
111 pub related: Vec<crate::related::RelatedRef>,
112 /// Which related entry `o` follows, as an index into `related` (tix #1513).
113 ///
114 /// Moved only by the related picker (`r`), never by `j`/`k` — those keep moving
115 /// the **relationship** cursor, which is a different list with a different
116 /// meaning. Always within `0..related.len()`; [`DetailState::clamp_related_cursor`]
117 /// is the only thing that writes it, so indexing cannot panic.
118 pub related_cursor: usize,
119 /// Whether the pane shows an issue it must not offer to mutate (tix #1513).
120 ///
121 /// `true` only for a `related_issues` *path* reference that parsed as an issue
122 /// outside the store — the file is readable but its writes have no store to route
123 /// through, so the pane refuses `e`/`c`/`a` and the relationship operations rather
124 /// than failing with a lookup error.
125 pub read_only: bool,
126 /// The issue's referenced epics, exactly as the service resolved them (WP04 T019,
127 /// FR-003, C-005).
128 ///
129 /// The same canonical rows `tix show` prints
130 /// ([`crate::commands::view::EpicBlockEntry`]), so the pane and the CLI cannot
131 /// word the same epic differently: the pane adds styling, never facts. Built by
132 /// the caller from the one service read
133 /// ([`crate::commands::view::epic_entries`]) because the renderer holds no store.
134 pub epics: Vec<crate::commands::view::EpicBlockEntry>,
135 /// The capacity scopes this issue contributes to, as the shared calculation
136 /// describes them (WP06 T031).
137 ///
138 /// One entry per cycle the issue's own `cycles` field names, plus its assignee
139 /// when it states one — the issue's **own** explicit scope references, folded by
140 /// the library's [`crate::capacity::calculate::CapacityScope`] adapter rather than
141 /// re-resolved here. Empty for an issue in no scope, which draws no block.
142 pub capacity: Vec<IssueCapacityView>,
143}
144
145/// One capacity scope an issue contributes to, as the detail pane draws it (WP06
146/// T031).
147///
148/// Every figure is a field of the shared DTOs
149/// ([`crate::capacity::calculate::CapacityCalculation`] and
150/// [`crate::capacity::snapshot::CapacitySnapshot`]); the pane adds styling, never a
151/// fact, and an absent value is `-` rather than `0`.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct IssueCapacityView {
154 /// The scope the figures belong to, `cycle `Cycle 1`` — the library's own
155 /// resolution, so the name and its stable id are one scope.
156 pub scope: String,
157 /// The issue's own contribution: its raw estimate, the value it contributes in
158 /// the scope's unit, or an explicit statement that it states no estimate.
159 pub issue_line: String,
160 /// The shared calculation's fields, in `tix capacity calculate`'s spelling.
161 pub fields: Vec<(String, String)>,
162 /// The findings that name this issue, or the scope's own, each with its remedy.
163 pub diagnostics: Vec<String>,
164}
165
166impl DetailState {
167 /// Keep the relationship cursor inside the list.
168 ///
169 /// Called after every mutation that can change the list's length — a removal, and
170 /// a refresh after a create. A cursor that survived a removal as `3` into a
171 /// two-element list would make `E` and `d` act on nothing while still reporting
172 /// a selection.
173 pub fn clamp_rel_cursor(&mut self) {
174 if self.relationships.is_empty() {
175 self.rel_cursor = 0;
176 } else if self.rel_cursor >= self.relationships.len() {
177 self.rel_cursor = self.relationships.len() - 1;
178 }
179 }
180
181 /// Keep the related-entry cursor inside the list (tix #1513).
182 ///
183 /// The same rule [`DetailState::clamp_rel_cursor`] follows: `o` indexes
184 /// `related` and the picker writes the index, so the list shrinking (a refresh
185 /// after the `related_issues` field was edited) must not leave it past the end.
186 pub fn clamp_related_cursor(&mut self) {
187 if self.related.is_empty() {
188 self.related_cursor = 0;
189 } else if self.related_cursor >= self.related.len() {
190 self.related_cursor = self.related.len() - 1;
191 }
192 }
193
194 /// Rows a **page** step moves: the last-rendered viewport, less one row of
195 /// overlap (tix #1442).
196 ///
197 /// The overlap is the point. A step that moves exactly the height of the pane
198 /// leaves no line on screen that was there before, so a reader paging through a
199 /// long body has nothing to anchor the new page to. One row of context costs
200 /// nothing and removes that.
201 pub fn page(&self) -> u16 {
202 self.viewport.get().saturating_sub(1).max(1)
203 }
204
205 /// Scroll by `rows` (negative scrolls up), clamped to the content.
206 ///
207 /// The clamp is what keeps a page step usable: without it, one `PgDn` past the
208 /// end of an issue left a blank pane, and the only way back was to scroll up the
209 /// same distance — which, on a page step, is the whole screen.
210 pub fn scroll_by(&mut self, rows: i32) {
211 let next = i32::from(self.scroll_offset.get()) + rows;
212 self.scroll_offset
213 .set(next.clamp(0, i32::from(self.max_scroll.get())) as u16);
214 }
215}