tix/app/nav.rs
1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5 /// The navigation tree: each trunk mode with the limbs drawn under it
6 /// (tui-mode-navigation).
7 ///
8 /// Built from the live config and registry, so the rail cannot disagree with
9 /// the surfaces a branch activates. Boards, cycles and templates come from
10 /// `Config`; only the milestone names touch the filesystem (a directory
11 /// listing of tiny records), and an empty group renders no rows.
12 pub fn nav_tree(&self) -> Vec<crate::ui::nav::NavNode> {
13 use crate::ui::nav::{BranchKind, NavBranch, NavNode};
14
15 let active_board = self.active_board_name().to_string();
16 let active_cycle = self.active_cycle_name();
17 let active_milestone = self.active_milestone_name();
18 let planner = || Planner::from_config(self.store.config.clone(), self.project_root());
19 TopMode::ALL
20 .iter()
21 .map(|mode| {
22 let branches = match mode {
23 // Every configured board is a limb; the active one is marked
24 // so the rail bolds it. The horizontal board tab bar stays as
25 // the always-visible active-board indicator on Board mode.
26 TopMode::Board => self
27 .store
28 .config
29 .configs()
30 .map(|(name, _)| NavBranch {
31 label: name.to_string(),
32 action: Action::SelectBoard(name.to_string()),
33 kind: BranchKind::Board,
34 active: name == active_board,
35 })
36 .collect(),
37 TopMode::Cycle => planner()
38 .cycle_names()
39 .into_iter()
40 .map(|name| NavBranch {
41 label: name.clone(),
42 active: active_cycle.as_deref() == Some(name.as_str()),
43 action: Action::SelectCycle(name),
44 kind: BranchKind::Cycle,
45 })
46 .collect(),
47 TopMode::Milestones => planner()
48 .list_milestones()
49 .unwrap_or_default()
50 .into_iter()
51 .map(|milestone| NavBranch {
52 active: active_milestone.as_deref() == Some(milestone.name.as_str()),
53 label: milestone.name.clone(),
54 action: Action::SelectMilestone(milestone.name),
55 kind: BranchKind::Milestone,
56 })
57 .collect(),
58 TopMode::NewIssue => self
59 .store
60 .config
61 .templates
62 .keys()
63 .map(|name| NavBranch {
64 label: name.clone(),
65 action: Action::NewFromTemplate(name.clone()),
66 kind: BranchKind::Template,
67 active: false,
68 })
69 .collect(),
70 // #1529: one limb per configured child, plus the parent when
71 // one is known. Activating a limb runs tix there.
72 TopMode::Repos => self
73 .repo_entries()
74 .into_iter()
75 .map(|(label, path, _is_parent)| NavBranch {
76 label,
77 action: Action::OpenRepo(path),
78 kind: BranchKind::Repo,
79 active: false,
80 })
81 .collect(),
82 // WP06: the Portfolio page's own tab, as a limb. Unlike the
83 // Repos limb this costs no read of the issue set — the rail
84 // would otherwise re-read every issue file per frame, and the
85 // project rows are the page's body rather than the rail's.
86 TopMode::Portfolio => vec![NavBranch {
87 label: "Projects".to_string(),
88 action: Action::SwitchMode(TopMode::Portfolio),
89 kind: BranchKind::Project,
90 active: matches!(self.mode, Mode::Portfolio),
91 }],
92 // The Stats page owns no registry, so it has no limbs — the same
93 // shape Triage, Config, Schedule and Library have.
94 TopMode::Stats
95 | TopMode::Triage
96 | TopMode::Config
97 | TopMode::Schedule
98 | TopMode::Library => Vec::new(),
99 };
100 NavNode {
101 mode: *mode,
102 branches,
103 }
104 })
105 .collect()
106 }
107
108 /// The repositories the F6 Repos mode lists, in display order: the
109 /// configured children (`[repos]`, in declaration order) followed by the
110 /// parent when one is known (tix #1529).
111 ///
112 /// One level deep by construction: the children come from **this** config and
113 /// the parent from `parent_repo` (or the `TIX_PARENT_REPO` the parent set
114 /// when spawning us). A child's own children are reached by running tix
115 /// there, never by walking from here.
116 pub fn repo_entries(&self) -> Vec<(String, PathBuf, bool)> {
117 let mut out: Vec<(String, PathBuf, bool)> = self
118 .store
119 .config
120 .repos()
121 .iter()
122 .map(|(name, path)| (name.clone(), path.clone(), false))
123 .collect();
124 if let Some(parent) = self.store.config.parent_repo() {
125 out.push(("parent".to_string(), parent.to_path_buf(), true));
126 }
127 out
128 }
129
130 /// The column width the rail needs for its current content.
131 ///
132 /// Grown past [`NavDisplay::width`] so the widest trunk or branch line is
133 /// drawn whole (one space of separation from the content area) rather than
134 /// clipped — that is what lets `Default` show a full branch name.
135 pub fn nav_width(&self) -> u16 {
136 if self.nav_display == NavDisplay::Off {
137 return 0;
138 }
139 let mut width = self.nav_display.width();
140 for node in self.nav_tree() {
141 let chord = self.bindings.label(InputContext::Shell, node.mode.action());
142 let trunk = if self.nav_display == NavDisplay::Small {
143 chord.chars().count()
144 } else {
145 chord.chars().count() + 1 + self.mode_label(node.mode).chars().count()
146 };
147 width = width.max(trunk as u16 + 1);
148 if self.nav_display != NavDisplay::Small {
149 for branch in &node.branches {
150 width = width.max(2 + branch.label.chars().count() as u16 + 1);
151 }
152 }
153 }
154 width
155 }
156
157 /// The trunk label for `mode`: the singular name, or the plural when the
158 /// mode owns **more than one** record — boards by configured count, cycles
159 /// by [`Planner::cycle_names`], milestones by [`Planner::list_milestones`].
160 /// The chord is never affected; only the noun moves.
161 pub fn mode_label(&self, mode: TopMode) -> &'static str {
162 let planner = || Planner::from_config(self.store.config.clone(), self.project_root());
163 let plural = match mode {
164 TopMode::Board => self.store.config.configs().count() > 1,
165 TopMode::Cycle => planner().cycle_names().len() > 1,
166 TopMode::Milestones => planner().list_milestones().unwrap_or_default().len() > 1,
167 // "Repos" is already plural; the count only matters for consistency.
168 TopMode::Repos => self.repo_entries().len() > 1,
169 TopMode::NewIssue
170 | TopMode::Triage
171 | TopMode::Config
172 | TopMode::Stats
173 | TopMode::Schedule
174 | TopMode::Library
175 | TopMode::Portfolio => false,
176 };
177 mode.label(plural)
178 }
179
180 /// The rail's rows in draw order — each trunk followed by its limbs, exactly
181 /// as [`crate::ui::nav::render`] paints them. The keyboard cursor indexes
182 /// this list; the pointer's hit regions come from the same tree.
183 pub fn nav_rows(&self) -> Vec<Action> {
184 let mut rows = Vec::new();
185 for node in self.nav_tree() {
186 rows.push(Action::SwitchMode(node.mode));
187 rows.extend(node.branches.iter().map(|branch| branch.action.clone()));
188 }
189 rows
190 }
191
192 /// The index of the rail row holding the keyboard cursor, `None` when the
193 /// mode content owns the keyboard.
194 pub fn nav_focus_index(&self) -> Option<usize> {
195 self.nav_focus
196 }
197
198 /// Whether rail row `index` holds the keyboard cursor. The rail draws such a
199 /// row exactly like a hovered one.
200 pub fn nav_row_focused(&self, index: usize) -> bool {
201 self.nav_focus == Some(index)
202 }
203
204 /// Whether `h`/`Left` means "enter the rail" in the current view.
205 ///
206 /// A page with its own horizontal cursor moves left first and enters the
207 /// rail at its left edge. A page with no horizontal cursor enters directly.
208 /// Text editors keep `h` and `Left` for the text; the New Issue form can
209 /// still enter the rail with `Left` when the active editor is at column 0.
210 pub(in crate::app) fn rail_entry_available(&self, key: KeyCode) -> bool {
211 if self.nav_display == NavDisplay::Off {
212 return false;
213 }
214 match &self.mode {
215 Mode::Board => self.board.real_cols() == 0 || self.board.active_col == 0,
216 Mode::TriageInbox => self.triage.as_ref().is_some_and(|state| {
217 state.modal.is_none()
218 && state.dropdown.is_none()
219 && !state.creating
220 && state.focus == TriageControl::Queue
221 }),
222 Mode::Cycle => self.cycle_state.as_ref().map_or(0, |state| state.selected) == 0,
223 Mode::Settings => {
224 self.settings
225 .as_ref()
226 .is_none_or(|modal| modal.prompt.is_none())
227 && self
228 .planning
229 .as_ref()
230 .is_none_or(|modal| modal.prompt.is_none())
231 && (self.milestone_mode
232 || self
233 .settings
234 .as_ref()
235 .is_some_and(|modal| modal.level == ConfigLevel::Sections))
236 }
237 Mode::Repos | Mode::Stats => true,
238 Mode::Schedule => self.schedule.as_ref().is_some_and(|state| {
239 state.prompt.is_none() && state.form.is_none() && state.series_form.is_none()
240 }),
241 Mode::Library => self
242 .library
243 .as_ref()
244 .is_some_and(|state| state.tab == crate::ui::library::LibraryTab::Views),
245 Mode::Portfolio => {
246 self.portfolio_roadmap_overlay.is_none()
247 && self.portfolio.as_ref().is_some_and(|state| {
248 state.tab != crate::ui::portfolio::PortfolioTab::Epics || state.member == 0
249 })
250 }
251 Mode::Detail => self.related_picker.is_none(),
252 Mode::Form => {
253 matches!(key, KeyCode::Left)
254 && self.form_picker.is_none()
255 && self.form.as_ref().is_some_and(|form| form.cursor == 0)
256 }
257 _ => false,
258 }
259 }
260
261 /// Put the keyboard cursor on the rail, at the current top mode's trunk row
262 /// (or the first row when that mode has no trunk). Idempotent.
263 pub(in crate::app) fn focus_rail(&mut self) {
264 if self.nav_focus.is_some() {
265 return;
266 }
267 let rows = self.nav_rows();
268 let index = rows
269 .iter()
270 .position(|action| *action == Action::SwitchMode(self.top_mode))
271 .unwrap_or(0);
272 self.nav_focus = Some(index);
273 }
274
275 /// The rail's keys while it owns the keyboard.
276 ///
277 /// `j`/`k` and the arrows walk the flattened trunk+limb rows; `Enter`, `l`
278 /// and `→` activate the focused row — a trunk switches modes, a limb runs
279 /// exactly the action a click on it runs — and hand the keyboard to the
280 /// opened content on the right, the same result a click has; `Esc` (and `←`)
281 /// leave the rail without activating. Anything else is swallowed: the rail
282 /// has focus, and a half-applied board key would act on a surface the user is
283 /// not looking at.
284 pub(in crate::app) fn handle_nav_key(&mut self, key: KeyEvent) -> Result<Action> {
285 let rows = self.nav_rows();
286 let last = rows.len().saturating_sub(1);
287 match key.code {
288 KeyCode::Up | KeyCode::Char('k') => {
289 if let Some(index) = self.nav_focus.as_mut() {
290 *index = index.saturating_sub(1);
291 }
292 }
293 KeyCode::Down | KeyCode::Char('j') => {
294 if let Some(index) = self.nav_focus.as_mut() {
295 *index = usize::min(index.saturating_add(1), last);
296 }
297 }
298 KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
299 self.activate_focused_rail_row()?;
300 // Opening a row hands the keyboard to the surface on the right,
301 // exactly like a click; the rail keeps the cursor only while the
302 // user is walking it.
303 self.nav_focus = None;
304 }
305 KeyCode::Left | KeyCode::Esc => self.nav_focus = None,
306 _ => {}
307 }
308 Ok(Action::None)
309 }
310
311 /// Run the rail row under the keyboard cursor: a trunk switches modes, a limb
312 /// runs exactly the action a click on it runs. A missing cursor (or one that
313 /// drifted past the end) does nothing.
314 pub(in crate::app) fn activate_focused_rail_row(&mut self) -> Result<()> {
315 let action = self
316 .nav_focus
317 .and_then(|index| self.nav_rows().get(index).cloned());
318 match action {
319 Some(Action::SwitchMode(mode)) => {
320 self.switch_top_mode(mode)?;
321 }
322 // Branch limbs: the same transition their click runs.
323 Some(other) => {
324 self.activate_branch(&other)?;
325 }
326 None => {}
327 }
328 Ok(())
329 }
330
331 /// The category resolution for the board the TUI is currently showing.
332 ///
333 /// WP05. Derived from [`Self::active_board_config`], so it is the resolution of
334 /// the board on screen rather than of whichever board is `default` — the TUI can
335 /// display any configured board, and a card showing another board's categories is
336 /// a wrong answer rather than a missing one.
337 pub(in crate::app) fn workflow_resolution(&self) -> crate::workflow::CategoryResolution {
338 let name = self
339 .store
340 .config
341 .configs()
342 .nth(self.active_config)
343 .map(|(name, _)| name)
344 .unwrap_or("default");
345 crate::workflow::resolution(&self.store.config, self.active_board_config(), name)
346 }
347
348 /// The category label and color hint for `issue`, as the detail view shows them.
349 ///
350 /// WP05 T023. Resolved through the service against the board on screen, and
351 /// derived rather than stored — an issue's category changes when its column
352 /// changes or when the board's `category_map` is edited, and a value captured
353 /// when the detail was opened would be stale for exactly as long as the view
354 /// stayed open.
355 pub(in crate::app) fn category_of_issue(
356 &self,
357 issue: &crate::model::Issue,
358 ) -> Option<(String, Option<crate::workflow::ColorHint>)> {
359 let resolution = self.workflow_resolution();
360 crate::workflow::category_of(issue, &resolution)
361 .map(|c| (c.label().to_string(), c.color_hint()))
362 }
363}