use std::fmt::Write;
use crate::keybindings::{EffectiveBindings, InputContext, KeyBindings, TuiAction};
use crate::ui::nav::TopMode;
pub const MODE_BOARD: &str = "board";
pub const MODE_DETAIL: &str = "detail";
pub const MODE_HELP: &str = "help";
pub const MODE_COMMENT: &str = "comment";
pub const MODE_ASSET: &str = "asset";
pub const MODE_FILTER: &str = "filter";
const MAX_HINT_CHARS: usize = 80;
pub struct Cmd {
pub key: &'static str,
pub desc: &'static str,
pub modes: &'static [&'static str],
pub mode: &'static str,
}
pub const COMMANDS: &[Cmd] = &[
Cmd {
key: "j/k",
desc: "move up/down",
modes: &[MODE_BOARD],
mode: MODE_BOARD,
},
Cmd {
key: "n",
desc: "new issue",
modes: &[MODE_BOARD],
mode: MODE_BOARD,
},
Cmd {
key: "?",
desc: "help",
modes: &[MODE_BOARD, MODE_DETAIL],
mode: "board/detail",
},
Cmd {
key: "h/l",
desc: "column",
modes: &[MODE_BOARD],
mode: MODE_BOARD,
},
Cmd {
key: "enter",
desc: "open",
modes: &[MODE_BOARD],
mode: MODE_BOARD,
},
Cmd {
key: "d",
desc: "delete",
modes: &[MODE_BOARD],
mode: MODE_BOARD,
},
Cmd {
key: "< / >",
desc: "move",
modes: &[MODE_BOARD],
mode: MODE_BOARD,
},
Cmd {
key: "r",
desc: "refresh",
modes: &[MODE_BOARD],
mode: MODE_BOARD,
},
Cmd {
key: "/",
desc: "search",
modes: &[MODE_BOARD],
mode: MODE_BOARD,
},
Cmd {
key: "f",
desc: "criteria",
modes: &[MODE_BOARD],
mode: MODE_BOARD,
},
Cmd {
key: "c",
desc: "comment",
modes: &[MODE_DETAIL],
mode: MODE_DETAIL,
},
Cmd {
key: "a",
desc: "asset",
modes: &[MODE_DETAIL],
mode: MODE_DETAIL,
},
];
fn actions(context: InputContext) -> &'static [TuiAction] {
match context {
InputContext::Board => &[
TuiAction::Open,
TuiAction::NewIssue,
TuiAction::Help,
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Planning,
TuiAction::ToggleChildren,
TuiAction::Quit,
],
InputContext::Detail => &[
TuiAction::Comment,
TuiAction::Asset,
TuiAction::RelatedPicker,
TuiAction::FollowRelated,
TuiAction::NavBack,
TuiAction::NewRelationship,
TuiAction::NextRelationship,
TuiAction::PreviousRelationship,
TuiAction::EditRelationship,
TuiAction::RemoveRelationship,
TuiAction::EditParent,
TuiAction::HealthForm,
TuiAction::Edit,
TuiAction::ScrollUp,
TuiAction::ScrollDown,
TuiAction::Help,
],
InputContext::Triage => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Toggle,
TuiAction::Submit,
TuiAction::Cancel,
],
InputContext::Cycle => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::MoveLeft,
TuiAction::MoveRight,
TuiAction::Planning,
TuiAction::Help,
TuiAction::Close,
],
InputContext::Milestones => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Planning,
TuiAction::NewPlanningItem,
TuiAction::EditPlanningItem,
TuiAction::DeletePlanningItem,
TuiAction::Help,
TuiAction::Close,
],
InputContext::Form | InputContext::PlanningForm => &[
TuiAction::Submit,
TuiAction::Next,
TuiAction::Previous,
TuiAction::OpenPicker,
TuiAction::Cancel,
],
InputContext::FormPicker => &[
TuiAction::Next,
TuiAction::Previous,
TuiAction::Submit,
TuiAction::Create,
TuiAction::Cancel,
],
InputContext::PlanningManagement => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::MoveLeft,
TuiAction::MoveRight,
TuiAction::Next,
TuiAction::NewPlanningItem,
TuiAction::EditPlanningItem,
TuiAction::DeletePlanningItem,
TuiAction::Close,
],
InputContext::Settings => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Toggle,
TuiAction::AddColumn,
TuiAction::MoveColumnUp,
TuiAction::MoveColumnDown,
TuiAction::Submit,
TuiAction::NewBoard,
TuiAction::EditPlanningItem,
TuiAction::Delete,
TuiAction::NextBoard,
TuiAction::PreviousBoard,
TuiAction::MoveLeft,
TuiAction::MoveRight,
TuiAction::Close,
],
InputContext::Shell => &[
TuiAction::SwitchToBoard,
TuiAction::SwitchToTriage,
TuiAction::SwitchToCycle,
TuiAction::SwitchToMilestones,
TuiAction::SwitchToRepos,
TuiAction::SwitchToStats,
TuiAction::SwitchToSchedule,
TuiAction::SwitchToLibrary,
TuiAction::SwitchToPortfolio,
TuiAction::SwitchToNewIssue,
TuiAction::SwitchToConfig,
TuiAction::ToggleNavigation,
],
InputContext::Repos => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Submit,
TuiAction::Close,
],
InputContext::Stats => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Next,
TuiAction::Previous,
TuiAction::TimeAdd,
TuiAction::TimeEdit,
TuiAction::TimeDelete,
TuiAction::TimeFilter,
TuiAction::TimeBreakdown,
TuiAction::TimeHistory,
TuiAction::Submit,
TuiAction::Help,
TuiAction::Close,
],
_ => &[],
}
}
pub const fn hint_context_for_mode(mode: TopMode) -> InputContext {
match mode {
TopMode::Board => InputContext::Board,
TopMode::Triage => InputContext::Triage,
TopMode::Cycle => InputContext::Cycle,
TopMode::Milestones => InputContext::Milestones,
TopMode::Repos => InputContext::Repos,
TopMode::Stats => InputContext::Stats,
TopMode::Schedule => InputContext::Schedule,
TopMode::Library => InputContext::Library,
TopMode::Portfolio => InputContext::Portfolio,
TopMode::NewIssue => InputContext::Form,
TopMode::Config => InputContext::Settings,
}
}
pub fn hint_bar(active_modes: &[&str]) -> String {
let bindings =
KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
for mode in active_modes {
if let Some(context) = match *mode {
MODE_BOARD => Some(InputContext::Board),
MODE_DETAIL => Some(InputContext::Detail),
_ => None,
} {
return hint_bar_for(&bindings, context);
}
}
String::new()
}
pub fn hint_bar_for(bindings: &EffectiveBindings, context: InputContext) -> String {
hint_bar_for_with(bindings, context, None)
}
pub fn hint_bar_for_with(
bindings: &EffectiveBindings,
context: InputContext,
stats_tab: Option<crate::app::StatsTab>,
) -> String {
if context == InputContext::Board
&& bindings.label(context, TuiAction::MoveUp) == "Up / k"
&& bindings.label(context, TuiAction::MoveDown) == "Down / j"
{
return truncate(
&format!(
"j/k nav · {} new issue · {} help",
bindings.label(context, TuiAction::NewIssue),
bindings.label(context, TuiAction::Help),
),
MAX_HINT_CHARS,
);
}
let scoped: Option<&'static [TuiAction]> = if context == InputContext::Stats {
stats_tab.map(stats_footer_actions)
} else {
None
};
let rows: &'static [TuiAction] = scoped.unwrap_or_else(|| actions(context));
let mut text = String::new();
for action in rows {
let _ = write!(
text,
"{} {} · ",
bindings.label(context, *action),
action.description_in(context)
);
}
truncate(text.trim_end_matches(" · "), MAX_HINT_CHARS)
}
#[cfg(test)]
const HELP_CONTEXTS: [InputContext; 36] = [
InputContext::Detail,
InputContext::Board,
InputContext::Rollover,
InputContext::Form,
InputContext::FormPicker,
InputContext::ConfirmDelete,
InputContext::MoveIntoDone,
InputContext::ConfirmBlockedMove,
InputContext::Help,
InputContext::HelpSearch,
InputContext::ContextMenu,
InputContext::RelatedPicker,
InputContext::Filter,
InputContext::FilterModal,
InputContext::Settings,
InputContext::SettingsPrompt,
InputContext::PlanningManagement,
InputContext::PlanningConfirm,
InputContext::PlanningForm,
InputContext::DatePicker,
InputContext::Comment,
InputContext::Asset,
InputContext::LinkPrompt,
InputContext::EditRepair,
InputContext::EditConflict,
InputContext::BulkModal,
InputContext::Triage,
InputContext::Cycle,
InputContext::Milestones,
InputContext::Repos,
InputContext::Schedule,
InputContext::Library,
InputContext::Portfolio,
InputContext::Shell,
InputContext::Views,
InputContext::ViewsPrompt,
];
pub const fn contexts_for_mode(mode: TopMode) -> &'static [InputContext] {
match mode {
TopMode::Board => &[
InputContext::Shell,
InputContext::Board,
InputContext::Detail,
InputContext::Rollover,
InputContext::ConfirmDelete,
InputContext::MoveIntoDone,
InputContext::ConfirmBlockedMove,
InputContext::Help,
InputContext::HelpSearch,
InputContext::ContextMenu,
InputContext::RelatedPicker,
InputContext::Filter,
InputContext::FilterModal,
InputContext::Comment,
InputContext::Asset,
InputContext::LinkPrompt,
InputContext::EditRepair,
InputContext::EditConflict,
InputContext::BulkModal,
InputContext::Views,
InputContext::ViewsPrompt,
],
TopMode::Triage => &[InputContext::Shell, InputContext::Triage],
TopMode::Cycle => &[
InputContext::Shell,
InputContext::Cycle,
InputContext::PlanningManagement,
InputContext::PlanningConfirm,
InputContext::PlanningForm,
InputContext::DatePicker,
],
TopMode::Milestones => &[
InputContext::Shell,
InputContext::Milestones,
InputContext::PlanningManagement,
InputContext::PlanningConfirm,
InputContext::PlanningForm,
InputContext::DatePicker,
InputContext::SettingsPrompt,
],
TopMode::Repos => &[InputContext::Shell, InputContext::Repos],
TopMode::Stats => &[
InputContext::Shell,
InputContext::Stats,
InputContext::Comment,
],
TopMode::Schedule => &[InputContext::Shell, InputContext::Schedule],
TopMode::Library => &[InputContext::Shell, InputContext::Library],
TopMode::Portfolio => &[InputContext::Shell, InputContext::Portfolio],
TopMode::NewIssue => &[
InputContext::Shell,
InputContext::Form,
InputContext::FormPicker,
],
TopMode::Config => &[
InputContext::Shell,
InputContext::Settings,
InputContext::SettingsPrompt,
],
}
}
pub fn help_overlay(bindings: &EffectiveBindings, mode: TopMode) -> Vec<(String, String, String)> {
help_overlay_for(bindings, mode, None, None)
}
const STATS_HEALTH_HELP_ACTIONS: &[TuiAction] = &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Next,
TuiAction::Previous,
TuiAction::Submit,
TuiAction::Help,
TuiAction::Close,
];
const STATS_TIME_HELP_ACTIONS: &[TuiAction] = &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Next,
TuiAction::Previous,
TuiAction::Submit,
TuiAction::TimeAdd,
TuiAction::TimeEdit,
TuiAction::TimeDelete,
TuiAction::TimeFilter,
TuiAction::TimeBreakdown,
TuiAction::TimeHistory,
TuiAction::Help,
TuiAction::Close,
];
const STATS_HEALTH_FOOTER: &[TuiAction] = &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Next,
TuiAction::Previous,
TuiAction::Submit,
TuiAction::Help,
TuiAction::Close,
];
const STATS_TIME_FOOTER: &[TuiAction] = &[
TuiAction::TimeAdd,
TuiAction::TimeEdit,
TuiAction::TimeDelete,
TuiAction::TimeFilter,
TuiAction::TimeBreakdown,
TuiAction::TimeHistory,
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Next,
TuiAction::Previous,
TuiAction::Submit,
TuiAction::Help,
TuiAction::Close,
];
fn stats_help_actions(tab: crate::app::StatsTab) -> &'static [TuiAction] {
match tab {
crate::app::StatsTab::Health => STATS_HEALTH_HELP_ACTIONS,
crate::app::StatsTab::Time => STATS_TIME_HELP_ACTIONS,
crate::app::StatsTab::Burndown => crate::ui::analytics::HELP_ACTIONS,
}
}
fn stats_footer_actions(tab: crate::app::StatsTab) -> &'static [TuiAction] {
match tab {
crate::app::StatsTab::Health => STATS_HEALTH_FOOTER,
crate::app::StatsTab::Time => STATS_TIME_FOOTER,
crate::app::StatsTab::Burndown => crate::ui::analytics::FOOTER_ACTIONS,
}
}
fn stats_raw_rows(tab: crate::app::StatsTab) -> &'static [(&'static str, &'static str)] {
match tab {
crate::app::StatsTab::Burndown => crate::ui::analytics::HELP_RAW,
crate::app::StatsTab::Health | crate::app::StatsTab::Time => &[],
}
}
pub fn help_overlay_for(
bindings: &EffectiveBindings,
mode: TopMode,
schedule_tab: Option<crate::ui::schedule::ScheduleTab>,
stats_tab: Option<crate::app::StatsTab>,
) -> Vec<(String, String, String)> {
use crate::ui::schedule::{
CAPACITY_HELP_ACTIONS, CAPACITY_HELP_RAW, CYCLE_HELP_ACTIONS, CYCLE_HELP_RAW, ScheduleTab,
};
let tab_rows = (mode == TopMode::Schedule)
.then_some(schedule_tab)
.flatten()
.and_then(|tab| match tab {
ScheduleTab::Cycles => Some((CYCLE_HELP_ACTIONS, CYCLE_HELP_RAW)),
ScheduleTab::Capacity => Some((CAPACITY_HELP_ACTIONS, CAPACITY_HELP_RAW)),
ScheduleTab::Series => None,
});
let stats_rows = if mode == TopMode::Stats {
stats_tab.map(stats_help_actions)
} else {
None
};
let stats_raw = if mode == TopMode::Stats {
stats_tab.map(stats_raw_rows)
} else {
None
};
let mut rows = Vec::new();
if mode == TopMode::Board {
rows.extend([
(
"mouse click".to_string(),
"click".to_string(),
"board".to_string(),
),
(
"mouse double".to_string(),
"double-click".to_string(),
"board".to_string(),
),
(
"mouse right".to_string(),
"right-click".to_string(),
"board".to_string(),
),
(
"mouse drag".to_string(),
"drag".to_string(),
"board".to_string(),
),
]);
}
for context in contexts_for_mode(mode) {
let schedule_context = *context == InputContext::Schedule;
if let Some((actions, raw)) = tab_rows
&& schedule_context
{
rows.extend(actions.iter().map(|(action, description)| {
(
bindings.label(*context, *action),
(*description).to_string(),
context.as_str().to_string(),
)
}));
rows.extend(raw.iter().map(|(key, description)| {
(
(*key).to_string(),
(*description).to_string(),
context.as_str().to_string(),
)
}));
continue;
}
if let Some(actions) = stats_rows
&& *context == InputContext::Stats
{
rows.extend(actions.iter().map(|action| {
(
bindings.label(*context, *action),
action.description_in(*context).to_string(),
context.as_str().to_string(),
)
}));
for (key, description) in stats_raw.unwrap_or(&[]) {
rows.push((
(*key).to_string(),
(*description).to_string(),
context.as_str().to_string(),
));
}
continue;
}
for action in all_actions(*context) {
rows.push((
bindings.label(*context, *action),
action.description_in(*context).to_string(),
context.as_str().to_string(),
));
}
}
rows
}
fn all_actions(context: InputContext) -> &'static [TuiAction] {
match context {
InputContext::Board => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::MoveLeft,
TuiAction::MoveRight,
TuiAction::NextBoard,
TuiAction::PreviousBoard,
TuiAction::Open,
TuiAction::Edit,
TuiAction::View,
TuiAction::Agent,
TuiAction::ToggleColumn,
TuiAction::FilterModal,
TuiAction::BulkModal,
TuiAction::Triage,
TuiAction::OpenMentions,
TuiAction::Views,
TuiAction::Mark,
TuiAction::Unmark,
TuiAction::MovePrevious,
TuiAction::MoveNext,
TuiAction::ScrollUp,
TuiAction::ScrollDown,
TuiAction::NewIssue,
TuiAction::CloneIssue,
TuiAction::ToggleArchived,
TuiAction::ToggleChildren,
TuiAction::Delete,
TuiAction::Refresh,
TuiAction::Settings,
TuiAction::Planning,
TuiAction::NewBoard,
TuiAction::Help,
TuiAction::Quit,
TuiAction::TextFilter,
TuiAction::ReorderUp,
TuiAction::ReorderDown,
],
InputContext::Detail => &[
TuiAction::Close,
TuiAction::Edit,
TuiAction::View,
TuiAction::ScrollUp,
TuiAction::ScrollDown,
TuiAction::Help,
TuiAction::Comment,
TuiAction::Asset,
TuiAction::NewRelationship,
TuiAction::NextRelationship,
TuiAction::PreviousRelationship,
TuiAction::EditRelationship,
TuiAction::RemoveRelationship,
TuiAction::EditParent,
TuiAction::HealthForm,
TuiAction::RelatedPicker,
TuiAction::FollowRelated,
TuiAction::NavBack,
],
InputContext::RelatedPicker => &[
TuiAction::Cancel,
TuiAction::Next,
TuiAction::Previous,
TuiAction::Submit,
],
InputContext::Rollover => &[TuiAction::Submit, TuiAction::Previous, TuiAction::Cancel],
InputContext::Form | InputContext::PlanningForm => &[
TuiAction::Submit,
TuiAction::Cancel,
TuiAction::Next,
TuiAction::Previous,
TuiAction::OpenPicker,
],
InputContext::FormPicker => &[
TuiAction::Cancel,
TuiAction::Next,
TuiAction::Previous,
TuiAction::Submit,
TuiAction::Create,
],
InputContext::ConfirmDelete
| InputContext::PlanningConfirm
| InputContext::ConfirmBlockedMove => &[TuiAction::Confirm, TuiAction::Cancel],
InputContext::MoveIntoDone
| InputContext::Filter
| InputContext::Comment
| InputContext::Asset
| InputContext::LinkPrompt => &[TuiAction::Submit, TuiAction::Cancel],
InputContext::Help => &[
TuiAction::Cancel,
TuiAction::Next,
TuiAction::Previous,
TuiAction::TextFilter,
TuiAction::ScrollUp,
TuiAction::ScrollDown,
TuiAction::Submit,
],
InputContext::ContextMenu => &[
TuiAction::Cancel,
TuiAction::Next,
TuiAction::Previous,
TuiAction::Submit,
TuiAction::ScrollUp,
TuiAction::ScrollDown,
],
InputContext::HelpSearch => &[TuiAction::Cancel, TuiAction::Submit],
InputContext::BulkModal => &[
TuiAction::Next,
TuiAction::Previous,
TuiAction::Submit,
TuiAction::Cancel,
],
InputContext::Triage => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Toggle,
TuiAction::Submit,
TuiAction::Cancel,
],
InputContext::Cycle => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::MoveLeft,
TuiAction::MoveRight,
TuiAction::Planning,
TuiAction::Help,
TuiAction::Close,
],
InputContext::FilterModal => &[
TuiAction::Next,
TuiAction::Previous,
TuiAction::Reset,
TuiAction::Submit,
TuiAction::Cancel,
TuiAction::OpenPicker,
],
InputContext::Milestones => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Submit,
TuiAction::NewPlanningItem,
TuiAction::EditPlanningItem,
TuiAction::DeletePlanningItem,
TuiAction::Planning,
TuiAction::Help,
TuiAction::Close,
],
InputContext::Stats => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Next,
TuiAction::Previous,
TuiAction::Submit,
TuiAction::TimeAdd,
TuiAction::TimeEdit,
TuiAction::TimeDelete,
TuiAction::TimeFilter,
TuiAction::TimeBreakdown,
TuiAction::TimeHistory,
TuiAction::Help,
TuiAction::Close,
],
InputContext::Repos => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Submit,
TuiAction::Help,
TuiAction::Close,
],
InputContext::Schedule => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::SeriesNew,
TuiAction::SeriesGenerate,
TuiAction::SeriesPause,
TuiAction::SeriesResume,
TuiAction::SeriesEnd,
TuiAction::SeriesSkip,
TuiAction::SeriesDefer,
TuiAction::SeriesIgnore,
TuiAction::Help,
TuiAction::Close,
],
InputContext::Library => &[
TuiAction::Next,
TuiAction::Previous,
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Submit,
TuiAction::Help,
TuiAction::Close,
],
InputContext::Portfolio => &[
TuiAction::Next,
TuiAction::Previous,
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::MoveLeft,
TuiAction::MoveRight,
TuiAction::Submit,
TuiAction::Help,
TuiAction::Close,
],
InputContext::Views => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::ApplyView,
TuiAction::NewView,
TuiAction::RenameView,
TuiAction::UpdateView,
TuiAction::DuplicateView,
TuiAction::DeleteView,
TuiAction::Close,
],
InputContext::ViewsPrompt => &[TuiAction::Submit, TuiAction::Cancel],
InputContext::Settings => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Toggle,
TuiAction::AddColumn,
TuiAction::MoveColumnUp,
TuiAction::MoveColumnDown,
TuiAction::Submit,
TuiAction::NewBoard,
TuiAction::EditPlanningItem,
TuiAction::Delete,
TuiAction::NextBoard,
TuiAction::PreviousBoard,
TuiAction::MoveLeft,
TuiAction::MoveRight,
TuiAction::Close,
],
InputContext::SettingsPrompt => &[TuiAction::Submit, TuiAction::Cancel],
InputContext::PlanningManagement => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::MoveLeft,
TuiAction::MoveRight,
TuiAction::Next,
TuiAction::NewPlanningItem,
TuiAction::EditPlanningItem,
TuiAction::DeletePlanningItem,
TuiAction::Close,
],
InputContext::DatePicker => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::MoveLeft,
TuiAction::MoveRight,
TuiAction::PreviousMonth,
TuiAction::NextMonth,
TuiAction::PreviousYear,
TuiAction::NextYear,
TuiAction::Submit,
TuiAction::Reset,
TuiAction::Cancel,
],
InputContext::EditRepair => &[
TuiAction::Confirm,
TuiAction::DeclareKey,
TuiAction::Reedit,
TuiAction::Ignore,
TuiAction::Discard,
],
InputContext::EditConflict => &[TuiAction::Reedit, TuiAction::Ignore, TuiAction::Confirm],
InputContext::Shell => &[
TuiAction::SwitchToBoard,
TuiAction::SwitchToTriage,
TuiAction::SwitchToCycle,
TuiAction::SwitchToMilestones,
TuiAction::SwitchToRepos,
TuiAction::SwitchToStats,
TuiAction::SwitchToSchedule,
TuiAction::SwitchToLibrary,
TuiAction::SwitchToPortfolio,
TuiAction::SwitchToNewIssue,
TuiAction::SwitchToConfig,
TuiAction::ToggleNavigation,
],
}
}
fn truncate(value: &str, max_chars: usize) -> String {
if max_chars == 0 || value.chars().count() <= max_chars {
return value.to_string();
}
let take = max_chars.saturating_sub(1);
let head: String = value.chars().take(take).collect();
format!("{}…", head.trim_end())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_bound_action_appears_in_the_overlay() {
let bindings =
KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
let mut missing: Vec<String> = Vec::new();
for context in HELP_CONTEXTS {
let listed = all_actions(context);
for action in bindings.bound_actions(context) {
if !listed.contains(&action) {
missing.push(format!("{} — {}", context.as_str(), action.as_str()));
}
}
}
assert!(
missing.is_empty(),
"the overlay does not list these bound actions, so nothing in the help \
would tell a user they exist:\n {}",
missing.join("\n ")
);
}
#[test]
fn the_overlay_and_its_guard_cover_the_same_contexts() {
let bindings =
KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
let drawn: Vec<(String, String, String)> = TopMode::ALL
.iter()
.flat_map(|mode| help_overlay(&bindings, *mode))
.collect();
for context in HELP_CONTEXTS {
let scope = context.as_str();
assert!(
drawn.iter().any(|(_, _, listed)| listed == scope),
"`{scope}` is in HELP_CONTEXTS but contributes no rows to any mode's overlay"
);
}
}
#[test]
fn help_is_scoped_to_the_active_mode() {
let bindings =
KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
for mode in TopMode::ALL {
let allowed: Vec<&str> = contexts_for_mode(mode).iter().map(|c| c.as_str()).collect();
for (key, description, scope) in help_overlay(&bindings, mode) {
assert!(
allowed.contains(&scope.as_str()),
"{mode:?} help shows `{key} {description}` from out-of-scope `{scope}`"
);
}
}
let cycle = help_overlay(&bindings, TopMode::Cycle);
assert!(
!cycle
.iter()
.any(|(_, description, _)| description == "toggle column"),
"cycle help must not list board bindings: {cycle:?}"
);
let board = help_overlay(&bindings, TopMode::Board);
assert!(
board
.iter()
.any(|(_, description, _)| description == "toggle column")
);
for mode in TopMode::ALL {
let rows = help_overlay(&bindings, mode);
assert!(
rows.iter()
.any(|(_, description, _)| description == "toggle navigation"),
"{mode:?} help must list the navigation control"
);
}
}
#[test]
fn dynamic_hint_uses_effective_aliases() {
let mut overrides = KeyBindings::default();
overrides.board.new_issue = Some(vec!["ctrl+n".into(), "f4".into()]);
overrides.board.help = Some(vec!["f1".into()]);
let bindings = KeyBindings::resolve(&overrides).unwrap();
let hint = hint_bar_for(&bindings, InputContext::Board);
assert!(hint.contains("Ctrl+n / F4 new issue"), "got {hint:?}");
assert!(hint.contains("F1 help"), "got {hint:?}");
}
#[test]
fn hints_follow_the_active_mode() {
let bindings =
KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
let board = hint_bar_for(&bindings, hint_context_for_mode(TopMode::Board));
let triage = hint_bar_for(&bindings, hint_context_for_mode(TopMode::Triage));
let config = hint_bar_for(&bindings, hint_context_for_mode(TopMode::Config));
assert!(board.contains("new issue"), "got {board:?}");
assert!(
triage.contains("toggle") && !triage.contains("new issue"),
"triage hint should describe triage, got {triage:?}"
);
assert!(
config.contains("add column") && !config.contains("new issue"),
"config hint should describe settings, got {config:?}"
);
}
#[test]
fn help_covers_every_configured_action() {
let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
let rows: Vec<(String, String, String)> = TopMode::ALL
.iter()
.flat_map(|mode| help_overlay(&bindings, *mode))
.collect();
for context in InputContext::ALL {
for action in all_actions(*context) {
assert!(
rows.iter().any(|(_, description, mode)| description
== action.description_in(*context)
&& mode == context.as_str()),
"{} / {} is not listed by any mode's help",
context.as_str(),
action.as_str()
);
}
}
}
}