#![forbid(unsafe_code)]
use web_time::{Duration, Instant};
use crate::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
pub const DEFAULT_ESC_SEQ_TIMEOUT_MS: u64 = 250;
pub const MIN_ESC_SEQ_TIMEOUT_MS: u64 = 150;
pub const MAX_ESC_SEQ_TIMEOUT_MS: u64 = 400;
pub const DEFAULT_ESC_DEBOUNCE_MS: u64 = 50;
pub const MIN_ESC_DEBOUNCE_MS: u64 = 0;
pub const MAX_ESC_DEBOUNCE_MS: u64 = 100;
#[derive(Debug, Clone)]
pub struct SequenceConfig {
pub esc_seq_timeout: Duration,
pub esc_debounce: Duration,
pub disable_sequences: bool,
}
impl Default for SequenceConfig {
fn default() -> Self {
Self {
esc_seq_timeout: Duration::from_millis(DEFAULT_ESC_SEQ_TIMEOUT_MS),
esc_debounce: Duration::from_millis(DEFAULT_ESC_DEBOUNCE_MS),
disable_sequences: false,
}
}
}
impl SequenceConfig {
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.esc_seq_timeout = timeout;
self
}
#[must_use]
pub fn with_debounce(mut self, debounce: Duration) -> Self {
self.esc_debounce = debounce;
self
}
#[must_use]
pub fn disable_sequences(mut self) -> Self {
self.disable_sequences = true;
self
}
#[must_use]
pub fn from_env() -> Self {
let mut config = Self::default();
if let Ok(val) = std::env::var("FTUI_ESC_SEQ_TIMEOUT_MS")
&& let Ok(ms) = val.parse::<u64>()
{
config.esc_seq_timeout = Duration::from_millis(ms);
}
if let Ok(val) = std::env::var("FTUI_ESC_DEBOUNCE_MS")
&& let Ok(ms) = val.parse::<u64>()
{
config.esc_debounce = Duration::from_millis(ms);
}
if let Ok(val) = std::env::var("FTUI_DISABLE_ESC_SEQ") {
config.disable_sequences = val == "1" || val.eq_ignore_ascii_case("true");
}
config.validated()
}
#[must_use]
pub fn validated(mut self) -> Self {
let timeout_ms = self.esc_seq_timeout.as_millis() as u64;
let clamped_timeout = timeout_ms.clamp(MIN_ESC_SEQ_TIMEOUT_MS, MAX_ESC_SEQ_TIMEOUT_MS);
self.esc_seq_timeout = Duration::from_millis(clamped_timeout);
let debounce_ms = self.esc_debounce.as_millis() as u64;
let clamped_debounce = debounce_ms.clamp(MIN_ESC_DEBOUNCE_MS, MAX_ESC_DEBOUNCE_MS);
let final_debounce = clamped_debounce.min(clamped_timeout);
self.esc_debounce = Duration::from_millis(final_debounce);
self
}
#[must_use]
pub fn is_valid(&self) -> bool {
let timeout_ms = self.esc_seq_timeout.as_millis() as u64;
let debounce_ms = self.esc_debounce.as_millis() as u64;
(MIN_ESC_SEQ_TIMEOUT_MS..=MAX_ESC_SEQ_TIMEOUT_MS).contains(&timeout_ms)
&& (MIN_ESC_DEBOUNCE_MS..=MAX_ESC_DEBOUNCE_MS).contains(&debounce_ms)
&& debounce_ms <= timeout_ms
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SequenceOutput {
Pending,
Esc,
EscEsc,
PassThrough,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DetectorState {
Idle,
AwaitingSecondEsc { first_esc_time: Instant },
}
#[derive(Debug)]
pub struct SequenceDetector {
config: SequenceConfig,
state: DetectorState,
}
impl SequenceDetector {
#[must_use]
pub fn new(config: SequenceConfig) -> Self {
Self {
config,
state: DetectorState::Idle,
}
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(SequenceConfig::default())
}
pub fn feed(&mut self, event: &KeyEvent, now: Instant) -> SequenceOutput {
if event.kind != KeyEventKind::Press {
return SequenceOutput::PassThrough;
}
if self.config.disable_sequences {
return if event.code == KeyCode::Escape {
SequenceOutput::Esc
} else {
SequenceOutput::PassThrough
};
}
match self.state {
DetectorState::Idle => {
if event.code == KeyCode::Escape {
self.state = DetectorState::AwaitingSecondEsc {
first_esc_time: now,
};
SequenceOutput::Pending
} else {
SequenceOutput::PassThrough
}
}
DetectorState::AwaitingSecondEsc { first_esc_time } => {
let elapsed = now.saturating_duration_since(first_esc_time);
if event.code == KeyCode::Escape {
if elapsed <= self.config.esc_seq_timeout {
self.state = DetectorState::Idle;
SequenceOutput::EscEsc
} else {
self.state = DetectorState::AwaitingSecondEsc {
first_esc_time: now,
};
SequenceOutput::Esc
}
} else {
self.state = DetectorState::Idle;
SequenceOutput::Esc
}
}
}
}
pub fn check_timeout(&mut self, now: Instant) -> Option<SequenceOutput> {
if let DetectorState::AwaitingSecondEsc { first_esc_time } = self.state {
let elapsed = now.saturating_duration_since(first_esc_time);
if elapsed > self.config.esc_seq_timeout {
self.state = DetectorState::Idle;
return Some(SequenceOutput::Esc);
}
}
None
}
#[must_use]
pub fn is_pending(&self) -> bool {
matches!(self.state, DetectorState::AwaitingSecondEsc { .. })
}
pub fn reset(&mut self) {
self.state = DetectorState::Idle;
}
#[must_use]
pub fn config(&self) -> &SequenceConfig {
&self.config
}
pub fn set_config(&mut self, config: SequenceConfig) {
self.config = config;
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AppState {
pub input_nonempty: bool,
pub task_running: bool,
pub modal_open: bool,
pub view_overlay: bool,
}
impl AppState {
#[must_use]
pub const fn new() -> Self {
Self {
input_nonempty: false,
task_running: false,
modal_open: false,
view_overlay: false,
}
}
#[must_use]
pub const fn with_input(mut self, nonempty: bool) -> Self {
self.input_nonempty = nonempty;
self
}
#[must_use]
pub const fn with_task(mut self, running: bool) -> Self {
self.task_running = running;
self
}
#[must_use]
pub const fn with_modal(mut self, open: bool) -> Self {
self.modal_open = open;
self
}
#[must_use]
pub const fn with_overlay(mut self, active: bool) -> Self {
self.view_overlay = active;
self
}
#[must_use]
pub const fn is_idle(&self) -> bool {
!self.input_nonempty && !self.task_running && !self.modal_open
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action {
ClearInput,
CancelTask,
DismissModal,
CloseOverlay,
ToggleTreeView,
Quit,
SoftQuit,
HardQuit,
Bell,
PassThrough,
}
impl Action {
#[must_use]
pub const fn consumes_event(&self) -> bool {
!matches!(self, Action::PassThrough)
}
#[must_use]
pub const fn is_quit(&self) -> bool {
matches!(self, Action::Quit | Action::SoftQuit | Action::HardQuit)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum CtrlCIdleAction {
#[default]
Quit,
Noop,
Bell,
}
impl CtrlCIdleAction {
#[must_use]
pub fn from_str_opt(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"quit" => Some(Self::Quit),
"noop" | "none" | "ignore" => Some(Self::Noop),
"bell" | "beep" => Some(Self::Bell),
_ => None,
}
}
#[must_use]
pub const fn to_action(self) -> Option<Action> {
match self {
Self::Quit => Some(Action::Quit),
Self::Noop => None,
Self::Bell => Some(Action::Bell),
}
}
}
#[derive(Debug, Clone)]
pub struct ActionConfig {
pub sequence_config: SequenceConfig,
pub ctrl_c_idle_action: CtrlCIdleAction,
}
impl Default for ActionConfig {
fn default() -> Self {
Self {
sequence_config: SequenceConfig::default(),
ctrl_c_idle_action: CtrlCIdleAction::Quit,
}
}
}
impl ActionConfig {
#[must_use]
pub fn with_sequence_config(mut self, config: SequenceConfig) -> Self {
self.sequence_config = config;
self
}
#[must_use]
pub fn with_ctrl_c_idle(mut self, action: CtrlCIdleAction) -> Self {
self.ctrl_c_idle_action = action;
self
}
#[must_use]
pub fn from_env() -> Self {
let mut config = Self {
sequence_config: SequenceConfig::from_env(),
ctrl_c_idle_action: CtrlCIdleAction::Quit,
};
if let Ok(val) = std::env::var("FTUI_CTRL_C_IDLE_ACTION")
&& let Some(action) = CtrlCIdleAction::from_str_opt(&val)
{
config.ctrl_c_idle_action = action;
}
config
}
#[must_use]
pub fn validated(mut self) -> Self {
self.sequence_config = self.sequence_config.validated();
self
}
}
#[derive(Debug)]
pub struct ActionMapper {
config: ActionConfig,
sequence_detector: SequenceDetector,
}
impl ActionMapper {
#[must_use]
pub fn new(config: ActionConfig) -> Self {
let sequence_detector = SequenceDetector::new(config.sequence_config.clone());
Self {
config,
sequence_detector,
}
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(ActionConfig::default())
}
#[must_use]
pub fn from_env() -> Self {
Self::new(ActionConfig::from_env())
}
pub fn map(&mut self, event: &KeyEvent, state: &AppState, now: Instant) -> Option<Action> {
if event.kind != KeyEventKind::Press {
return Some(Action::PassThrough);
}
if event.modifiers.contains(Modifiers::CTRL)
&& let KeyCode::Char(c) = event.code
{
match c.to_ascii_lowercase() {
'c' => return self.resolve_ctrl_c(state),
'd' => return Some(Action::SoftQuit),
'q' => return Some(Action::HardQuit),
_ => {}
}
}
if event.code == KeyCode::Escape && event.modifiers == Modifiers::NONE {
return self.handle_esc_sequence(state, now);
}
let seq_output = self.sequence_detector.feed(event, now);
match seq_output {
SequenceOutput::Esc => {
self.resolve_single_esc(state)
}
SequenceOutput::Pending => {
Some(Action::PassThrough)
}
SequenceOutput::EscEsc => {
Some(Action::ToggleTreeView)
}
SequenceOutput::PassThrough => Some(Action::PassThrough),
}
}
fn handle_esc_sequence(&mut self, state: &AppState, now: Instant) -> Option<Action> {
let esc_event = KeyEvent::new(KeyCode::Escape);
let output = self.sequence_detector.feed(&esc_event, now);
match output {
SequenceOutput::Pending => {
None
}
SequenceOutput::Esc => {
self.resolve_single_esc(state)
}
SequenceOutput::EscEsc => {
Some(Action::ToggleTreeView)
}
SequenceOutput::PassThrough => {
Some(Action::PassThrough)
}
}
}
fn resolve_ctrl_c(&self, state: &AppState) -> Option<Action> {
if state.modal_open {
return Some(Action::DismissModal);
}
if state.input_nonempty {
return Some(Action::ClearInput);
}
if state.task_running {
return Some(Action::CancelTask);
}
self.config.ctrl_c_idle_action.to_action()
}
fn resolve_single_esc(&self, state: &AppState) -> Option<Action> {
if state.modal_open {
return Some(Action::DismissModal);
}
if state.view_overlay {
return Some(Action::CloseOverlay);
}
if state.input_nonempty {
return Some(Action::ClearInput);
}
if state.task_running {
return Some(Action::CancelTask);
}
Some(Action::PassThrough)
}
pub fn check_timeout(&mut self, state: &AppState, now: Instant) -> Option<Action> {
if let Some(SequenceOutput::Esc) = self.sequence_detector.check_timeout(now) {
return self.resolve_single_esc(state);
}
None
}
#[must_use]
pub fn is_pending_esc(&self) -> bool {
self.sequence_detector.is_pending()
}
pub fn reset(&mut self) {
self.sequence_detector.reset();
}
#[must_use]
pub fn config(&self) -> &ActionConfig {
&self.config
}
pub fn set_config(&mut self, config: ActionConfig) {
self.sequence_detector
.set_config(config.sequence_config.clone());
self.config = config;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn now() -> Instant {
Instant::now()
}
fn esc_press() -> KeyEvent {
KeyEvent::new(KeyCode::Escape)
}
fn key_press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code)
}
fn esc_release() -> KeyEvent {
KeyEvent::new(KeyCode::Escape).with_kind(KeyEventKind::Release)
}
const MS_50: Duration = Duration::from_millis(50);
const MS_100: Duration = Duration::from_millis(100);
const MS_200: Duration = Duration::from_millis(200);
const MS_300: Duration = Duration::from_millis(300);
#[test]
fn single_esc_returns_pending() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
let output = detector.feed(&esc_press(), t);
assert_eq!(output, SequenceOutput::Pending);
assert!(detector.is_pending());
}
#[test]
fn esc_esc_within_timeout() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&esc_press(), t + MS_100);
assert_eq!(output, SequenceOutput::EscEsc);
assert!(!detector.is_pending());
}
#[test]
fn esc_esc_at_timeout_boundary() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&esc_press(), t + Duration::from_millis(250));
assert_eq!(output, SequenceOutput::EscEsc);
}
#[test]
fn esc_esc_past_timeout() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&esc_press(), t + Duration::from_millis(251));
assert_eq!(output, SequenceOutput::Esc);
assert!(detector.is_pending()); }
#[test]
fn timeout_check_emits_pending_esc() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
assert!(detector.check_timeout(t + MS_200).is_none());
assert!(detector.is_pending());
let output = detector.check_timeout(t + Duration::from_millis(251));
assert_eq!(output, Some(SequenceOutput::Esc));
assert!(!detector.is_pending());
}
#[test]
fn other_key_interrupts_sequence() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&key_press(KeyCode::Char('a')), t + MS_100);
assert_eq!(output, SequenceOutput::Esc);
assert!(!detector.is_pending());
}
#[test]
fn non_esc_key_passes_through() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
let output = detector.feed(&key_press(KeyCode::Char('x')), t);
assert_eq!(output, SequenceOutput::PassThrough);
}
#[test]
fn release_event_passes_through() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
let output = detector.feed(&esc_release(), t);
assert_eq!(output, SequenceOutput::PassThrough);
assert!(!detector.is_pending());
}
#[test]
fn release_during_pending_passes_through() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&esc_release(), t + MS_50);
assert_eq!(output, SequenceOutput::PassThrough);
assert!(detector.is_pending());
}
#[test]
fn custom_timeout() {
let config = SequenceConfig::default().with_timeout(Duration::from_millis(100));
let mut detector = SequenceDetector::new(config);
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&esc_press(), t + Duration::from_millis(150));
assert_eq!(output, SequenceOutput::Esc);
}
#[test]
fn disabled_sequences() {
let config = SequenceConfig::default().disable_sequences();
let mut detector = SequenceDetector::new(config);
let t = now();
let output = detector.feed(&esc_press(), t);
assert_eq!(output, SequenceOutput::Esc);
assert!(!detector.is_pending());
let output = detector.feed(&esc_press(), t + MS_50);
assert_eq!(output, SequenceOutput::Esc);
}
#[test]
fn disabled_sequences_passthrough() {
let config = SequenceConfig::default().disable_sequences();
let mut detector = SequenceDetector::new(config);
let t = now();
let output = detector.feed(&key_press(KeyCode::Char('a')), t);
assert_eq!(output, SequenceOutput::PassThrough);
}
#[test]
fn config_default_values() {
let config = SequenceConfig::default();
assert_eq!(config.esc_seq_timeout, Duration::from_millis(250));
assert_eq!(config.esc_debounce, Duration::from_millis(50));
assert!(!config.disable_sequences);
}
#[test]
fn config_builder_chain() {
let config = SequenceConfig::default()
.with_timeout(Duration::from_millis(300))
.with_debounce(Duration::from_millis(100))
.disable_sequences();
assert_eq!(config.esc_seq_timeout, Duration::from_millis(300));
assert_eq!(config.esc_debounce, Duration::from_millis(100));
assert!(config.disable_sequences);
}
#[test]
fn reset_clears_pending() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
assert!(detector.is_pending());
detector.reset();
assert!(!detector.is_pending());
let output = detector.feed(&esc_press(), t + MS_100);
assert_eq!(output, SequenceOutput::Pending);
}
#[test]
fn reset_discards_pending_esc() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
detector.reset();
assert!(detector.check_timeout(t + MS_300).is_none());
}
#[test]
fn rapid_triple_esc() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
let out1 = detector.feed(&esc_press(), t);
assert_eq!(out1, SequenceOutput::Pending);
let out2 = detector.feed(&esc_press(), t + MS_50);
assert_eq!(out2, SequenceOutput::EscEsc);
let out3 = detector.feed(&esc_press(), t + MS_100);
assert_eq!(out3, SequenceOutput::Pending);
}
#[test]
fn alternating_esc_and_key() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let out1 = detector.feed(&key_press(KeyCode::Char('a')), t + MS_50);
assert_eq!(out1, SequenceOutput::Esc);
let out2 = detector.feed(&esc_press(), t + MS_100);
assert_eq!(out2, SequenceOutput::Pending);
let out3 = detector.feed(&key_press(KeyCode::Char('b')), t + MS_200);
assert_eq!(out3, SequenceOutput::Esc);
}
#[test]
fn enter_key_interrupts() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&key_press(KeyCode::Enter), t + MS_100);
assert_eq!(output, SequenceOutput::Esc);
}
#[test]
fn function_key_interrupts() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&key_press(KeyCode::F(1)), t + MS_100);
assert_eq!(output, SequenceOutput::Esc);
}
#[test]
fn arrow_key_interrupts() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&key_press(KeyCode::Up), t + MS_100);
assert_eq!(output, SequenceOutput::Esc);
}
#[test]
fn config_getter_and_setter() {
let mut detector = SequenceDetector::with_defaults();
assert_eq!(
detector.config().esc_seq_timeout,
Duration::from_millis(250)
);
let new_config = SequenceConfig::default().with_timeout(Duration::from_millis(500));
detector.set_config(new_config);
assert_eq!(
detector.config().esc_seq_timeout,
Duration::from_millis(500)
);
}
#[test]
fn set_config_preserves_pending_state() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
assert!(detector.is_pending());
detector.set_config(SequenceConfig::default().with_timeout(Duration::from_millis(500)));
assert!(detector.is_pending());
let output = detector.feed(&esc_press(), t + MS_300);
assert_eq!(output, SequenceOutput::EscEsc); }
#[test]
fn debug_format() {
let detector = SequenceDetector::with_defaults();
let dbg = format!("{:?}", detector);
assert!(dbg.contains("SequenceDetector"));
}
#[test]
fn config_debug_format() {
let config = SequenceConfig::default();
let dbg = format!("{:?}", config);
assert!(dbg.contains("SequenceConfig"));
}
#[test]
fn output_debug_and_eq() {
assert_eq!(SequenceOutput::Pending, SequenceOutput::Pending);
assert_eq!(SequenceOutput::Esc, SequenceOutput::Esc);
assert_eq!(SequenceOutput::EscEsc, SequenceOutput::EscEsc);
assert_eq!(SequenceOutput::PassThrough, SequenceOutput::PassThrough);
assert_ne!(SequenceOutput::Esc, SequenceOutput::EscEsc);
let dbg = format!("{:?}", SequenceOutput::EscEsc);
assert!(dbg.contains("EscEsc"));
}
#[test]
fn no_stuck_state() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
for i in 0..100 {
let offset = Duration::from_millis(i * 10);
if i % 3 == 0 {
detector.feed(&esc_press(), t + offset);
} else {
detector.feed(&key_press(KeyCode::Char('x')), t + offset);
}
}
detector.check_timeout(t + Duration::from_secs(2));
assert!(!detector.is_pending());
}
#[test]
fn deterministic_output() {
let config = SequenceConfig::default();
let t = now();
let mut d1 = SequenceDetector::new(config.clone());
let mut d2 = SequenceDetector::new(config);
let events = [
(esc_press(), t),
(esc_press(), t + MS_100),
(key_press(KeyCode::Char('a')), t + MS_200),
(esc_press(), t + MS_300),
];
for (event, time) in &events {
let out1 = d1.feed(event, *time);
let out2 = d2.feed(event, *time);
assert_eq!(out1, out2);
}
}
mod action_mapper_tests {
use super::*;
use crate::event::Modifiers;
fn ctrl_c() -> KeyEvent {
KeyEvent::new(KeyCode::Char('c')).with_modifiers(Modifiers::CTRL)
}
fn ctrl_d() -> KeyEvent {
KeyEvent::new(KeyCode::Char('d')).with_modifiers(Modifiers::CTRL)
}
fn ctrl_q() -> KeyEvent {
KeyEvent::new(KeyCode::Char('q')).with_modifiers(Modifiers::CTRL)
}
fn idle_state() -> AppState {
AppState::default()
}
fn input_state() -> AppState {
AppState::new().with_input(true)
}
fn task_state() -> AppState {
AppState::new().with_task(true)
}
fn modal_state() -> AppState {
AppState::new().with_modal(true)
}
fn overlay_state() -> AppState {
AppState::new().with_overlay(true)
}
#[test]
fn test_ctrl_c_clears_nonempty_input() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_c(), &input_state(), t);
assert_eq!(action, Some(Action::ClearInput));
}
#[test]
fn test_ctrl_c_cancels_running_task() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_c(), &task_state(), t);
assert_eq!(action, Some(Action::CancelTask));
}
#[test]
fn test_ctrl_c_quits_when_idle() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_c(), &idle_state(), t);
assert_eq!(action, Some(Action::Quit));
}
#[test]
fn test_ctrl_c_dismisses_modal() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_c(), &modal_state(), t);
assert_eq!(action, Some(Action::DismissModal));
}
#[test]
fn test_ctrl_c_modal_priority_over_input() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let state = AppState::new().with_modal(true).with_input(true);
let action = mapper.map(&ctrl_c(), &state, t);
assert_eq!(action, Some(Action::DismissModal));
}
#[test]
fn test_ctrl_c_input_priority_over_task() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let state = AppState::new().with_input(true).with_task(true);
let action = mapper.map(&ctrl_c(), &state, t);
assert_eq!(action, Some(Action::ClearInput));
}
#[test]
fn test_ctrl_c_idle_config_noop() {
let config = ActionConfig::default().with_ctrl_c_idle(CtrlCIdleAction::Noop);
let mut mapper = ActionMapper::new(config);
let t = now();
let action = mapper.map(&ctrl_c(), &idle_state(), t);
assert_eq!(action, None); }
#[test]
fn test_ctrl_c_idle_config_bell() {
let config = ActionConfig::default().with_ctrl_c_idle(CtrlCIdleAction::Bell);
let mut mapper = ActionMapper::new(config);
let t = now();
let action = mapper.map(&ctrl_c(), &idle_state(), t);
assert_eq!(action, Some(Action::Bell));
}
#[test]
fn test_ctrl_d_soft_quit() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_d(), &idle_state(), t);
assert_eq!(action, Some(Action::SoftQuit));
}
#[test]
fn test_ctrl_d_ignores_state() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_d(), &modal_state(), t);
assert_eq!(action, Some(Action::SoftQuit));
let action = mapper.map(&ctrl_d(), &input_state(), t);
assert_eq!(action, Some(Action::SoftQuit));
}
#[test]
fn test_ctrl_q_hard_quit() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_q(), &idle_state(), t);
assert_eq!(action, Some(Action::HardQuit));
}
#[test]
fn test_ctrl_q_ignores_state() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_q(), &modal_state(), t);
assert_eq!(action, Some(Action::HardQuit));
}
#[test]
fn test_esc_dismisses_modal() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action1 = mapper.map(&esc_press(), &modal_state(), t);
assert_eq!(action1, None);
let action2 = mapper.check_timeout(&modal_state(), t + MS_300);
assert_eq!(action2, Some(Action::DismissModal));
}
#[test]
fn test_esc_clears_input_no_modal() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &input_state(), t);
let action = mapper.check_timeout(&input_state(), t + MS_300);
assert_eq!(action, Some(Action::ClearInput));
}
#[test]
fn test_esc_cancels_task_empty_input() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &task_state(), t);
let action = mapper.check_timeout(&task_state(), t + MS_300);
assert_eq!(action, Some(Action::CancelTask));
}
#[test]
fn test_esc_closes_overlay() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &overlay_state(), t);
let action = mapper.check_timeout(&overlay_state(), t + MS_300);
assert_eq!(action, Some(Action::CloseOverlay));
}
#[test]
fn test_esc_modal_priority_over_overlay() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let state = AppState::new().with_modal(true).with_overlay(true);
mapper.map(&esc_press(), &state, t);
let action = mapper.check_timeout(&state, t + MS_300);
assert_eq!(action, Some(Action::DismissModal));
}
#[test]
fn test_esc_passthrough_when_idle() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &idle_state(), t);
let action = mapper.check_timeout(&idle_state(), t + MS_300);
assert_eq!(action, Some(Action::PassThrough));
}
#[test]
fn test_esc_esc_within_timeout() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &idle_state(), t);
let action = mapper.map(&esc_press(), &idle_state(), t + MS_100);
assert_eq!(action, Some(Action::ToggleTreeView));
}
#[test]
fn test_esc_esc_ignores_state() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &modal_state(), t);
let action = mapper.map(&esc_press(), &modal_state(), t + MS_100);
assert_eq!(action, Some(Action::ToggleTreeView));
}
#[test]
fn test_esc_esc_timeout_expired() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &input_state(), t);
let action = mapper.map(&esc_press(), &input_state(), t + MS_300);
assert_eq!(action, Some(Action::ClearInput));
assert!(mapper.is_pending_esc());
}
#[test]
fn test_esc_then_other_key() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &input_state(), t);
let action = mapper.map(&key_press(KeyCode::Char('a')), &input_state(), t + MS_50);
assert_eq!(action, Some(Action::ClearInput));
}
#[test]
fn test_regular_key_passthrough() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&key_press(KeyCode::Char('x')), &idle_state(), t);
assert_eq!(action, Some(Action::PassThrough));
}
#[test]
fn test_release_event_passthrough() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let release = KeyEvent::new(KeyCode::Char('x')).with_kind(KeyEventKind::Release);
let action = mapper.map(&release, &idle_state(), t);
assert_eq!(action, Some(Action::PassThrough));
}
#[test]
fn test_app_state_builders() {
let state = AppState::new()
.with_input(true)
.with_task(true)
.with_modal(true)
.with_overlay(true);
assert!(state.input_nonempty);
assert!(state.task_running);
assert!(state.modal_open);
assert!(state.view_overlay);
assert!(!state.is_idle());
}
#[test]
fn test_app_state_is_idle() {
assert!(AppState::default().is_idle());
assert!(!AppState::new().with_input(true).is_idle());
assert!(!AppState::new().with_task(true).is_idle());
assert!(!AppState::new().with_modal(true).is_idle());
assert!(AppState::new().with_overlay(true).is_idle());
}
#[test]
fn test_action_consumes_event() {
assert!(Action::ClearInput.consumes_event());
assert!(Action::CancelTask.consumes_event());
assert!(Action::Quit.consumes_event());
assert!(!Action::PassThrough.consumes_event());
}
#[test]
fn test_action_is_quit() {
assert!(Action::Quit.is_quit());
assert!(Action::SoftQuit.is_quit());
assert!(Action::HardQuit.is_quit());
assert!(!Action::ClearInput.is_quit());
assert!(!Action::PassThrough.is_quit());
}
#[test]
fn test_ctrl_c_idle_action_from_str() {
assert_eq!(
CtrlCIdleAction::from_str_opt("quit"),
Some(CtrlCIdleAction::Quit)
);
assert_eq!(
CtrlCIdleAction::from_str_opt("QUIT"),
Some(CtrlCIdleAction::Quit)
);
assert_eq!(
CtrlCIdleAction::from_str_opt("noop"),
Some(CtrlCIdleAction::Noop)
);
assert_eq!(
CtrlCIdleAction::from_str_opt("none"),
Some(CtrlCIdleAction::Noop)
);
assert_eq!(
CtrlCIdleAction::from_str_opt("ignore"),
Some(CtrlCIdleAction::Noop)
);
assert_eq!(
CtrlCIdleAction::from_str_opt("bell"),
Some(CtrlCIdleAction::Bell)
);
assert_eq!(
CtrlCIdleAction::from_str_opt("beep"),
Some(CtrlCIdleAction::Bell)
);
assert_eq!(CtrlCIdleAction::from_str_opt("invalid"), None);
}
#[test]
fn test_ctrl_c_idle_action_to_action() {
assert_eq!(CtrlCIdleAction::Quit.to_action(), Some(Action::Quit));
assert_eq!(CtrlCIdleAction::Noop.to_action(), None);
assert_eq!(CtrlCIdleAction::Bell.to_action(), Some(Action::Bell));
}
#[test]
fn test_action_config_builder() {
let config = ActionConfig::default()
.with_sequence_config(SequenceConfig::default().with_timeout(MS_100))
.with_ctrl_c_idle(CtrlCIdleAction::Bell);
assert_eq!(config.sequence_config.esc_seq_timeout, MS_100);
assert_eq!(config.ctrl_c_idle_action, CtrlCIdleAction::Bell);
}
#[test]
fn test_mapper_reset() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &idle_state(), t);
assert!(mapper.is_pending_esc());
mapper.reset();
assert!(!mapper.is_pending_esc());
}
#[test]
fn test_deterministic_action_mapping() {
let t = now();
let mut m1 = ActionMapper::with_defaults();
let mut m2 = ActionMapper::with_defaults();
let events = [
(ctrl_c(), input_state()),
(ctrl_d(), modal_state()),
(ctrl_q(), idle_state()),
];
for (event, state) in &events {
let a1 = m1.map(event, state, t);
let a2 = m2.map(event, state, t);
assert_eq!(a1, a2);
}
}
#[test]
fn test_uppercase_ctrl_keys() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let ctrl_c_upper = KeyEvent::new(KeyCode::Char('C')).with_modifiers(Modifiers::CTRL);
let action = mapper.map(&ctrl_c_upper, &idle_state(), t);
assert_eq!(action, Some(Action::Quit));
}
#[test]
fn test_sequence_config_validation_clamps_high_timeout() {
let config = SequenceConfig::default()
.with_timeout(Duration::from_millis(1000)) .validated();
assert_eq!(config.esc_seq_timeout.as_millis(), 400);
}
#[test]
fn test_sequence_config_validation_clamps_low_timeout() {
let config = SequenceConfig::default()
.with_timeout(Duration::from_millis(50)) .validated();
assert_eq!(config.esc_seq_timeout.as_millis(), 150);
}
#[test]
fn test_sequence_config_validation_clamps_high_debounce() {
let config = SequenceConfig::default()
.with_debounce(Duration::from_millis(200)) .validated();
assert_eq!(config.esc_debounce.as_millis(), 100);
}
#[test]
fn test_sequence_config_validation_debounce_not_exceeds_timeout() {
let config = SequenceConfig::default()
.with_timeout(Duration::from_millis(150))
.with_debounce(Duration::from_millis(200)) .validated();
assert!(config.esc_debounce <= config.esc_seq_timeout);
}
#[test]
fn test_sequence_config_is_valid() {
assert!(SequenceConfig::default().is_valid());
let invalid = SequenceConfig::default().with_timeout(Duration::from_millis(500));
assert!(!invalid.is_valid());
assert!(invalid.validated().is_valid());
}
#[test]
fn test_sequence_config_constants() {
assert_eq!(DEFAULT_ESC_SEQ_TIMEOUT_MS, 250);
assert_eq!(MIN_ESC_SEQ_TIMEOUT_MS, 150);
assert_eq!(MAX_ESC_SEQ_TIMEOUT_MS, 400);
assert_eq!(DEFAULT_ESC_DEBOUNCE_MS, 50);
assert_eq!(MIN_ESC_DEBOUNCE_MS, 0);
assert_eq!(MAX_ESC_DEBOUNCE_MS, 100);
}
#[test]
fn test_action_config_validated() {
let config = ActionConfig::default()
.with_sequence_config(
SequenceConfig::default().with_timeout(Duration::from_millis(1000)),
)
.validated();
assert_eq!(config.sequence_config.esc_seq_timeout.as_millis(), 400);
}
}
}