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
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
#[allow(clippy::wildcard_imports)]
use super::*;
impl App {
/// The navigation tree: each trunk mode with the limbs drawn under it
/// (tui-mode-navigation).
///
/// Built from the live config and registry, so the rail cannot disagree with
/// the surfaces a branch activates. Boards, cycles and templates come from
/// `Config`; only the milestone names touch the filesystem (a directory
/// listing of tiny records), and an empty group renders no rows.
pub fn nav_tree(&self) -> Vec<crate::ui::nav::NavNode> {
use crate::ui::nav::{BranchKind, NavBranch, NavNode};
let active_board = self.active_board_name().to_string();
let active_cycle = self.active_cycle_name();
let active_milestone = self.active_milestone_name();
let planner = || Planner::from_config(self.store.config.clone(), self.project_root());
TopMode::ALL
.iter()
.map(|mode| {
let branches = match mode {
// Every configured board is a limb; the active one is marked
// so the rail bolds it. The horizontal board tab bar stays as
// the always-visible active-board indicator on Board mode.
TopMode::Board => self
.store
.config
.configs()
.map(|(name, _)| NavBranch {
label: name.to_string(),
action: Action::SelectBoard(name.to_string()),
kind: BranchKind::Board,
active: name == active_board,
})
.collect(),
TopMode::Cycle => planner()
.cycle_names()
.into_iter()
.map(|name| NavBranch {
label: name.clone(),
active: active_cycle.as_deref() == Some(name.as_str()),
action: Action::SelectCycle(name),
kind: BranchKind::Cycle,
})
.collect(),
TopMode::Milestones => planner()
.list_milestones()
.unwrap_or_default()
.into_iter()
.map(|milestone| NavBranch {
active: active_milestone.as_deref() == Some(milestone.name.as_str()),
label: milestone.name.clone(),
action: Action::SelectMilestone(milestone.name),
kind: BranchKind::Milestone,
})
.collect(),
TopMode::NewIssue => self
.store
.config
.templates
.keys()
.map(|name| NavBranch {
label: name.clone(),
action: Action::NewFromTemplate(name.clone()),
kind: BranchKind::Template,
active: false,
})
.collect(),
// #1529: one limb per configured child, plus the parent when
// one is known. Activating a limb runs tix there.
TopMode::Repos => self
.repo_entries()
.into_iter()
.map(|(label, path, _is_parent)| NavBranch {
label,
action: Action::OpenRepo(path),
kind: BranchKind::Repo,
active: false,
})
.collect(),
// WP06: the Portfolio page's own tab, as a limb. Unlike the
// Repos limb this costs no read of the issue set — the rail
// would otherwise re-read every issue file per frame, and the
// project rows are the page's body rather than the rail's.
TopMode::Portfolio => vec![NavBranch {
label: "Projects".to_string(),
action: Action::SwitchMode(TopMode::Portfolio),
kind: BranchKind::Project,
active: matches!(self.mode, Mode::Portfolio),
}],
// The Stats page owns no registry, so it has no limbs — the same
// shape Triage, Config, Schedule and Library have.
TopMode::Stats
| TopMode::Triage
| TopMode::Config
| TopMode::Schedule
| TopMode::Library => Vec::new(),
};
NavNode {
mode: *mode,
branches,
}
})
.collect()
}
/// The repositories the F6 Repos mode lists, in display order: the
/// configured children (`[repos]`, in declaration order) followed by the
/// parent when one is known (tix #1529).
///
/// One level deep by construction: the children come from **this** config and
/// the parent from `parent_repo` (or the `TIX_PARENT_REPO` the parent set
/// when spawning us). A child's own children are reached by running tix
/// there, never by walking from here.
pub fn repo_entries(&self) -> Vec<(String, PathBuf, bool)> {
let mut out: Vec<(String, PathBuf, bool)> = self
.store
.config
.repos()
.iter()
.map(|(name, path)| (name.clone(), path.clone(), false))
.collect();
if let Some(parent) = self.store.config.parent_repo() {
out.push(("parent".to_string(), parent.to_path_buf(), true));
}
out
}
/// The column width the rail needs for its current content.
///
/// Grown past [`NavDisplay::width`] so the widest trunk or branch line is
/// drawn whole (one space of separation from the content area) rather than
/// clipped — that is what lets `Default` show a full branch name.
pub fn nav_width(&self) -> u16 {
if self.nav_display == NavDisplay::Off {
return 0;
}
let mut width = self.nav_display.width();
for node in self.nav_tree() {
let chord = self.bindings.label(InputContext::Shell, node.mode.action());
let trunk = if self.nav_display == NavDisplay::Small {
chord.chars().count()
} else {
chord.chars().count() + 1 + self.mode_label(node.mode).chars().count()
};
width = width.max(trunk as u16 + 1);
if self.nav_display != NavDisplay::Small {
for branch in &node.branches {
width = width.max(2 + branch.label.chars().count() as u16 + 1);
}
}
}
width
}
/// The trunk label for `mode`: the singular name, or the plural when the
/// mode owns **more than one** record — boards by configured count, cycles
/// by [`Planner::cycle_names`], milestones by [`Planner::list_milestones`].
/// The chord is never affected; only the noun moves.
pub fn mode_label(&self, mode: TopMode) -> &'static str {
let planner = || Planner::from_config(self.store.config.clone(), self.project_root());
let plural = match mode {
TopMode::Board => self.store.config.configs().count() > 1,
TopMode::Cycle => planner().cycle_names().len() > 1,
TopMode::Milestones => planner().list_milestones().unwrap_or_default().len() > 1,
// "Repos" is already plural; the count only matters for consistency.
TopMode::Repos => self.repo_entries().len() > 1,
TopMode::NewIssue
| TopMode::Triage
| TopMode::Config
| TopMode::Stats
| TopMode::Schedule
| TopMode::Library
| TopMode::Portfolio => false,
};
mode.label(plural)
}
/// The rail's rows in draw order — each trunk followed by its limbs, exactly
/// as [`crate::ui::nav::render`] paints them. The keyboard cursor indexes
/// this list; the pointer's hit regions come from the same tree.
pub fn nav_rows(&self) -> Vec<Action> {
let mut rows = Vec::new();
for node in self.nav_tree() {
rows.push(Action::SwitchMode(node.mode));
rows.extend(node.branches.iter().map(|branch| branch.action.clone()));
}
rows
}
/// The index of the rail row holding the keyboard cursor, `None` when the
/// mode content owns the keyboard.
pub fn nav_focus_index(&self) -> Option<usize> {
self.nav_focus
}
/// Whether rail row `index` holds the keyboard cursor. The rail draws such a
/// row exactly like a hovered one.
pub fn nav_row_focused(&self, index: usize) -> bool {
self.nav_focus == Some(index)
}
/// Whether `h`/`Left` means "enter the rail" in the current view.
///
/// A page with its own horizontal cursor moves left first and enters the
/// rail at its left edge. A page with no horizontal cursor enters directly.
/// Text editors keep `h` and `Left` for the text; the New Issue form can
/// still enter the rail with `Left` when the active editor is at column 0.
pub(in crate::app) fn rail_entry_available(&self, key: KeyCode) -> bool {
if self.nav_display == NavDisplay::Off {
return false;
}
match &self.mode {
Mode::Board => self.board.real_cols() == 0 || self.board.active_col == 0,
Mode::TriageInbox => self.triage.as_ref().is_some_and(|state| {
state.modal.is_none()
&& state.dropdown.is_none()
&& !state.creating
&& state.focus == TriageControl::Queue
}),
Mode::Cycle => self.cycle_state.as_ref().map_or(0, |state| state.selected) == 0,
Mode::Settings => {
self.settings
.as_ref()
.is_none_or(|modal| modal.prompt.is_none())
&& self
.planning
.as_ref()
.is_none_or(|modal| modal.prompt.is_none())
&& (self.milestone_mode
|| self
.settings
.as_ref()
.is_some_and(|modal| modal.level == ConfigLevel::Sections))
}
Mode::Repos | Mode::Stats => true,
Mode::Schedule => self.schedule.as_ref().is_some_and(|state| {
state.prompt.is_none() && state.form.is_none() && state.series_form.is_none()
}),
Mode::Library => self
.library
.as_ref()
.is_some_and(|state| state.tab == crate::ui::library::LibraryTab::Views),
Mode::Portfolio => {
self.portfolio_roadmap_overlay.is_none()
&& self.portfolio.as_ref().is_some_and(|state| {
state.tab != crate::ui::portfolio::PortfolioTab::Epics || state.member == 0
})
}
Mode::Detail => self.related_picker.is_none(),
Mode::Form => {
matches!(key, KeyCode::Left)
&& self.form_picker.is_none()
&& self.form.as_ref().is_some_and(|form| form.cursor == 0)
}
_ => false,
}
}
/// Put the keyboard cursor on the rail, at the current top mode's trunk row
/// (or the first row when that mode has no trunk). Idempotent.
pub(in crate::app) fn focus_rail(&mut self) {
if self.nav_focus.is_some() {
return;
}
let rows = self.nav_rows();
let index = rows
.iter()
.position(|action| *action == Action::SwitchMode(self.top_mode))
.unwrap_or(0);
self.nav_focus = Some(index);
}
/// The rail's keys while it owns the keyboard.
///
/// `j`/`k` and the arrows walk the flattened trunk+limb rows; `Enter`, `l`
/// and `→` activate the focused row — a trunk switches modes, a limb runs
/// exactly the action a click on it runs — and hand the keyboard to the
/// opened content on the right, the same result a click has; `Esc` (and `←`)
/// leave the rail without activating. Anything else is swallowed: the rail
/// has focus, and a half-applied board key would act on a surface the user is
/// not looking at.
pub(in crate::app) fn handle_nav_key(&mut self, key: KeyEvent) -> Result<Action> {
let rows = self.nav_rows();
let last = rows.len().saturating_sub(1);
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if let Some(index) = self.nav_focus.as_mut() {
*index = index.saturating_sub(1);
}
}
KeyCode::Down | KeyCode::Char('j') => {
if let Some(index) = self.nav_focus.as_mut() {
*index = usize::min(index.saturating_add(1), last);
}
}
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
self.activate_focused_rail_row()?;
// Opening a row hands the keyboard to the surface on the right,
// exactly like a click; the rail keeps the cursor only while the
// user is walking it.
self.nav_focus = None;
}
KeyCode::Left | KeyCode::Esc => self.nav_focus = None,
_ => {}
}
Ok(Action::None)
}
/// Run the rail row under the keyboard cursor: a trunk switches modes, a limb
/// runs exactly the action a click on it runs. A missing cursor (or one that
/// drifted past the end) does nothing.
pub(in crate::app) fn activate_focused_rail_row(&mut self) -> Result<()> {
let action = self
.nav_focus
.and_then(|index| self.nav_rows().get(index).cloned());
match action {
Some(Action::SwitchMode(mode)) => {
self.switch_top_mode(mode)?;
}
// Branch limbs: the same transition their click runs.
Some(other) => {
self.activate_branch(&other)?;
}
None => {}
}
Ok(())
}
/// The category resolution for the board the TUI is currently showing.
///
/// WP05. Derived from [`Self::active_board_config`], so it is the resolution of
/// the board on screen rather than of whichever board is `default` — the TUI can
/// display any configured board, and a card showing another board's categories is
/// a wrong answer rather than a missing one.
pub(in crate::app) fn workflow_resolution(&self) -> crate::workflow::CategoryResolution {
let name = self
.store
.config
.configs()
.nth(self.active_config)
.map(|(name, _)| name)
.unwrap_or("default");
crate::workflow::resolution(&self.store.config, self.active_board_config(), name)
}
/// The category label and color hint for `issue`, as the detail view shows them.
///
/// WP05 T023. Resolved through the service against the board on screen, and
/// derived rather than stored — an issue's category changes when its column
/// changes or when the board's `category_map` is edited, and a value captured
/// when the detail was opened would be stale for exactly as long as the view
/// stayed open.
pub(in crate::app) fn category_of_issue(
&self,
issue: &crate::model::Issue,
) -> Option<(String, Option<crate::workflow::ColorHint>)> {
let resolution = self.workflow_resolution();
crate::workflow::category_of(issue, &resolution)
.map(|c| (c.label().to_string(), c.color_hint()))
}
}