Skip to main content

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}