Skip to main content

tix/app/
feedback.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// Why an advertised TUI action could not run, and what would make it possible.
5///
6/// The pair travels together so a refusal cannot regress into a bare "nothing
7/// happened" or a reason with no next step. The active mode renders both lines.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct ActionFeedback {
10    /// The action named in the user's own vocabulary.
11    pub action: String,
12    /// Why this action cannot run in the current state.
13    pub reason: String,
14    /// What the user can do to make the action possible.
15    pub remedy: String,
16    /// The legacy plain-status spelling some surfaces still consume. The
17    /// two-line notice always uses `reason` and `remedy`.
18    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    /// The first notice line.
41    pub(in crate::app) fn reason_line(&self) -> String {
42        format!("{} unavailable: {}", self.action, self.reason)
43    }
44
45    /// The remedy line.
46    pub(in crate::app) fn remedy_line(&self) -> String {
47        format!("remedy: {}", self.remedy)
48    }
49}
50
51impl App {
52    /// Refuse `action` before dispatch when the current mode makes it impossible.
53    ///
54    /// This is the empty/ineligible half of the contract in tix #1643. Service
55    /// errors and prompts that already explain themselves keep their own
56    /// wording; this guard covers paths that previously returned without
57    /// changing anything.
58    #[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}