#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Hint {
pub key: &'static str,
pub label: &'static str,
}
const fn hint(key: &'static str, label: &'static str) -> Hint {
Hint { key, label }
}
pub fn filter_hints() -> &'static [Hint] {
const HINTS: &[Hint] = &[
hint("type", "to filter"),
hint("↑/↓", "move"),
hint("Backspace", "delete"),
hint("Enter", "apply"),
hint("Esc", "clear"),
];
HINTS
}
pub fn create_hints() -> &'static [Hint] {
const HINTS: &[Hint] = &[
hint("↑/↓", "options"),
hint("Enter", "next / submit"),
hint("Esc", "cancel"),
];
HINTS
}
pub fn pr_picker_hints() -> &'static [Hint] {
const HINTS: &[Hint] = &[
hint("↑/↓", "select"),
hint("Enter", "checkout"),
hint("Esc", "close"),
];
HINTS
}
pub fn compose_ai_hints() -> &'static [Hint] {
const HINTS: &[Hint] = &[
hint("Ctrl-A", "AI fill"),
hint("Ctrl-M", "model"),
hint("Ctrl-E", "effort"),
hint("↑/↓", "pick"),
];
HINTS
}
pub fn compose_edit_hints() -> &'static [Hint] {
const HINTS: &[Hint] = &[
hint("Ctrl-S", "submit"),
hint("Ctrl-D", "draft"),
hint("Tab", "field"),
hint("Shift+Tab", "prev field"),
hint("Enter", "advance"),
hint("Esc", "cancel"),
];
HINTS
}
pub fn checkout_hints() -> &'static [Hint] {
const HINTS: &[Hint] = &[
hint("↑/↓", "branches"),
hint("Enter", "checkout"),
hint("Esc", "cancel"),
];
HINTS
}
pub fn confirm_hints() -> &'static [Hint] {
const HINTS: &[Hint] = &[hint("y", "remove"), hint("Esc", "cancel")];
HINTS
}
pub fn confirm_create_hints() -> &'static [Hint] {
const HINTS: &[Hint] = &[hint("y", "create & switch"), hint("Esc", "cancel")];
HINTS
}
pub fn confirm_delete_branch_hints() -> &'static [Hint] {
const HINTS: &[Hint] = &[hint("y", "delete"), hint("Esc", "cancel")];
HINTS
}
pub fn confirm_stale_base_hints() -> &'static [Hint] {
const HINTS: &[Hint] = &[
hint("u", "update"),
hint("p", "proceed"),
hint("Esc", "cancel"),
];
HINTS
}
pub fn confirm_init_submodules_hints() -> &'static [Hint] {
const HINTS: &[Hint] = &[hint("Enter/y", "initialize"), hint("n/Esc", "skip")];
HINTS
}
pub fn help_hints() -> &'static [Hint] {
const HINTS: &[Hint] = &[hint("any key", "close")];
HINTS
}
pub fn format_hint_row(hints: &[Hint]) -> String {
hints
.iter()
.map(|h| format!("{}: {}", h.key, h.label))
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::app::testutil::app;
use crate::tui::app::{
App, CheckoutState, ComposeField, CreateState, CreateStep, Mode, PrComposeState, PrItem,
PrPickerState, StaleBaseState,
};
use crate::tui::event::Effect;
use crate::tui::options::OptionList;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
#[test]
fn format_hint_row_joins_key_and_label() {
let row = format_hint_row(&[hint("↑/↓", "options"), hint("Esc", "cancel")]);
assert_eq!(row, "↑/↓: options Esc: cancel");
}
#[test]
fn every_hint_is_well_formed() {
let tables = [
filter_hints(),
create_hints(),
pr_picker_hints(),
compose_ai_hints(),
compose_edit_hints(),
checkout_hints(),
confirm_hints(),
confirm_create_hints(),
confirm_delete_branch_hints(),
confirm_stale_base_hints(),
help_hints(),
];
for table in tables {
for h in table {
assert!(!h.key.is_empty(), "empty key in {table:?}");
assert!(!h.label.is_empty(), "empty label for {:?}", h.key);
}
}
}
fn key_event(key: &str) -> KeyEvent {
let first = key.split('/').next().unwrap_or(key);
let (code, mods) = match first {
"type" | "any key" => (KeyCode::Char('x'), KeyModifiers::empty()),
"↑" => (KeyCode::Up, KeyModifiers::empty()),
"↓" => (KeyCode::Down, KeyModifiers::empty()),
"Enter" => (KeyCode::Enter, KeyModifiers::empty()),
"Esc" => (KeyCode::Esc, KeyModifiers::empty()),
"Tab" => (KeyCode::Tab, KeyModifiers::empty()),
"Shift+Tab" => (KeyCode::BackTab, KeyModifiers::empty()),
"Backspace" => (KeyCode::Backspace, KeyModifiers::empty()),
"y" => (KeyCode::Char('y'), KeyModifiers::empty()),
"u" => (KeyCode::Char('u'), KeyModifiers::empty()),
"p" => (KeyCode::Char('p'), KeyModifiers::empty()),
"Ctrl-A" => (KeyCode::Char('a'), KeyModifiers::CONTROL),
"Ctrl-S" => (KeyCode::Char('s'), KeyModifiers::CONTROL),
"Ctrl-D" => (KeyCode::Char('d'), KeyModifiers::CONTROL),
"Ctrl-M" => (KeyCode::Char('m'), KeyModifiers::CONTROL),
"Ctrl-E" => (KeyCode::Char('e'), KeyModifiers::CONTROL),
other => panic!("unrecognized hint key {other:?}; teach key_event()"),
};
KeyEvent::new(code, mods)
}
fn pr(number: u64) -> PrItem {
PrItem {
number,
title: format!("pr {number}"),
author: "x".into(),
state: "OPEN".into(),
created_at: "2024-01-15T10:30:00Z".into(),
}
}
fn options(items: &[&str]) -> OptionList {
let mut ol = OptionList::new(items.iter().map(|s| (*s).into()).collect());
ol.open();
ol
}
fn arranged(mode_kind: &str) -> App {
let mut a = app(&[("alpha", true), ("alpine", false), ("beta", false)]);
match mode_kind {
"filter" => {
a.filter = "al".into();
a.selected = 1;
a.mode = Mode::Filter;
}
"create" => {
a.mode = Mode::Create(CreateState {
step: CreateStep::Branch,
branch: "fe".into(),
options: options(&["main", "master"]),
..Default::default()
});
}
"pr_picker" => {
a.mode = Mode::PrPicker(PrPickerState {
prs: vec![pr(1), pr(2)],
selected: 1,
..Default::default()
});
}
"compose" => {
a.mode = Mode::PrCompose(PrComposeState {
field: ComposeField::Model,
title: "hi".into(),
..Default::default()
});
}
"checkout" => {
a.mode = Mode::Checkout(CheckoutState {
worktree_index: 0,
query: "m".into(),
options: options(&["main", "master"]),
..Default::default()
});
}
"confirm" => a.mode = Mode::ConfirmRemove(0),
"confirm_create" => a.mode = Mode::ConfirmCreate(0),
"confirm_delete_branch" => {
a.mode = Mode::ConfirmDeleteBranch {
index: 0,
force: false,
}
}
"confirm_stale_base" => {
a.mode = Mode::ConfirmStaleBase(StaleBaseState {
branch: "feature".into(),
base: Some("main".into()),
behind: 1,
upstream_display: "origin/main".into(),
can_fast_forward: true,
})
}
"help" => a.mode = Mode::Help,
other => panic!("unknown mode {other}"),
}
a
}
fn fingerprint(a: &App) -> String {
format!("{:?}|{}|{}", a.mode, a.filter, a.selected)
}
fn assert_hints_live(mode_kind: &str, hints: &[Hint]) {
for h in hints {
let mut a = arranged(mode_kind);
let before = fingerprint(&a);
let effect = a.handle_event(Event::Key(key_event(h.key)));
let after = fingerprint(&a);
assert!(
effect != Effect::None || before != after,
"{mode_kind} hint {:?} ({}) was ignored by the handler",
h.key,
h.label,
);
}
}
#[test]
fn modal_hints_drive_real_handlers() {
assert_hints_live("filter", filter_hints());
assert_hints_live("create", create_hints());
assert_hints_live("pr_picker", pr_picker_hints());
assert_hints_live("compose", compose_ai_hints());
assert_hints_live("compose", compose_edit_hints());
assert_hints_live("checkout", checkout_hints());
assert_hints_live("confirm", confirm_hints());
assert_hints_live("confirm_create", confirm_create_hints());
assert_hints_live("confirm_delete_branch", confirm_delete_branch_hints());
assert_hints_live("confirm_stale_base", confirm_stale_base_hints());
assert_hints_live("help", help_hints());
}
}