use std::fmt::Write;
use crate::keybindings::{EffectiveBindings, InputContext, KeyBindings, TuiAction};
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: "filter",
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::NewIssue,
TuiAction::Help,
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Planning,
TuiAction::Quit,
],
InputContext::Detail => &[
TuiAction::Comment,
TuiAction::Asset,
TuiAction::NewRelationship,
TuiAction::NextRelationship,
TuiAction::PreviousRelationship,
TuiAction::EditRelationship,
TuiAction::RemoveRelationship,
TuiAction::Edit,
TuiAction::ScrollUp,
TuiAction::ScrollDown,
TuiAction::Help,
],
_ => &[],
}
}
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 {
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 mut text = String::new();
for action in actions(context) {
let _ = write!(
text,
"{} {} · ",
bindings.label(context, *action),
action.description()
);
}
truncate(text.trim_end_matches(" · "), MAX_HINT_CHARS)
}
const HELP_CONTEXTS: [InputContext; 25] = [
InputContext::Detail,
InputContext::Board,
InputContext::Rollover,
InputContext::Form,
InputContext::FormPicker,
InputContext::ConfirmDelete,
InputContext::MoveIntoDone,
InputContext::ConfirmBlockedMove,
InputContext::Help,
InputContext::HelpSearch,
InputContext::ContextMenu,
InputContext::Filter,
InputContext::FilterModal,
InputContext::Settings,
InputContext::SettingsPrompt,
InputContext::PlanningManagement,
InputContext::PlanningConfirm,
InputContext::PlanningForm,
InputContext::DatePicker,
InputContext::Comment,
InputContext::Asset,
InputContext::LinkPrompt,
InputContext::EditWarning,
InputContext::BulkModal,
InputContext::Triage,
];
pub fn help_overlay(bindings: &EffectiveBindings) -> Vec<(String, String, String)> {
let mut rows = vec![
(
"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 HELP_CONTEXTS {
for action in all_actions(context) {
rows.push((
bindings.label(context, *action),
action.description().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::Agent,
TuiAction::ToggleColumn,
TuiAction::FilterModal,
TuiAction::BulkModal,
TuiAction::Triage,
TuiAction::Mark,
TuiAction::Unmark,
TuiAction::MovePrevious,
TuiAction::MoveNext,
TuiAction::ScrollUp,
TuiAction::ScrollDown,
TuiAction::NewIssue,
TuiAction::CloneIssue,
TuiAction::ToggleArchived,
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::ScrollUp,
TuiAction::ScrollDown,
TuiAction::Help,
TuiAction::Comment,
TuiAction::Asset,
TuiAction::NewRelationship,
TuiAction::NextRelationship,
TuiAction::PreviousRelationship,
TuiAction::EditRelationship,
TuiAction::RemoveRelationship,
],
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,
],
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::FilterModal => &[
TuiAction::Next,
TuiAction::Previous,
TuiAction::Reset,
TuiAction::Submit,
TuiAction::Cancel,
TuiAction::OpenPicker,
],
InputContext::Settings => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Next,
TuiAction::Previous,
TuiAction::Toggle,
TuiAction::AddColumn,
TuiAction::NewBoard,
TuiAction::Delete,
TuiAction::Close,
],
InputContext::SettingsPrompt => &[TuiAction::Submit, TuiAction::Cancel],
InputContext::PlanningManagement => &[
TuiAction::MoveUp,
TuiAction::MoveDown,
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::EditWarning => &[TuiAction::Reedit, TuiAction::Ignore],
}
}
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 = help_overlay(&bindings);
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 the overlay"
);
}
}
#[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 help_covers_every_configured_action() {
let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
let rows = help_overlay(&bindings);
for context in InputContext::ALL {
for action in all_actions(*context) {
assert!(
rows.iter()
.any(|(_, description, mode)| description == action.description()
&& mode == context.as_str())
);
}
}
}
}