tix/app/state.rs
1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum Action {
6 None,
7 Quit,
8 OpenEditor(String),
9 /// Open the issue file in the resolved read-only viewer (tix #1507).
10 OpenViewer(String),
11 /// Open an **arbitrary** file in the resolved read-only viewer (tix #1513).
12 ///
13 /// Distinct from [`Action::OpenViewer`], which resolves a store-relative
14 /// filename through the store: a `related_issues` *path* entry may point
15 /// anywhere, including outside the store, and there is no store entry to look
16 /// up. The path is absolute — it comes from [`crate::related::resolve_path`].
17 OpenViewerAt(PathBuf),
18 /// Run `tix` in a child (or parent) repository, with the working directory
19 /// set to it (tix #1529). Handled in the event loop because it hands the
20 /// terminal to the child, exactly as [`Action::OpenEditor`] does; on exit the
21 /// parent TUI is restored, so the jump is inherently reversible.
22 OpenRepo(PathBuf),
23 /// Re-open the editor on the conflict prompt's temp copy so the user can
24 /// compare it with the newer on-disk version (tix #1510). Handled in the
25 /// event loop because it hands the terminal to the child, exactly as
26 /// [`Action::OpenEditor`] does.
27 CompareEdit,
28 /// Re-open the editor on the repair prompt's retained copy (tix #1506), so a
29 /// problem with no automatic fix can be corrected by hand. Handled in the
30 /// event loop for the same reason [`Action::CompareEdit`] is: the child needs
31 /// the terminal.
32 ReeditRepair,
33 OpenAgent(String),
34 /// Switch to a named board by tab index (mouse click on a tab, D5/D6).
35 SwitchBoard(usize),
36 /// Confirm-delete dialog Yes/No button clicked; maps to the `y` /
37 /// any-other-key branch of [`App::handle_confirm_delete_key`] (D6).
38 ConfirmDeleteYes,
39 ConfirmDeleteNo,
40 /// A filter-modal criterion row was clicked; focus it and open its picker or,
41 /// on a date row, the calendar (D6).
42 FilterModalFocus(FilterField),
43 /// A visible option row of the filter modal's open picker was clicked; choose
44 /// it by its position in the filtered option list.
45 FilterPickerOption(usize),
46 /// A click landed outside the filter modal's open picker; close it.
47 FilterPickerClose,
48 /// A settings-modal row was clicked; select it and run the Enter action.
49 SettingsRow(usize),
50 /// A dashboards **detail** row was clicked; select it and run the Enter
51 /// action (tui-config-mode).
52 ConfigDetailRow(usize),
53 /// A date-picker day cell or control was clicked; maps to the equivalent
54 /// key branch (D6): day → select that date, Prev/Next → page the month,
55 /// Submit → Enter.
56 DatePickerHit(DatePickerHit),
57 /// The help overlay was clicked; close it like the `?`/`q`/Esc branch.
58 HelpClose,
59 /// A vertical mode-navigation entry was clicked; switch the shell to that
60 /// top-level mode (tui-mode-navigation).
61 SwitchMode(TopMode),
62 /// The Triage mode's status control was clicked; open the status picker
63 /// (tui-triage-mode).
64 TriageOpenStatus,
65 /// The Triage mode's assignee control was clicked; open the assignee picker.
66 TriageOpenAssignee,
67 /// The Triage mode's create control was clicked; start the inline prompt.
68 TriageCreate,
69 /// The Triage mode's queue control was clicked; move the focus there. The
70 /// queue has no action of its own, so this focuses and stops.
71 TriageFocusQueue,
72 /// A visible option row of the open Triage picker was clicked; choose it by
73 /// its position in the filtered option list.
74 TriagePickOption(usize),
75 /// A click landed outside the open Triage picker; close it.
76 TriageDropdownClose,
77 /// The decision modal's `OK` button was clicked; advance the modal through
78 /// preview, arm, and apply in the same order as the keyboard.
79 TriageModalOk,
80 /// A Cycle-mode row was clicked; select that cycle (tui-cycle-mode).
81 CycleSelect(usize),
82 /// A row of the shared flat issue table was clicked; open that issue's
83 /// detail pane. The filename is store-relative, exactly what
84 /// [`crate::storage::IssueSelector::Filename`] takes. Raised by both the
85 /// Cycle mode's and the Milestones mode's issue table
86 /// ([`crate::ui::issue_table`]).
87 OpenIssue(String),
88 /// The New Issue mode's Template row was clicked; open the template picker
89 /// (tui-new-issue-mode).
90 TemplateOpen,
91 /// A visible option row of the open template picker was clicked; choose that
92 /// template by its position in the filtered option list.
93 TemplatePickOption(usize),
94 /// A Config-mode section-rail entry was clicked; switch the section
95 /// (tui-config-mode).
96 ConfigSection(ConfigSection),
97 /// A Config-mode cycle/milestone row was clicked; select that row's index
98 /// in the planning cursor (tui-config-mode).
99 ConfigPlanningRow(usize),
100 /// A Config-mode Types/custom-attributes row was clicked; select that row's
101 /// index in the section's list cursor (tui-config-mode).
102 ConfigListRow(usize),
103 /// A rail **branch** under the Board trunk was clicked; switch the active
104 /// board to the named configuration (tui-mode-navigation).
105 SelectBoard(String),
106 /// A rail branch under the Cycles trunk was clicked; enter the Cycle mode
107 /// focused on the named cycle (tui-mode-navigation).
108 SelectCycle(String),
109 /// A rail branch under the Milestones trunk was clicked; enter the
110 /// Milestones mode focused on the named milestone (tui-mode-navigation).
111 SelectMilestone(String),
112 /// A rail branch under the New Issue trunk was clicked; open the New Issue
113 /// form seeded from the named `[templates.<name>]` record.
114 NewFromTemplate(String),
115}
116/// The capacity read the TUI renders (WP06, mission `cycle-capacity-planning-01M3C85C`).
117///
118/// One read of the shared pair ([`crate::commands::capacity::snapshot_for`] — the
119/// schedule index and the one-pass issue snapshot) plus the display data derived from
120/// it, built once per refresh. Holding the pair is what lets the Capacity tab, the
121/// board's cycle group headers and an issue's detail all read **one** reading rather
122/// than each building another, and it is what keeps a render from touching the disk at
123/// all (NFR-001/C-001): the page draws only what a refresh already derived.
124///
125/// Nothing here writes: the pair is the read-only value the CLI's
126/// `tix capacity calculate` reads, and the view is display data over the DTOs.
127pub(in crate::app) struct CapacityCache {
128 /// The schedule index the snapshot was built against.
129 pub(in crate::app) index: crate::cycle_schedule::identity::ScheduleIndex,
130 /// The one-pass issue snapshot every calculation reads.
131 pub(in crate::app) snapshot: crate::capacity::scope::IssueSnapshot,
132 /// The configured scopes' rows, details and board cells.
133 pub(in crate::app) view: crate::ui::schedule::CapacityView,
134 /// Whether every configured scope's calculation **was counted and came to zero**,
135 /// so the page can say so rather than leave a reader to infer it from `-`.
136 ///
137 /// False as soon as any scope has no answer at all (an entry that is not in force)
138 /// or has committed work: "not calculated" is not the claim "committed nothing".
139 pub(in crate::app) nothing_committed: bool,
140}
141/// What a surface answered about one due candidate (WP07).
142///
143/// The four actions the warning overlay and the Schedule page share. It exists so
144/// both doors call **one** implementation ([`App::apply_candidate`]): the overlay
145/// maps its `WarningAction` onto these, the page's keys name them directly, and a
146/// difference between the two surfaces is impossible to write by accident.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub(in crate::app) enum CandidateAction {
149 /// Create the occurrence (WP04, idempotent).
150 Generate,
151 /// Move the candidate past the date, without recording it as skipped.
152 Defer,
153 /// Record the date as skipped.
154 Skip,
155 /// Stop offering the date: recorded, like `skip`.
156 Ignore,
157}
158/// Where a move should land: a visible column's cursor index, or a configured
159/// column by name (including one hidden with the board's `c` toggle).
160///
161/// The board's `<`/`>` keys, drag-and-drop and the old context menu all moved by
162/// visible index. The action dialog offers **every configured column**, which
163/// includes hidden ones, so the prompt chain and the write path carry this type
164/// rather than a bare `usize`.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum MoveTarget {
167 Visible(usize),
168 Named(String),
169}
170impl MoveTarget {
171 /// The column's configured name, resolved through the board for a visible
172 /// index and used verbatim for a named target.
173 pub fn name(&self, board: &BoardState) -> String {
174 match self {
175 Self::Visible(index) => board.column_name(*index).to_string(),
176 Self::Named(name) => name.clone(),
177 }
178 }
179
180 pub fn visible_index(&self) -> Option<usize> {
181 match self {
182 Self::Visible(index) => Some(*index),
183 Self::Named(_) => None,
184 }
185 }
186}
187/// A move that is waiting on one of its prompts.
188///
189/// The four prompts (blocked acknowledgement, terminal-exit reason, WIP override,
190/// commit) used to re-enter the shared move carrying only *their own* answer, so
191/// each prompt threw the others away. A blocked move out of a terminal column could
192/// ask the same question twice, and a move into `done` dropped the reason at the
193/// commit prompt. This struct is the one place those answers live between prompts.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct PendingMove {
196 /// The issues to move, in the order the selection was marked. A single-card
197 /// move is a list of one, so one prompt chain serves both: the issue's own
198 /// column, its commit and its guards are read per item, and the answers the
199 /// prompts collect (reason, acknowledgement, override, commit) apply to the
200 /// whole batch.
201 pub numbers: Vec<u32>,
202 pub target: MoveTarget,
203 pub target_name: String,
204 pub reason: Option<String>,
205 pub acknowledged: bool,
206 /// `None` means the commit prompt has not been answered; `Some(None)` means
207 /// the user answered it with no hash, `Some(Some(hash))` with one.
208 #[allow(clippy::option_option)]
209 pub commit: Option<Option<String>>,
210}
211/// Which half of a two-step relationship prompt is being answered.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub enum LinkStep {
214 /// The other issue, as a selector.
215 Target,
216 /// The relationship kind. **Required** (C-001).
217 Kind,
218}
219/// A rectangular click target recorded by a widget during drawing (D3), so the
220/// same frame's pointer position can be resolved to an [`Action`] without the
221/// widget knowing anything about mouse handling.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct HitRegion {
224 pub rect: Rect,
225 pub action: Action,
226}
227/// Origin of a mouse press (D4): which column and (when the press landed on a
228/// card) which card it started on, plus a timestamp used for the double-click
229/// window. Kept by [`MouseState::last_down`] until a second press on the same
230/// card, a drag, or the next distinct target replaces it.
231#[derive(Debug)]
232pub(in crate::app) struct MouseDown {
233 pub(in crate::app) col: usize,
234 pub(in crate::app) card: Option<usize>,
235 pub(in crate::app) at: Instant,
236}
237/// An in-flight drag (D4): the pressed card's origin, so an `Up` can compare
238/// the drop target against it and no-op for same-column / non-card drops.
239#[derive(Debug)]
240pub(in crate::app) struct Drag {
241 pub(in crate::app) col: usize,
242 pub(in crate::app) card: Option<usize>,
243}
244/// Mouse interaction state driven by `handle_board_mouse` (D4). A `Down`
245/// records the press origin, `Drag` frames promote it into an active drag (no
246/// board mutation while dragging), and the resolving `Up` decides between a
247/// plain click / double-click / drag-drop.
248#[derive(Debug)]
249pub struct MouseState {
250 pub(in crate::app) last_down: Option<MouseDown>,
251 pub(in crate::app) drag: Option<Drag>,
252 pub double_click_window: Duration,
253}
254impl Default for MouseState {
255 fn default() -> Self {
256 MouseState {
257 last_down: None,
258 drag: None,
259 double_click_window: Duration::from_millis(500),
260 }
261 }
262}
263/// Rows a mouse-wheel notch scrolls, in every surface that scrolls.
264///
265/// One constant rather than a number per handler: the detail pane and the keybindings
266/// overlay (and anything else that scrolls later) move by the same amount, or the
267/// wheel means something different depending on where the pointer happens to be.
268pub(in crate::app) const WHEEL_ROWS: u16 = 5;
269/// How many detail panes the back stack keeps (tix #1513).
270///
271/// Following related links is unbounded, so the stack is not; the oldest pane is
272/// dropped once the cap is reached, which keeps `Backspace` walking back toward
273/// what the reader saw most recently.
274pub(in crate::app) const MAX_DETAIL_NAV: usize = 32;
275/// How much of the keybindings overlay fits, and how far it can scroll (tix #1441).
276///
277/// Recorded by the overlay's own draw: its *handlers* need what only the renderer
278/// knows, which is why the two numbers travel back through a `Cell` rather than being
279/// recomputed from the key that was pressed.
280#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
281pub(in crate::app) struct HelpMetrics {
282 /// Binding rows the overlay can show at once.
283 pub(in crate::app) visible: u16,
284 /// The largest `help_scroll` that still shows the last row.
285 pub(in crate::app) max_scroll: u16,
286}
287/// The editor round trip in flight (tix #1510).
288///
289/// Recorded when the editor is launched and consumed when it exits. `snapshot` is
290/// the issue file's raw bytes at launch, and re-reading the same path after the
291/// child exits is what makes a concurrent write — an agent editing through MCP,
292/// a watcher, another session — detectable at all.
293pub(in crate::app) struct PendingEdit {
294 /// The issue file, relative to the store's `issues_dir`.
295 pub(in crate::app) filename: String,
296 /// The temp copy the editor writes to.
297 pub(in crate::app) temp: PathBuf,
298 /// Raw bytes of the issue file when the editor was launched.
299 pub(in crate::app) snapshot: Vec<u8>,
300}
301/// The saved view the live board is associated with (WP05, T024/C-006).
302///
303/// **Session-only.** The marker is never persisted (data-model §4's "if
304/// explicitly persisted"), and applying a view writes nothing: it installs the
305/// plan into the live filter state and records here that it did.
306///
307/// `visibility` records whether the applied plan's **visibility snapshot** is
308/// still overlaid on the live board. It is cleared when the user changes column
309/// visibility by hand, because that is a live change — and a live change is
310/// exactly what the divergence report exists to name (C-006). The marker itself
311/// is *not* cleared then: the view is still the one the board was associated
312/// with, and "you are on `Foo` but its `sort` no longer matches" is the fact the
313/// user needs.
314pub(in crate::app) struct ViewSession {
315 /// The stable id and the normalized plan that was applied.
316 pub(in crate::app) marker: ActiveViewMarker,
317 /// The display label, resolved at apply time. The name is not the identity
318 /// (C-002), but the indicator has to read as something.
319 pub(in crate::app) name: String,
320 /// Whether the applied plan's visibility snapshot is still overlaid.
321 pub(in crate::app) visibility: bool,
322}