#[allow(clippy::wildcard_imports)]
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActionFeedback {
pub action: String,
pub reason: String,
pub remedy: String,
pub status: Option<String>,
}
impl ActionFeedback {
pub(in crate::app) fn new(
action: &str,
reason: impl Into<String>,
remedy: impl Into<String>,
) -> Self {
Self {
action: action.to_string(),
reason: reason.into(),
remedy: remedy.into(),
status: None,
}
}
fn with_status(mut self, status: impl Into<String>) -> Self {
self.status = Some(status.into());
self
}
pub(in crate::app) fn reason_line(&self) -> String {
format!("{} unavailable: {}", self.action, self.reason)
}
pub(in crate::app) fn remedy_line(&self) -> String {
format!("remedy: {}", self.remedy)
}
}
impl App {
#[allow(clippy::too_many_lines)]
pub(in crate::app) fn action_refusal(
&self,
context: InputContext,
action: TuiAction,
) -> Option<ActionFeedback> {
use TuiAction::{
Agent, Delete, DeletePlanningItem, Edit, EditPlanningItem, MoveColumnDown,
MoveColumnUp, MoveDown, MoveLeft, MoveNext, MovePrevious, MoveRight, MoveUp, Open,
OpenPicker, PreviousBoard, ReorderDown, ReorderUp, ScrollDown, ScrollUp, Submit,
TimeAdd, TimeBreakdown, TimeDelete, TimeEdit, TimeFilter, TimeHistory, Toggle, View,
};
let unavailable = |reason: &str, remedy: &str| {
Some(ActionFeedback::new(
action.description_in(context),
reason,
remedy,
))
};
let empty_list = |noun: &str, remedy: &str| {
unavailable(&format!("there are no {noun} to act on"), remedy)
};
let needs_selection = || {
unavailable(
"no issue is selected",
"move the cursor onto an issue, then try again",
)
};
match context {
InputContext::Board
if matches!(
action,
Open | Edit
| View
| Agent
| Delete
| MovePrevious
| MoveNext
| ReorderUp
| ReorderDown
) && self.board.get_selected_card().is_none() =>
{
needs_selection()
}
InputContext::Board
if matches!(
action,
MoveUp | MoveDown | ScrollUp | ScrollDown | TuiAction::Mark
) && self.board.column_cards(self.board.active_col).is_empty() =>
{
unavailable(
"the visible board column has no issues",
"add an issue with n or clear the filter with f",
)
}
InputContext::Form if action == OpenPicker => {
let focus = self.form.as_ref()?.focus;
if focus.is_target() {
unavailable(
"the Target row is a toggle, not a picker",
"press Enter to switch between Triage and Todo",
)
} else if matches!(
focus,
crate::ui::form::FormFocus::Title
| crate::ui::form::FormFocus::Estimate
| crate::ui::form::FormFocus::Body
) {
unavailable(
&format!("the {} field is typed, not picked", focus.label()),
"focus a chosen field, or press Enter where the field has a picker",
)
} else {
None
}
}
InputContext::Form if action == Submit => {
let form = self.form.as_ref()?;
let enter_does_something_else = form.focus.is_target()
|| form.focus.is_template()
|| form.focus.is_status()
|| form.focus.is_priority()
|| (form.focus.is_date() && !form.focused_date_is_complete())
|| (form.focus.is_parent() && form.parent.trim().is_empty());
(form.title.trim().is_empty() && !enter_does_something_else).then(|| {
ActionFeedback::new(
action.description_in(context),
"the Title field is empty",
"type a title, then press Enter again",
)
.with_status("Title is required")
})
}
InputContext::Triage
if matches!(action, MoveUp | MoveDown | Submit | TuiAction::Toggle)
&& self
.triage
.as_ref()
.is_none_or(|state| state.rows.is_empty()) =>
{
unavailable(
"the triage inbox has no issues",
"press n to create one in the entry column, or q to return to the board",
)
}
InputContext::Cycle if matches!(action, MoveUp | MoveDown | MoveLeft | MoveRight) => {
let rows = self.cycle_entry_count();
let selected = self.cycle_state.as_ref().map_or(0, |state| state.selected);
let groups = self.cycle_groups();
let populated_groups = groups
.iter()
.filter(|group| !group.entries.is_empty())
.count();
let at_vertical_bound = match action {
MoveUp => selected == 0,
MoveDown => rows == 0 || selected + 1 >= rows,
MoveLeft | MoveRight => populated_groups <= 1,
_ => false,
};
at_vertical_bound.then(|| {
ActionFeedback::new(
action.description_in(context),
if rows == 0 {
"there are no cycle rows".to_string()
} else {
"the cursor is already at the edge of the cycle listing".to_string()
},
"press m to define another cycle, or move in the other direction",
)
})
}
InputContext::Milestones
if matches!(
action,
MoveUp | MoveDown | Submit | EditPlanningItem | DeletePlanningItem
) && self.planning_rows_len() == 0 =>
{
empty_list(
"milestones",
"press n to define one, or q to return to the board",
)
}
InputContext::Repos
if matches!(action, MoveUp | MoveDown | Submit)
&& self.repo_entries().is_empty() =>
{
empty_list(
"repositories",
"add a `[repos]` entry to tix.toml, then reopen Repos",
)
}
InputContext::Stats => match self.stats_tab {
StatsTab::Health => {
if matches!(action, MoveUp | MoveDown | Submit) && self.stats_rows().is_empty()
{
return empty_list(
"health scopes",
"record issue health or add an issue, then reopen Stats",
);
}
if matches!(
action,
TimeAdd | TimeEdit | TimeDelete | TimeFilter | TimeBreakdown | TimeHistory
) {
return unavailable(
"the Time controls are on the Time tab",
"press Tab until the Time tab is active",
);
}
None
}
StatsTab::Time => {
let rows = self
.stats_time
.as_ref()
.map_or(0, |state| state.rows().len());
if matches!(action, MoveUp | MoveDown | Submit | TimeEdit | TimeDelete)
&& rows == 0
{
return empty_list(
"time entries",
"press a to add an entry, or Tab to choose another tab",
);
}
None
}
StatsTab::Burndown => None,
},
InputContext::Schedule if matches!(action, MoveUp | MoveDown) => {
let tab = self
.schedule
.as_ref()
.map_or(crate::ui::schedule::ScheduleTab::Series, |state| state.tab);
let rows = match tab {
crate::ui::schedule::ScheduleTab::Series => self.schedule_entries().len(),
crate::ui::schedule::ScheduleTab::Cycles => self.cycle_schedule_entries().len(),
crate::ui::schedule::ScheduleTab::Capacity => self.capacity_rows().len(),
};
(rows == 0).then(|| {
let remedy = match tab {
crate::ui::schedule::ScheduleTab::Series => {
"press n to define a series, then try again"
}
crate::ui::schedule::ScheduleTab::Cycles => {
"press n to define a cycle schedule, then try again"
}
crate::ui::schedule::ScheduleTab::Capacity => {
"configure a capacity scope, then reopen Schedule"
}
};
ActionFeedback::new(
action.description_in(context),
format!("the {} tab has no rows", tab.label()),
remedy,
)
})
}
InputContext::Portfolio => {
let tab = self
.portfolio
.as_ref()
.map_or(crate::ui::portfolio::PortfolioTab::Projects, |state| {
state.tab
});
let rows = match tab {
crate::ui::portfolio::PortfolioTab::Projects => self.projects_snapshot().len(),
crate::ui::portfolio::PortfolioTab::Epics => self.portfolio_epics().len(),
crate::ui::portfolio::PortfolioTab::Roadmap => self
.portfolio_roadmap()
.map_or(0, |projection| projection.initiatives.len()),
};
if matches!(action, MoveUp | MoveDown) && rows == 0 {
return empty_list(
"portfolio rows",
"press Tab to choose another tab, or define a record with tix",
);
}
if matches!(action, MoveLeft | MoveRight) {
let members = self
.portfolio_epics()
.get(self.portfolio_state().selected)
.map_or(0, |entry| entry.members.len());
if members == 0 {
return empty_list(
"epic members",
"press Tab until Epics is active and select an epic with members",
);
}
}
if action == Submit && tab != crate::ui::portfolio::PortfolioTab::Epics {
return unavailable(
"only an epic member can be opened directly",
"press Tab until Epics is active, then select a member",
);
}
None
}
InputContext::Library if matches!(action, MoveUp | MoveDown | Submit) => {
let tab = self
.library
.as_ref()
.map_or(crate::ui::library::LibraryTab::Views, |state| state.tab);
self.library_entries(tab).is_empty().then(|| {
ActionFeedback::new(
action.description_in(context),
format!("the {} tab has no rows", tab.label()),
"press Tab to choose another tab, or create a view with tix view create",
)
})
}
InputContext::Settings => {
let boards = self.store.config.configs().count();
let section = self
.settings
.as_ref()
.map_or(crate::ui::settings::ConfigSection::Dashboards, |modal| {
modal.section
});
if action == Delete
&& section == crate::ui::settings::ConfigSection::Dashboards
&& boards <= 1
{
return unavailable(
"this is the last board",
"press n to add another board first",
)
.map(|feedback| feedback.with_status("Cannot delete the last board"));
}
if matches!(action, PreviousBoard | TuiAction::NextBoard) && boards <= 1 {
return unavailable(
"there is only one configured board",
"press n to add another board, then switch again",
);
}
let level = self
.settings
.as_ref()
.map_or(crate::ui::settings::ConfigLevel::Sections, |modal| {
modal.level
});
if matches!(action, MoveColumnUp | MoveColumnDown) {
let columns = self.active_board_config().columns.len();
if level != crate::ui::settings::ConfigLevel::Detail || columns < 2 {
return unavailable(
"column order can only change in a board with at least two columns",
"open the board detail and add a second column first",
);
}
}
if action == Toggle && level != crate::ui::settings::ConfigLevel::Detail {
return unavailable(
"this toggle acts on a board-detail row",
"press l or Enter to open the selected board's detail",
);
}
None
}
_ => None,
}
}
}