Skip to main content

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}