use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::app::{LiveScreen, Mode, ReplayScreen};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyChord {
Char(char),
Ctrl(char),
Up,
Down,
Left,
Right,
Enter,
Esc,
Tab,
BackTab,
Home,
End,
}
impl KeyChord {
#[must_use]
pub fn from_event(key: KeyEvent) -> Option<Self> {
if key.modifiers.contains(KeyModifiers::CONTROL)
&& let KeyCode::Char(c) = key.code
{
return Some(Self::Ctrl(c.to_ascii_lowercase()));
}
match key.code {
KeyCode::Char(c) => Some(Self::Char(c)),
KeyCode::Up => Some(Self::Up),
KeyCode::Down => Some(Self::Down),
KeyCode::Left => Some(Self::Left),
KeyCode::Right => Some(Self::Right),
KeyCode::Enter => Some(Self::Enter),
KeyCode::Esc => Some(Self::Esc),
KeyCode::Tab => Some(Self::Tab),
KeyCode::BackTab => Some(Self::BackTab),
KeyCode::Home => Some(Self::Home),
KeyCode::End => Some(Self::End),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Context {
Global,
Any,
Live(LiveScreen),
Replay(ReplayScreen),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Global(GlobalAction),
Chain(ChainAction),
Depth(DepthAction),
Surface(SurfaceAction),
Payoff(PayoffAction),
Replay(ReplayAction),
Ascend,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum GlobalAction {
Quit,
ToggleHelp,
SwitchScreen,
NextScreen,
PrevScreen,
Reconnect,
Rediscover,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlobalCommand {
Quit,
ToggleHelp,
SwitchScreen(u8),
NextScreen,
PrevScreen,
Reconnect,
Rediscover,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum ChainAction {
MoveStrike,
SwitchExpiry,
SwitchUnderlying,
FocusLeg,
Drill,
AddLeg,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum DepthAction {
Scroll,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum SurfaceAction {
CycleGreek,
ToggleView,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum PayoffAction {
AddLeg,
RemoveLeg,
Quantity,
ToggleSide,
Commit,
Cancel,
ToggleCurve,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum ReplayAction {
PlayPause,
SpeedSlower,
SpeedFaster,
StepBack,
StepForward,
PrevFill,
NextFill,
JumpStart,
JumpEnd,
}
#[derive(Debug, Clone, Copy)]
pub struct Binding {
pub context: Context,
pub action: Action,
pub chords: &'static [KeyChord],
pub keys_label: &'static str,
pub help: &'static str,
pub deferred: Option<&'static str>,
}
pub static KEYMAP: &[Binding] = &[
Binding {
context: Context::Global,
action: Action::Global(GlobalAction::Quit),
chords: &[KeyChord::Char('q'), KeyChord::Ctrl('c')],
keys_label: "q / Ctrl-C",
help: "Quit",
deferred: None,
},
Binding {
context: Context::Global,
action: Action::Global(GlobalAction::ToggleHelp),
chords: &[KeyChord::Char('?')],
keys_label: "?",
help: "Toggle help",
deferred: None,
},
Binding {
context: Context::Global,
action: Action::Global(GlobalAction::SwitchScreen),
chords: &[
KeyChord::Char('1'),
KeyChord::Char('2'),
KeyChord::Char('3'),
KeyChord::Char('4'),
],
keys_label: "1-4",
help: "Switch screen",
deferred: None,
},
Binding {
context: Context::Global,
action: Action::Global(GlobalAction::NextScreen),
chords: &[KeyChord::Tab],
keys_label: "Tab",
help: "Next screen",
deferred: None,
},
Binding {
context: Context::Global,
action: Action::Global(GlobalAction::PrevScreen),
chords: &[KeyChord::BackTab],
keys_label: "S-Tab",
help: "Previous screen",
deferred: None,
},
Binding {
context: Context::Global,
action: Action::Global(GlobalAction::Reconnect),
chords: &[KeyChord::Char('r')],
keys_label: "r",
help: "Reconnect provider",
deferred: None,
},
Binding {
context: Context::Global,
action: Action::Global(GlobalAction::Rediscover),
chords: &[KeyChord::Char('R')],
keys_label: "R",
help: "Reload / re-open",
deferred: None,
},
Binding {
context: Context::Any,
action: Action::Ascend,
chords: &[KeyChord::Esc],
keys_label: "Esc",
help: "Ascend / close",
deferred: None,
},
Binding {
context: Context::Live(LiveScreen::Chain),
action: Action::Chain(ChainAction::MoveStrike),
chords: &[
KeyChord::Up,
KeyChord::Down,
KeyChord::Char('k'),
KeyChord::Char('j'),
],
keys_label: "โโ / kj",
help: "Move strike",
deferred: None,
},
Binding {
context: Context::Live(LiveScreen::Chain),
action: Action::Chain(ChainAction::SwitchExpiry),
chords: &[
KeyChord::Left,
KeyChord::Right,
KeyChord::Char('h'),
KeyChord::Char('l'),
],
keys_label: "โโ / hl",
help: "Switch expiration",
deferred: Some("soon"),
},
Binding {
context: Context::Live(LiveScreen::Chain),
action: Action::Chain(ChainAction::SwitchUnderlying),
chords: &[KeyChord::Char('['), KeyChord::Char(']')],
keys_label: "[ ]",
help: "Prev / next underlying",
deferred: Some("soon"),
},
Binding {
context: Context::Live(LiveScreen::Chain),
action: Action::Chain(ChainAction::FocusLeg),
chords: &[KeyChord::Char('c'), KeyChord::Char('p')],
keys_label: "c / p",
help: "Focus call / put leg",
deferred: None,
},
Binding {
context: Context::Live(LiveScreen::Chain),
action: Action::Chain(ChainAction::Drill),
chords: &[KeyChord::Enter],
keys_label: "Enter",
help: "Drill into strike",
deferred: Some("soon"),
},
Binding {
context: Context::Live(LiveScreen::Chain),
action: Action::Chain(ChainAction::AddLeg),
chords: &[KeyChord::Char('a')],
keys_label: "a",
help: "Add leg to builder",
deferred: None,
},
Binding {
context: Context::Live(LiveScreen::Depth),
action: Action::Depth(DepthAction::Scroll),
chords: &[
KeyChord::Up,
KeyChord::Down,
KeyChord::Char('k'),
KeyChord::Char('j'),
],
keys_label: "โโ / kj",
help: "Scroll ladder",
deferred: None,
},
Binding {
context: Context::Live(LiveScreen::Surface),
action: Action::Surface(SurfaceAction::CycleGreek),
chords: &[KeyChord::Char('g'), KeyChord::Char('G')],
keys_label: "g / G",
help: "Cycle axis (curve/surf)",
deferred: None,
},
Binding {
context: Context::Live(LiveScreen::Surface),
action: Action::Surface(SurfaceAction::ToggleView),
chords: &[KeyChord::Char('x')],
keys_label: "x",
help: "Smile / curve / surface",
deferred: None,
},
Binding {
context: Context::Live(LiveScreen::Payoff),
action: Action::Payoff(PayoffAction::AddLeg),
chords: &[KeyChord::Char('a')],
keys_label: "a",
help: "Add leg at cursor",
deferred: None,
},
Binding {
context: Context::Live(LiveScreen::Payoff),
action: Action::Payoff(PayoffAction::RemoveLeg),
chords: &[KeyChord::Char('x')],
keys_label: "x",
help: "Remove cursor leg",
deferred: None,
},
Binding {
context: Context::Live(LiveScreen::Payoff),
action: Action::Payoff(PayoffAction::Quantity),
chords: &[KeyChord::Char('+'), KeyChord::Char('-')],
keys_label: "+ / -",
help: "Quantity + / -",
deferred: None,
},
Binding {
context: Context::Live(LiveScreen::Payoff),
action: Action::Payoff(PayoffAction::ToggleSide),
chords: &[KeyChord::Char('s')],
keys_label: "s",
help: "Toggle buy / sell",
deferred: None,
},
Binding {
context: Context::Live(LiveScreen::Payoff),
action: Action::Payoff(PayoffAction::Commit),
chords: &[KeyChord::Enter],
keys_label: "Enter",
help: "Commit strategy",
deferred: None,
},
Binding {
context: Context::Live(LiveScreen::Payoff),
action: Action::Payoff(PayoffAction::Cancel),
chords: &[KeyChord::Esc],
keys_label: "Esc",
help: "Cancel builder",
deferred: None,
},
Binding {
context: Context::Live(LiveScreen::Payoff),
action: Action::Payoff(PayoffAction::ToggleCurve),
chords: &[KeyChord::Char('t')],
keys_label: "t",
help: "Expiration / t+0",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Replay),
action: Action::Replay(ReplayAction::PlayPause),
chords: &[KeyChord::Char(' ')],
keys_label: "Space",
help: "Play / pause",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Replay),
action: Action::Replay(ReplayAction::SpeedSlower),
chords: &[KeyChord::Char('-')],
keys_label: "-",
help: "Slower (while playing)",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Replay),
action: Action::Replay(ReplayAction::SpeedFaster),
chords: &[KeyChord::Char('+')],
keys_label: "+",
help: "Faster (while playing)",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Replay),
action: Action::Replay(ReplayAction::StepBack),
chords: &[KeyChord::Left, KeyChord::Char('h')],
keys_label: "โ / h",
help: "Step back",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Replay),
action: Action::Replay(ReplayAction::StepForward),
chords: &[KeyChord::Right, KeyChord::Char('l')],
keys_label: "โ / l",
help: "Step forward",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Replay),
action: Action::Replay(ReplayAction::PrevFill),
chords: &[KeyChord::Char(',')],
keys_label: ",",
help: "Previous fill",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Replay),
action: Action::Replay(ReplayAction::NextFill),
chords: &[KeyChord::Char('.')],
keys_label: ".",
help: "Next fill",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Replay),
action: Action::Replay(ReplayAction::JumpStart),
chords: &[KeyChord::Home],
keys_label: "Home",
help: "Jump to start",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Replay),
action: Action::Replay(ReplayAction::JumpEnd),
chords: &[KeyChord::End],
keys_label: "End",
help: "Jump to end",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Payoff),
action: Action::Replay(ReplayAction::PlayPause),
chords: &[KeyChord::Char(' ')],
keys_label: "Space",
help: "Play / pause",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Payoff),
action: Action::Replay(ReplayAction::SpeedSlower),
chords: &[KeyChord::Char('-')],
keys_label: "-",
help: "Slower (while playing)",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Payoff),
action: Action::Replay(ReplayAction::SpeedFaster),
chords: &[KeyChord::Char('+')],
keys_label: "+",
help: "Faster (while playing)",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Payoff),
action: Action::Replay(ReplayAction::StepBack),
chords: &[KeyChord::Left, KeyChord::Char('h')],
keys_label: "โ / h",
help: "Step back",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Payoff),
action: Action::Replay(ReplayAction::StepForward),
chords: &[KeyChord::Right, KeyChord::Char('l')],
keys_label: "โ / l",
help: "Step forward",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Payoff),
action: Action::Replay(ReplayAction::JumpStart),
chords: &[KeyChord::Home],
keys_label: "Home",
help: "Jump to start",
deferred: None,
},
Binding {
context: Context::Replay(ReplayScreen::Payoff),
action: Action::Replay(ReplayAction::JumpEnd),
chords: &[KeyChord::End],
keys_label: "End",
help: "Jump to end",
deferred: None,
},
];
#[must_use]
pub fn resolve_global(chord: KeyChord) -> Option<GlobalCommand> {
let binding = KEYMAP
.iter()
.find(|b| b.context == Context::Global && b.chords.contains(&chord))?;
match binding.action {
Action::Global(action) => resolve_global_command(action, chord),
Action::Chain(_)
| Action::Depth(_)
| Action::Surface(_)
| Action::Payoff(_)
| Action::Replay(_)
| Action::Ascend => None,
}
}
#[must_use]
fn resolve_global_command(action: GlobalAction, chord: KeyChord) -> Option<GlobalCommand> {
match action {
GlobalAction::Quit => Some(GlobalCommand::Quit),
GlobalAction::ToggleHelp => Some(GlobalCommand::ToggleHelp),
GlobalAction::NextScreen => Some(GlobalCommand::NextScreen),
GlobalAction::PrevScreen => Some(GlobalCommand::PrevScreen),
GlobalAction::Reconnect => Some(GlobalCommand::Reconnect),
GlobalAction::Rediscover => Some(GlobalCommand::Rediscover),
GlobalAction::SwitchScreen => slot_from_chord(chord).map(GlobalCommand::SwitchScreen),
}
}
#[must_use]
fn slot_from_chord(chord: KeyChord) -> Option<u8> {
let KeyChord::Char(c) = chord else {
return None;
};
c.to_digit(10).and_then(|d| u8::try_from(d).ok())
}
#[must_use]
pub fn resolve_chain(chord: KeyChord) -> Option<ChainAction> {
let binding = KEYMAP
.iter()
.find(|b| b.context == Context::Live(LiveScreen::Chain) && b.chords.contains(&chord))?;
match binding.action {
Action::Chain(action) => Some(action),
Action::Global(_)
| Action::Depth(_)
| Action::Surface(_)
| Action::Payoff(_)
| Action::Replay(_)
| Action::Ascend => None,
}
}
#[must_use]
pub fn resolve_payoff(chord: KeyChord) -> Option<PayoffAction> {
let binding = KEYMAP
.iter()
.find(|b| b.context == Context::Live(LiveScreen::Payoff) && b.chords.contains(&chord))?;
match binding.action {
Action::Payoff(action) => Some(action),
Action::Global(_)
| Action::Chain(_)
| Action::Depth(_)
| Action::Surface(_)
| Action::Replay(_)
| Action::Ascend => None,
}
}
#[must_use]
pub fn resolve_depth(chord: KeyChord) -> Option<DepthAction> {
let binding = KEYMAP
.iter()
.find(|b| b.context == Context::Live(LiveScreen::Depth) && b.chords.contains(&chord))?;
match binding.action {
Action::Depth(action) => Some(action),
Action::Global(_)
| Action::Chain(_)
| Action::Surface(_)
| Action::Payoff(_)
| Action::Replay(_)
| Action::Ascend => None,
}
}
#[must_use]
pub fn resolve_surface(chord: KeyChord) -> Option<SurfaceAction> {
let binding = KEYMAP
.iter()
.find(|b| b.context == Context::Live(LiveScreen::Surface) && b.chords.contains(&chord))?;
match binding.action {
Action::Surface(action) => Some(action),
Action::Global(_)
| Action::Chain(_)
| Action::Depth(_)
| Action::Payoff(_)
| Action::Replay(_)
| Action::Ascend => None,
}
}
#[must_use]
pub fn resolve_replay(chord: KeyChord, screen: ReplayScreen) -> Option<ReplayAction> {
let binding = KEYMAP
.iter()
.find(|b| b.context == Context::Replay(screen) && b.chords.contains(&chord))?;
match binding.action {
Action::Replay(action) => Some(action),
Action::Global(_)
| Action::Chain(_)
| Action::Depth(_)
| Action::Surface(_)
| Action::Payoff(_)
| Action::Ascend => None,
}
}
#[must_use]
fn bindings_in(context: Context) -> Vec<&'static Binding> {
KEYMAP.iter().filter(|b| b.context == context).collect()
}
#[must_use]
pub(crate) fn help_sections(mode: &Mode) -> Vec<(&'static str, Vec<&'static Binding>)> {
let mut global = bindings_in(Context::Global);
global.extend(bindings_in(Context::Any));
match mode {
Mode::Live(_) => {
let mut sections = vec![("Global", global)];
sections.push(("Chain", bindings_in(Context::Live(LiveScreen::Chain))));
sections.push(("Depth", bindings_in(Context::Live(LiveScreen::Depth))));
sections.push(("Surface", bindings_in(Context::Live(LiveScreen::Surface))));
sections.push(("Payoff", bindings_in(Context::Live(LiveScreen::Payoff))));
sections
}
Mode::Replay(_) => {
global.retain(|b| b.action != Action::Global(GlobalAction::Reconnect));
let mut sections = vec![("Global", global)];
sections.push(("Replay", bindings_in(Context::Replay(ReplayScreen::Replay))));
sections.push(("Payoff", bindings_in(Context::Replay(ReplayScreen::Payoff))));
sections
}
}
}
#[must_use]
pub fn help_bindings(mode: &Mode) -> Vec<&'static Binding> {
help_sections(mode)
.into_iter()
.flat_map(|(_, bindings)| bindings)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::tests_support::{live_app_on, replay_app_on};
use crate::app::{LiveScreen, ReplayScreen, ScreenLoad};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn test_resolve_global_every_global_chord_resolves_and_is_documented() {
let live = live_app_on(LiveScreen::Chain, ScreenLoad::Ready, false);
let documented = help_bindings(&live.mode);
for binding in KEYMAP.iter().filter(|b| b.context == Context::Global) {
for &chord in binding.chords {
assert!(
resolve_global(chord).is_some(),
"global chord {chord:?} does not resolve",
);
assert!(
documented.iter().any(|b| b.chords.contains(&chord)),
"global chord {chord:?} is dispatched but not in the overlay",
);
}
}
}
#[test]
fn test_resolve_chain_every_chain_chord_resolves_and_is_documented() {
let live = live_app_on(LiveScreen::Chain, ScreenLoad::Ready, false);
let documented = help_bindings(&live.mode);
for binding in KEYMAP
.iter()
.filter(|b| b.context == Context::Live(LiveScreen::Chain))
{
for &chord in binding.chords {
assert!(
resolve_chain(chord).is_some(),
"chain chord {chord:?} does not resolve",
);
assert!(
documented.iter().any(|b| b.chords.contains(&chord)),
"chain chord {chord:?} is dispatched but not in the overlay",
);
}
}
}
#[test]
fn test_resolve_chain_ignores_non_chain_chords() {
assert_eq!(resolve_chain(KeyChord::Char('q')), None);
assert_eq!(resolve_chain(KeyChord::Esc), None);
assert_eq!(resolve_chain(KeyChord::Char('z')), None);
assert_eq!(
resolve_chain(KeyChord::Char('j')),
Some(ChainAction::MoveStrike),
);
assert_eq!(
resolve_chain(KeyChord::Char('c')),
Some(ChainAction::FocusLeg)
);
}
#[test]
fn test_keymap_marks_deferred_chain_keys_not_the_wired_ones() {
let deferral = |action: ChainAction| -> Option<&'static str> {
KEYMAP
.iter()
.find(|b| b.action == Action::Chain(action))
.and_then(|b| b.deferred)
};
assert_eq!(
deferral(ChainAction::MoveStrike),
None,
"MoveStrike is wired"
);
assert_eq!(deferral(ChainAction::FocusLeg), None, "FocusLeg is wired");
assert_eq!(
deferral(ChainAction::AddLeg),
None,
"AddLeg is wired in #26 (chainโaโbuilder)"
);
assert!(deferral(ChainAction::SwitchExpiry).is_some());
assert!(deferral(ChainAction::SwitchUnderlying).is_some());
assert!(deferral(ChainAction::Drill).is_some());
}
#[test]
fn test_keymap_marks_replay_keys_all_wired() {
let deferral = |action: ReplayAction| -> Option<&'static str> {
KEYMAP
.iter()
.find(|b| b.action == Action::Replay(action))
.and_then(|b| b.deferred)
};
assert_eq!(deferral(ReplayAction::StepBack), None);
assert_eq!(deferral(ReplayAction::StepForward), None);
assert_eq!(deferral(ReplayAction::JumpStart), None);
assert_eq!(deferral(ReplayAction::JumpEnd), None);
assert_eq!(deferral(ReplayAction::PlayPause), None);
assert_eq!(deferral(ReplayAction::SpeedFaster), None);
assert_eq!(deferral(ReplayAction::SpeedSlower), None);
assert_eq!(deferral(ReplayAction::PrevFill), None);
assert_eq!(deferral(ReplayAction::NextFill), None);
}
#[test]
fn test_help_reconnect_scoped_to_live_overlay_only() {
let live = live_app_on(LiveScreen::Chain, ScreenLoad::Ready, false);
let replay = replay_app_on(ReplayScreen::Replay, false);
let is_reconnect = |b: &&Binding| b.action == Action::Global(GlobalAction::Reconnect);
assert!(
help_bindings(&live.mode).iter().any(is_reconnect),
"the live overlay lists Reconnect",
);
assert!(
!help_bindings(&replay.mode).iter().any(is_reconnect),
"the replay overlay omits Reconnect (no-op in replay)",
);
assert_eq!(
resolve_global(KeyChord::Char('r')),
Some(GlobalCommand::Reconnect),
);
}
#[test]
fn test_help_speed_keys_note_while_playing() {
let help_of = |action: ReplayAction| -> &'static str {
KEYMAP
.iter()
.find(|b| b.action == Action::Replay(action))
.map_or("", |b| b.help)
};
assert!(
help_of(ReplayAction::SpeedFaster).contains("playing"),
"faster notes while-playing",
);
assert!(
help_of(ReplayAction::SpeedSlower).contains("playing"),
"slower notes while-playing",
);
}
#[test]
fn test_keymap_deferred_keys_still_resolve_and_are_documented() {
let live = live_app_on(LiveScreen::Chain, ScreenLoad::Ready, false);
let documented = help_bindings(&live.mode);
assert_eq!(
resolve_chain(KeyChord::Enter),
Some(ChainAction::Drill),
"a deferred key still resolves",
);
assert!(
documented
.iter()
.any(|b| b.chords.contains(&KeyChord::Enter)
&& b.context == Context::Live(LiveScreen::Chain)),
"a deferred key stays documented",
);
}
#[test]
fn test_resolve_replay_every_replay_chord_resolves_and_is_documented() {
let replay = replay_app_on(ReplayScreen::Replay, false);
let documented = help_bindings(&replay.mode);
for binding in KEYMAP
.iter()
.filter(|b| b.context == Context::Replay(ReplayScreen::Replay))
{
for &chord in binding.chords {
assert!(
resolve_replay(chord, ReplayScreen::Replay).is_some(),
"replay chord {chord:?} does not resolve",
);
assert!(
documented.iter().any(|b| b.chords.contains(&chord)),
"replay chord {chord:?} is dispatched but not in the overlay",
);
}
}
}
#[test]
fn test_resolve_replay_payoff_every_chord_resolves_and_is_documented() {
let replay = replay_app_on(ReplayScreen::Payoff, false);
let documented = help_bindings(&replay.mode);
let mut chords_seen = 0usize;
for binding in KEYMAP
.iter()
.filter(|b| b.context == Context::Replay(ReplayScreen::Payoff))
{
for &chord in binding.chords {
chords_seen += 1;
assert!(
resolve_replay(chord, ReplayScreen::Payoff).is_some(),
"replay payoff chord {chord:?} does not resolve",
);
assert!(
documented.iter().any(|b| b.chords.contains(&chord)),
"replay payoff chord {chord:?} is dispatched but not in the overlay",
);
}
}
assert!(
chords_seen > 0,
"the payoff panel binds its scrub/playback keys"
);
assert!(
resolve_replay(KeyChord::Char(','), ReplayScreen::Payoff).is_none(),
"`,` (prev fill) is inert on the payoff panel",
);
}
#[test]
fn test_resolve_payoff_every_payoff_chord_resolves_and_is_documented() {
let live = live_app_on(LiveScreen::Payoff, ScreenLoad::Ready, false);
let documented = help_bindings(&live.mode);
for binding in KEYMAP
.iter()
.filter(|b| b.context == Context::Live(LiveScreen::Payoff))
{
for &chord in binding.chords {
assert!(
resolve_payoff(chord).is_some(),
"payoff chord {chord:?} does not resolve",
);
assert!(
documented.iter().any(|b| b.chords.contains(&chord)),
"payoff chord {chord:?} is dispatched but not in the overlay",
);
}
}
}
#[test]
fn test_resolve_payoff_quantity_shares_plus_and_minus_chords() {
assert_eq!(
resolve_payoff(KeyChord::Char('+')),
Some(PayoffAction::Quantity),
);
assert_eq!(
resolve_payoff(KeyChord::Char('-')),
Some(PayoffAction::Quantity),
);
assert_eq!(resolve_payoff(KeyChord::Enter), Some(PayoffAction::Commit));
assert_eq!(resolve_payoff(KeyChord::Esc), Some(PayoffAction::Cancel));
assert_eq!(resolve_payoff(KeyChord::Char('q')), None);
}
#[test]
fn test_resolve_surface_every_surface_chord_resolves_and_is_documented() {
let live = live_app_on(LiveScreen::Surface, ScreenLoad::Ready, false);
let documented = help_bindings(&live.mode);
for binding in KEYMAP
.iter()
.filter(|b| b.context == Context::Live(LiveScreen::Surface))
{
for &chord in binding.chords {
assert!(
resolve_surface(chord).is_some(),
"surface chord {chord:?} does not resolve",
);
assert!(
documented.iter().any(|b| b.chords.contains(&chord)),
"surface chord {chord:?} is dispatched but not in the overlay",
);
}
}
}
#[test]
fn test_resolve_depth_every_depth_chord_resolves_and_is_documented() {
let live = live_app_on(LiveScreen::Depth, ScreenLoad::Ready, false);
let documented = help_bindings(&live.mode);
for binding in KEYMAP
.iter()
.filter(|b| b.context == Context::Live(LiveScreen::Depth))
{
for &chord in binding.chords {
assert!(
resolve_depth(chord).is_some(),
"depth chord {chord:?} does not resolve",
);
assert!(
documented.iter().any(|b| b.chords.contains(&chord)),
"depth chord {chord:?} is dispatched but not in the overlay",
);
}
}
}
#[test]
fn test_resolve_depth_ignores_non_depth_chords_and_binds_scroll() {
assert_eq!(resolve_depth(KeyChord::Up), Some(DepthAction::Scroll));
assert_eq!(resolve_depth(KeyChord::Down), Some(DepthAction::Scroll));
assert_eq!(
resolve_depth(KeyChord::Char('k')),
Some(DepthAction::Scroll),
"k scrolls up (the chain idiom)",
);
assert_eq!(
resolve_depth(KeyChord::Char('j')),
Some(DepthAction::Scroll),
"j scrolls down (the chain idiom)",
);
assert_eq!(resolve_depth(KeyChord::Char('q')), None);
}
#[test]
fn test_keymap_depth_scroll_is_wired_not_deferred() {
let deferred = KEYMAP
.iter()
.find(|b| b.action == Action::Depth(DepthAction::Scroll))
.and_then(|b| b.deferred);
assert_eq!(deferred, None, "depth Scroll is wired in #48");
}
#[test]
fn test_keymap_surface_keys_are_wired_not_deferred() {
for action in [SurfaceAction::CycleGreek, SurfaceAction::ToggleView] {
let deferred = KEYMAP
.iter()
.find(|b| b.action == Action::Surface(action))
.and_then(|b| b.deferred);
assert_eq!(deferred, None, "surface {action:?} is wired in #47");
}
assert_eq!(
resolve_surface(KeyChord::Char('g')),
Some(SurfaceAction::CycleGreek),
);
assert_eq!(
resolve_surface(KeyChord::Char('G')),
Some(SurfaceAction::CycleGreek),
);
assert_eq!(
resolve_surface(KeyChord::Char('x')),
Some(SurfaceAction::ToggleView),
);
assert_eq!(resolve_surface(KeyChord::Char('q')), None);
}
#[test]
fn test_keymap_payoff_builder_keys_are_wired_not_deferred() {
for action in [
PayoffAction::AddLeg,
PayoffAction::RemoveLeg,
PayoffAction::Quantity,
PayoffAction::ToggleSide,
PayoffAction::Commit,
PayoffAction::Cancel,
PayoffAction::ToggleCurve,
] {
let deferred = KEYMAP
.iter()
.find(|b| b.action == Action::Payoff(action))
.and_then(|b| b.deferred);
assert_eq!(deferred, None, "payoff {action:?} is wired in #26");
}
}
#[test]
fn test_resolve_global_switch_screen_binds_the_pressed_slot() {
assert_eq!(
resolve_global(KeyChord::Char('2')),
Some(GlobalCommand::SwitchScreen(2)),
);
assert_eq!(
resolve_global(KeyChord::Tab),
Some(GlobalCommand::NextScreen)
);
assert_eq!(
resolve_global(KeyChord::BackTab),
Some(GlobalCommand::PrevScreen),
);
assert_eq!(
resolve_global(KeyChord::Char('q')),
Some(GlobalCommand::Quit)
);
assert_eq!(
resolve_global(KeyChord::Ctrl('c')),
Some(GlobalCommand::Quit)
);
}
#[test]
fn test_resolve_global_unmapped_key_returns_none() {
assert_eq!(resolve_global(KeyChord::Char('j')), None);
assert_eq!(resolve_global(KeyChord::Esc), None);
assert_eq!(resolve_global(KeyChord::Char('z')), None);
}
#[test]
fn test_key_chord_from_event_normalizes_ctrl_and_named_keys() {
assert_eq!(
KeyChord::from_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
Some(KeyChord::Ctrl('c')),
);
assert_eq!(
KeyChord::from_event(press(KeyCode::Char('?'))),
Some(KeyChord::Char('?')),
);
assert_eq!(
KeyChord::from_event(press(KeyCode::BackTab)),
Some(KeyChord::BackTab),
);
assert_eq!(KeyChord::from_event(press(KeyCode::Up)), Some(KeyChord::Up));
assert!(KeyChord::from_event(press(KeyCode::F(5))).is_none());
}
}