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
#[allow(clippy::wildcard_imports)]
use super::*;
impl App {
/// The cycles the F3 mode lists, in the order `Planner` knows them.
///
/// Every name comes through `Planner` — the same read path the planning
/// surface and the CLI use — and the implicit `Default` cycle is always
/// present even when no file backs it, so a fresh project shows the cycle
/// its new issues join. Progress comes from [`Planner::cycle_progress`],
/// which counts by cycle membership and the board's completion rule, so the
/// mode cannot disagree with `tix cycle list`.
pub(in crate::app) fn cycle_entries(&self) -> Vec<crate::ui::cycle::CycleEntry> {
let root = self.project_root();
let planner = Planner::from_config(self.store.config.clone(), root);
let mut names = planner.cycle_names();
if !names
.iter()
.any(|name| name == crate::cycle_store::DEFAULT_CYCLE_NAME)
{
names.push(crate::cycle_store::DEFAULT_CYCLE_NAME.to_string());
}
names
.into_iter()
.map(|name| {
let cycle = planner.cycle(&name);
crate::ui::cycle::CycleEntry {
progress: planner.cycle_progress(&self.store, &name),
description: cycle
.and_then(|c| c.description.clone())
.unwrap_or_default(),
start_date: cycle.and_then(|c| c.start_date.clone()),
due_date: cycle.and_then(|c| c.due_date.clone()),
name,
}
})
.collect()
}
/// The mode's rows grouped current / future / archived on today's date.
pub(in crate::app) fn cycle_groups(&self) -> Vec<crate::ui::cycle::CycleGrouping> {
crate::ui::cycle::group_entries(self.cycle_entries(), &today())
}
/// The name of the cycle the rail should mark active: the F3 Cycle mode's
/// cursor target, or `None` when that mode is not the shell's top mode.
///
/// Read from the **rendered** flat order ([`crate::ui::cycle::flat_entries`])
/// — the same order the listing highlights and the issue table lists — so
/// the bold limb names the cycle the mode is actually showing. Gated on the
/// top mode so a cycle does not stay bold after the user has moved to
/// another surface, and bounds-guarded so a stale cursor past the last entry
/// names no cycle rather than panicking.
pub fn active_cycle_name(&self) -> Option<String> {
if self.top_mode != TopMode::Cycle {
return None;
}
let selected = self.cycle_state.as_ref()?.selected;
let groups = self.cycle_groups();
crate::ui::cycle::flat_entries(&groups)
.get(selected)
.map(|entry| entry.name.clone())
}
/// The name of the milestone the rail should mark active: the F5 Milestones
/// mode's cursor target, or `None` otherwise.
///
/// Gated on `milestone_mode` **and** the top mode, so the Config mode's own
/// milestones section never bolds a row through this path (its cursor is the
/// same `planning.selected`, but the F12 surface is not the F5 mode).
pub fn active_milestone_name(&self) -> Option<String> {
if !self.milestone_mode || self.top_mode != TopMode::Milestones {
return None;
}
let selected = self.planning.as_ref()?.selected;
let (_cycles, milestones) = self.planning_rows();
milestones.get(selected).map(|row| row.name.clone())
}
pub(in crate::app) fn cycle_entry_count(&self) -> usize {
self.cycle_entries().len()
}
/// Select the cycle row a click landed on — mouse parity with `j`/`k`.
pub fn select_cycle_row(&mut self, index: usize) {
let len = self.cycle_entry_count();
if len == 0 {
return;
}
self.cycle_state
.get_or_insert_with(crate::ui::cycle::CycleState::default)
.selected = usize::min(index, len - 1);
}
/// The F3 Cycle mode's keys: move the cursor, jump between the group
/// sections, open planning management (create/rename/delete cycles and
/// milestones), open help, or close.
pub(in crate::app) fn handle_cycle_key(&mut self, key: KeyEvent) -> Result<Action> {
if !matches!(self.mode, Mode::Cycle) {
self.mode = Mode::Board;
return Ok(Action::None);
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
self.cycle_state = None;
self.mode = Mode::Board;
}
KeyCode::Up | KeyCode::Char('k') => {
let len = self.cycle_entry_count();
if let Some(state) = self.cycle_state.as_mut() {
state.move_selected(-1, len);
}
}
KeyCode::Down | KeyCode::Char('j') => {
let len = self.cycle_entry_count();
if let Some(state) = self.cycle_state.as_mut() {
state.move_selected(1, len);
}
}
// The listing's horizontal axis is its group sections
// (tui-cycle-mode): `h`/`l` land on the first entry of the previous
// / next **rendered** group and clamp at the ends. The letters are
// listed beside the arrows because the bound keys canonicalize to
// them; both spellings reach here.
KeyCode::Left | KeyCode::Char('h') => {
let groups = self.cycle_groups();
if let Some(state) = self.cycle_state.as_mut() {
state.move_group(&groups, -1);
}
}
KeyCode::Right | KeyCode::Char('l') => {
let groups = self.cycle_groups();
if let Some(state) = self.cycle_state.as_mut() {
state.move_group(&groups, 1);
}
}
KeyCode::Char('m') | KeyCode::Char('M') => self.open_planning_modal(),
KeyCode::Char('?') => self.open_help(Mode::Cycle),
_ => {}
}
Ok(Action::None)
}
pub(in crate::app) fn handle_repos_key(&mut self, key: KeyEvent) -> Result<Action> {
if !matches!(self.mode, Mode::Repos) {
return Ok(Action::None);
}
let entries = self.repo_entries();
let len = entries.len();
if len > 0 {
self.repos_cursor = self.repos_cursor.min(len - 1);
} else {
self.repos_cursor = 0;
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
self.mode = Mode::Board;
}
KeyCode::Up | KeyCode::Char('k') => {
self.repos_cursor = self.repos_cursor.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if len > 0 && self.repos_cursor + 1 < len {
self.repos_cursor += 1;
}
}
KeyCode::Enter => {
if let Some((_, path, _)) = entries.get(self.repos_cursor) {
return Ok(Action::OpenRepo(path.clone()));
}
}
KeyCode::Char('?') => self.open_help(Mode::Repos),
_ => {}
}
Ok(Action::None)
}
}