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