1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct ActionFeedback {
10 pub action: String,
12 pub reason: String,
14 pub remedy: String,
16 pub status: Option<String>,
19}
20
21impl ActionFeedback {
22 pub(in crate::app) fn new(
23 action: &str,
24 reason: impl Into<String>,
25 remedy: impl Into<String>,
26 ) -> Self {
27 Self {
28 action: action.to_string(),
29 reason: reason.into(),
30 remedy: remedy.into(),
31 status: None,
32 }
33 }
34
35 fn with_status(mut self, status: impl Into<String>) -> Self {
36 self.status = Some(status.into());
37 self
38 }
39
40 pub(in crate::app) fn reason_line(&self) -> String {
42 format!("{} unavailable: {}", self.action, self.reason)
43 }
44
45 pub(in crate::app) fn remedy_line(&self) -> String {
47 format!("remedy: {}", self.remedy)
48 }
49}
50
51impl App {
52 #[allow(clippy::too_many_lines)]
59 pub(in crate::app) fn action_refusal(
60 &self,
61 context: InputContext,
62 action: TuiAction,
63 ) -> Option<ActionFeedback> {
64 use TuiAction::{
65 Agent, Delete, DeletePlanningItem, Edit, EditPlanningItem, MoveColumnDown,
66 MoveColumnUp, MoveDown, MoveLeft, MoveNext, MovePrevious, MoveRight, MoveUp, Open,
67 OpenPicker, PreviousBoard, ReorderDown, ReorderUp, ScrollDown, ScrollUp, Submit,
68 TimeAdd, TimeBreakdown, TimeDelete, TimeEdit, TimeFilter, TimeHistory, Toggle, View,
69 };
70
71 let unavailable = |reason: &str, remedy: &str| {
72 Some(ActionFeedback::new(
73 action.description_in(context),
74 reason,
75 remedy,
76 ))
77 };
78 let empty_list = |noun: &str, remedy: &str| {
79 unavailable(&format!("there are no {noun} to act on"), remedy)
80 };
81 let needs_selection = || {
82 unavailable(
83 "no issue is selected",
84 "move the cursor onto an issue, then try again",
85 )
86 };
87
88 match context {
89 InputContext::Board
90 if matches!(
91 action,
92 Open | Edit
93 | View
94 | Agent
95 | Delete
96 | MovePrevious
97 | MoveNext
98 | ReorderUp
99 | ReorderDown
100 ) && self.board.get_selected_card().is_none() =>
101 {
102 needs_selection()
103 }
104 InputContext::Board
105 if matches!(
106 action,
107 MoveUp | MoveDown | ScrollUp | ScrollDown | TuiAction::Mark
108 ) && self.board.column_cards(self.board.active_col).is_empty() =>
109 {
110 unavailable(
111 "the visible board column has no issues",
112 "add an issue with n or clear the filter with f",
113 )
114 }
115 InputContext::Form if action == OpenPicker => {
116 let focus = self.form.as_ref()?.focus;
117 if focus.is_target() {
118 unavailable(
119 "the Target row is a toggle, not a picker",
120 "press Enter to switch between Triage and Todo",
121 )
122 } else if matches!(
123 focus,
124 crate::ui::form::FormFocus::Title
125 | crate::ui::form::FormFocus::Estimate
126 | crate::ui::form::FormFocus::Body
127 ) {
128 unavailable(
129 &format!("the {} field is typed, not picked", focus.label()),
130 "focus a chosen field, or press Enter where the field has a picker",
131 )
132 } else {
133 None
134 }
135 }
136 InputContext::Form if action == Submit => {
137 let form = self.form.as_ref()?;
138 let enter_does_something_else = form.focus.is_target()
139 || form.focus.is_template()
140 || form.focus.is_status()
141 || form.focus.is_priority()
142 || (form.focus.is_date() && !form.focused_date_is_complete())
143 || (form.focus.is_parent() && form.parent.trim().is_empty());
144 (form.title.trim().is_empty() && !enter_does_something_else).then(|| {
145 ActionFeedback::new(
146 action.description_in(context),
147 "the Title field is empty",
148 "type a title, then press Enter again",
149 )
150 .with_status("Title is required")
151 })
152 }
153 InputContext::Triage
154 if matches!(action, MoveUp | MoveDown | Submit | TuiAction::Toggle)
155 && self
156 .triage
157 .as_ref()
158 .is_none_or(|state| state.rows.is_empty()) =>
159 {
160 unavailable(
161 "the triage inbox has no issues",
162 "press n to create one in the entry column, or q to return to the board",
163 )
164 }
165 InputContext::Cycle if matches!(action, MoveUp | MoveDown | MoveLeft | MoveRight) => {
166 let rows = self.cycle_entry_count();
167 let selected = self.cycle_state.as_ref().map_or(0, |state| state.selected);
168 let groups = self.cycle_groups();
169 let populated_groups = groups
170 .iter()
171 .filter(|group| !group.entries.is_empty())
172 .count();
173 let at_vertical_bound = match action {
174 MoveUp => selected == 0,
175 MoveDown => rows == 0 || selected + 1 >= rows,
176 MoveLeft | MoveRight => populated_groups <= 1,
177 _ => false,
178 };
179 at_vertical_bound.then(|| {
180 ActionFeedback::new(
181 action.description_in(context),
182 if rows == 0 {
183 "there are no cycle rows".to_string()
184 } else {
185 "the cursor is already at the edge of the cycle listing".to_string()
186 },
187 "press m to define another cycle, or move in the other direction",
188 )
189 })
190 }
191 InputContext::Milestones
192 if matches!(
193 action,
194 MoveUp | MoveDown | Submit | EditPlanningItem | DeletePlanningItem
195 ) && self.planning_rows_len() == 0 =>
196 {
197 empty_list(
198 "milestones",
199 "press n to define one, or q to return to the board",
200 )
201 }
202 InputContext::Repos
203 if matches!(action, MoveUp | MoveDown | Submit)
204 && self.repo_entries().is_empty() =>
205 {
206 empty_list(
207 "repositories",
208 "add a `[repos]` entry to tix.toml, then reopen Repos",
209 )
210 }
211 InputContext::Stats => match self.stats_tab {
212 StatsTab::Health => {
213 if matches!(action, MoveUp | MoveDown | Submit) && self.stats_rows().is_empty()
214 {
215 return empty_list(
216 "health scopes",
217 "record issue health or add an issue, then reopen Stats",
218 );
219 }
220 if matches!(
221 action,
222 TimeAdd | TimeEdit | TimeDelete | TimeFilter | TimeBreakdown | TimeHistory
223 ) {
224 return unavailable(
225 "the Time controls are on the Time tab",
226 "press Tab until the Time tab is active",
227 );
228 }
229 None
230 }
231 StatsTab::Time => {
232 let rows = self
233 .stats_time
234 .as_ref()
235 .map_or(0, |state| state.rows().len());
236 if matches!(action, MoveUp | MoveDown | Submit | TimeEdit | TimeDelete)
237 && rows == 0
238 {
239 return empty_list(
240 "time entries",
241 "press a to add an entry, or Tab to choose another tab",
242 );
243 }
244 None
245 }
246 StatsTab::Burndown => None,
247 },
248 InputContext::Schedule if matches!(action, MoveUp | MoveDown) => {
249 let tab = self
250 .schedule
251 .as_ref()
252 .map_or(crate::ui::schedule::ScheduleTab::Series, |state| state.tab);
253 let rows = match tab {
254 crate::ui::schedule::ScheduleTab::Series => self.schedule_entries().len(),
255 crate::ui::schedule::ScheduleTab::Cycles => self.cycle_schedule_entries().len(),
256 crate::ui::schedule::ScheduleTab::Capacity => self.capacity_rows().len(),
257 };
258 (rows == 0).then(|| {
259 let remedy = match tab {
260 crate::ui::schedule::ScheduleTab::Series => {
261 "press n to define a series, then try again"
262 }
263 crate::ui::schedule::ScheduleTab::Cycles => {
264 "press n to define a cycle schedule, then try again"
265 }
266 crate::ui::schedule::ScheduleTab::Capacity => {
267 "configure a capacity scope, then reopen Schedule"
268 }
269 };
270 ActionFeedback::new(
271 action.description_in(context),
272 format!("the {} tab has no rows", tab.label()),
273 remedy,
274 )
275 })
276 }
277 InputContext::Portfolio => {
278 let tab = self
279 .portfolio
280 .as_ref()
281 .map_or(crate::ui::portfolio::PortfolioTab::Projects, |state| {
282 state.tab
283 });
284 let rows = match tab {
285 crate::ui::portfolio::PortfolioTab::Projects => self.projects_snapshot().len(),
286 crate::ui::portfolio::PortfolioTab::Epics => self.portfolio_epics().len(),
287 crate::ui::portfolio::PortfolioTab::Roadmap => self
288 .portfolio_roadmap()
289 .map_or(0, |projection| projection.initiatives.len()),
290 };
291 if matches!(action, MoveUp | MoveDown) && rows == 0 {
292 return empty_list(
293 "portfolio rows",
294 "press Tab to choose another tab, or define a record with tix",
295 );
296 }
297 if matches!(action, MoveLeft | MoveRight) {
298 let members = self
299 .portfolio_epics()
300 .get(self.portfolio_state().selected)
301 .map_or(0, |entry| entry.members.len());
302 if members == 0 {
303 return empty_list(
304 "epic members",
305 "press Tab until Epics is active and select an epic with members",
306 );
307 }
308 }
309 if action == Submit && tab != crate::ui::portfolio::PortfolioTab::Epics {
310 return unavailable(
311 "only an epic member can be opened directly",
312 "press Tab until Epics is active, then select a member",
313 );
314 }
315 None
316 }
317 InputContext::Library if matches!(action, MoveUp | MoveDown | Submit) => {
318 let tab = self
319 .library
320 .as_ref()
321 .map_or(crate::ui::library::LibraryTab::Views, |state| state.tab);
322 self.library_entries(tab).is_empty().then(|| {
323 ActionFeedback::new(
324 action.description_in(context),
325 format!("the {} tab has no rows", tab.label()),
326 "press Tab to choose another tab, or create a view with tix view create",
327 )
328 })
329 }
330 InputContext::Settings => {
331 let boards = self.store.config.configs().count();
332 let section = self
333 .settings
334 .as_ref()
335 .map_or(crate::ui::settings::ConfigSection::Dashboards, |modal| {
336 modal.section
337 });
338 if action == Delete
339 && section == crate::ui::settings::ConfigSection::Dashboards
340 && boards <= 1
341 {
342 return unavailable(
343 "this is the last board",
344 "press n to add another board first",
345 )
346 .map(|feedback| feedback.with_status("Cannot delete the last board"));
347 }
348 if matches!(action, PreviousBoard | TuiAction::NextBoard) && boards <= 1 {
349 return unavailable(
350 "there is only one configured board",
351 "press n to add another board, then switch again",
352 );
353 }
354 let level = self
355 .settings
356 .as_ref()
357 .map_or(crate::ui::settings::ConfigLevel::Sections, |modal| {
358 modal.level
359 });
360 if matches!(action, MoveColumnUp | MoveColumnDown) {
361 let columns = self.active_board_config().columns.len();
362 if level != crate::ui::settings::ConfigLevel::Detail || columns < 2 {
363 return unavailable(
364 "column order can only change in a board with at least two columns",
365 "open the board detail and add a second column first",
366 );
367 }
368 }
369 if action == Toggle && level != crate::ui::settings::ConfigLevel::Detail {
370 return unavailable(
371 "this toggle acts on a board-detail row",
372 "press l or Enter to open the selected board's detail",
373 );
374 }
375 None
376 }
377 _ => None,
378 }
379 }
380}