Skip to main content

tix/app/
cycle.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5    /// The cycles the F3 mode lists, in the order `Planner` knows them.
6    ///
7    /// Every name comes through `Planner` — the same read path the planning
8    /// surface and the CLI use — and the implicit `Default` cycle is always
9    /// present even when no file backs it, so a fresh project shows the cycle
10    /// its new issues join. Progress comes from [`Planner::cycle_progress`],
11    /// which counts by cycle membership and the board's completion rule, so the
12    /// mode cannot disagree with `tix cycle list`.
13    pub(in crate::app) fn cycle_entries(&self) -> Vec<crate::ui::cycle::CycleEntry> {
14        let root = self.project_root();
15        let planner = Planner::from_config(self.store.config.clone(), root);
16        let mut names = planner.cycle_names();
17        if !names
18            .iter()
19            .any(|name| name == crate::cycle_store::DEFAULT_CYCLE_NAME)
20        {
21            names.push(crate::cycle_store::DEFAULT_CYCLE_NAME.to_string());
22        }
23        names
24            .into_iter()
25            .map(|name| {
26                let cycle = planner.cycle(&name);
27                crate::ui::cycle::CycleEntry {
28                    progress: planner.cycle_progress(&self.store, &name),
29                    description: cycle
30                        .and_then(|c| c.description.clone())
31                        .unwrap_or_default(),
32                    start_date: cycle.and_then(|c| c.start_date.clone()),
33                    due_date: cycle.and_then(|c| c.due_date.clone()),
34                    name,
35                }
36            })
37            .collect()
38    }
39
40    /// The mode's rows grouped current / future / archived on today's date.
41    pub(in crate::app) fn cycle_groups(&self) -> Vec<crate::ui::cycle::CycleGrouping> {
42        crate::ui::cycle::group_entries(self.cycle_entries(), &today())
43    }
44
45    /// The name of the cycle the rail should mark active: the F3 Cycle mode's
46    /// cursor target, or `None` when that mode is not the shell's top mode.
47    ///
48    /// Read from the **rendered** flat order ([`crate::ui::cycle::flat_entries`])
49    /// — the same order the listing highlights and the issue table lists — so
50    /// the bold limb names the cycle the mode is actually showing. Gated on the
51    /// top mode so a cycle does not stay bold after the user has moved to
52    /// another surface, and bounds-guarded so a stale cursor past the last entry
53    /// names no cycle rather than panicking.
54    pub fn active_cycle_name(&self) -> Option<String> {
55        if self.top_mode != TopMode::Cycle {
56            return None;
57        }
58        let selected = self.cycle_state.as_ref()?.selected;
59        let groups = self.cycle_groups();
60        crate::ui::cycle::flat_entries(&groups)
61            .get(selected)
62            .map(|entry| entry.name.clone())
63    }
64
65    /// The name of the milestone the rail should mark active: the F5 Milestones
66    /// mode's cursor target, or `None` otherwise.
67    ///
68    /// Gated on `milestone_mode` **and** the top mode, so the Config mode's own
69    /// milestones section never bolds a row through this path (its cursor is the
70    /// same `planning.selected`, but the F12 surface is not the F5 mode).
71    pub fn active_milestone_name(&self) -> Option<String> {
72        if !self.milestone_mode || self.top_mode != TopMode::Milestones {
73            return None;
74        }
75        let selected = self.planning.as_ref()?.selected;
76        let (_cycles, milestones) = self.planning_rows();
77        milestones.get(selected).map(|row| row.name.clone())
78    }
79
80    pub(in crate::app) fn cycle_entry_count(&self) -> usize {
81        self.cycle_entries().len()
82    }
83
84    /// Select the cycle row a click landed on — mouse parity with `j`/`k`.
85    pub fn select_cycle_row(&mut self, index: usize) {
86        let len = self.cycle_entry_count();
87        if len == 0 {
88            return;
89        }
90        self.cycle_state
91            .get_or_insert_with(crate::ui::cycle::CycleState::default)
92            .selected = usize::min(index, len - 1);
93    }
94
95    /// The F3 Cycle mode's keys: move the cursor, jump between the group
96    /// sections, open planning management (create/rename/delete cycles and
97    /// milestones), open help, or close.
98    pub(in crate::app) fn handle_cycle_key(&mut self, key: KeyEvent) -> Result<Action> {
99        if !matches!(self.mode, Mode::Cycle) {
100            self.mode = Mode::Board;
101            return Ok(Action::None);
102        }
103        match key.code {
104            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
105                self.cycle_state = None;
106                self.mode = Mode::Board;
107            }
108            KeyCode::Up | KeyCode::Char('k') => {
109                let len = self.cycle_entry_count();
110                if let Some(state) = self.cycle_state.as_mut() {
111                    state.move_selected(-1, len);
112                }
113            }
114            KeyCode::Down | KeyCode::Char('j') => {
115                let len = self.cycle_entry_count();
116                if let Some(state) = self.cycle_state.as_mut() {
117                    state.move_selected(1, len);
118                }
119            }
120            // The listing's horizontal axis is its group sections
121            // (tui-cycle-mode): `h`/`l` land on the first entry of the previous
122            // / next **rendered** group and clamp at the ends. The letters are
123            // listed beside the arrows because the bound keys canonicalize to
124            // them; both spellings reach here.
125            KeyCode::Left | KeyCode::Char('h') => {
126                let groups = self.cycle_groups();
127                if let Some(state) = self.cycle_state.as_mut() {
128                    state.move_group(&groups, -1);
129                }
130            }
131            KeyCode::Right | KeyCode::Char('l') => {
132                let groups = self.cycle_groups();
133                if let Some(state) = self.cycle_state.as_mut() {
134                    state.move_group(&groups, 1);
135                }
136            }
137            KeyCode::Char('m') | KeyCode::Char('M') => self.open_planning_modal(),
138            KeyCode::Char('?') => self.open_help(Mode::Cycle),
139            _ => {}
140        }
141        Ok(Action::None)
142    }
143
144    pub(in crate::app) fn handle_repos_key(&mut self, key: KeyEvent) -> Result<Action> {
145        if !matches!(self.mode, Mode::Repos) {
146            return Ok(Action::None);
147        }
148        let entries = self.repo_entries();
149        let len = entries.len();
150        if len > 0 {
151            self.repos_cursor = self.repos_cursor.min(len - 1);
152        } else {
153            self.repos_cursor = 0;
154        }
155        match key.code {
156            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
157                self.mode = Mode::Board;
158            }
159            KeyCode::Up | KeyCode::Char('k') => {
160                self.repos_cursor = self.repos_cursor.saturating_sub(1);
161            }
162            KeyCode::Down | KeyCode::Char('j') => {
163                if len > 0 && self.repos_cursor + 1 < len {
164                    self.repos_cursor += 1;
165                }
166            }
167            KeyCode::Enter => {
168                if let Some((_, path, _)) = entries.get(self.repos_cursor) {
169                    return Ok(Action::OpenRepo(path.clone()));
170                }
171            }
172            KeyCode::Char('?') => self.open_help(Mode::Repos),
173            _ => {}
174        }
175        Ok(Action::None)
176    }
177}