diff --git a/src/components/diff.rs b/src/components/diff.rs index 04779caada..f7285ffb95 100644 --- a/src/components/diff.rs +++ b/src/components/diff.rs @@ -870,9 +870,14 @@ impl Component for DiffComponent { Ok(EventState::Consumed) } else if key_match(e, self.key_config.keys.move_left) { - self.horizontal_scroll - .move_right(HorizontalScrollType::Left); - Ok(EventState::Consumed) + if self + .horizontal_scroll + .move_right(HorizontalScrollType::Left) + { + Ok(EventState::Consumed) + } else { + Ok(EventState::NotConsumed) + } } else if key_match( e, self.key_config.keys.diff_hunk_next, diff --git a/src/keys/key_repeat_guard.rs b/src/keys/key_repeat_guard.rs new file mode 100644 index 0000000000..ff14169317 --- /dev/null +++ b/src/keys/key_repeat_guard.rs @@ -0,0 +1,90 @@ +//! Debounce arrow-key edge navigation against key autorepeat. + +use super::key_list::GituiKeyEvent; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::{ + collections::HashMap, + time::{Duration, Instant}, +}; + +const DEFAULT_COOLDOWN: Duration = Duration::from_millis(500); + +#[derive(Hash, Eq, PartialEq, Clone, Copy)] +struct KeyId { + code: KeyCode, + modifiers: KeyModifiers, +} + +impl From for KeyId { + fn from(key: GituiKeyEvent) -> Self { + Self { + code: key.code, + modifiers: key.modifiers, + } + } +} + +/// +pub struct KeyRepeatGuard { + last: HashMap, + cooldown: Duration, +} + +impl KeyRepeatGuard { + /// + pub fn new() -> Self { + Self { + last: HashMap::new(), + cooldown: DEFAULT_COOLDOWN, + } + } + + #[cfg(test)] + pub fn with_cooldown(cooldown: Duration) -> Self { + Self { + last: HashMap::new(), + cooldown, + } + } + + /// + pub fn record(&mut self, key: GituiKeyEvent) { + self.last.insert(key.into(), Instant::now()); + } + + /// + pub fn record_key_event(&mut self, key: &KeyEvent) { + self.record(GituiKeyEvent { + code: key.code, + modifiers: key.modifiers, + }); + } + + /// Whether edge navigation (leaving a scrollable view) should run now. + pub fn allow_edge_navigation(&self, key: GituiKeyEvent) -> bool { + self.last + .get(&key.into()) + .is_none_or(|t| Instant::now().duration_since(*t) >= self.cooldown) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyModifiers}; + use std::thread::sleep; + + #[test] + fn test_blocks_rapid_repeats() { + let mut guard = KeyRepeatGuard::with_cooldown(Duration::from_millis(50)); + let key = + GituiKeyEvent::new(KeyCode::Up, KeyModifiers::empty()); + + assert!(guard.allow_edge_navigation(key)); + guard.record(key); + assert!(!guard.allow_edge_navigation(key)); + + sleep(Duration::from_millis(60)); + assert!(guard.allow_edge_navigation(key)); + } +} diff --git a/src/keys/mod.rs b/src/keys/mod.rs index a770087fcf..9180dd9074 100644 --- a/src/keys/mod.rs +++ b/src/keys/mod.rs @@ -1,6 +1,8 @@ mod key_config; mod key_list; +mod key_repeat_guard; mod symbols; pub use key_config::{KeyConfig, SharedKeyConfig}; pub use key_list::key_match; +pub use key_repeat_guard::KeyRepeatGuard; diff --git a/src/tabs/status.rs b/src/tabs/status.rs index 135cf18e4e..4eca1de507 100644 --- a/src/tabs/status.rs +++ b/src/tabs/status.rs @@ -7,7 +7,7 @@ use crate::{ DiffComponent, DrawableComponent, EventState, FileTreeItemKind, }, - keys::{key_match, SharedKeyConfig}, + keys::{key_match, KeyRepeatGuard, SharedKeyConfig}, options::SharedOptions, queue::{Action, InternalEvent, NeedsUpdate, Queue, ResetItem}, strings, try_or_popup, @@ -81,6 +81,7 @@ pub struct Status { git_action_executed: bool, options: SharedOptions, key_config: SharedKeyConfig, + key_repeat_guard: KeyRepeatGuard, } impl DrawableComponent for Status { @@ -198,6 +199,7 @@ impl Status { env.repo.clone(), ), key_config: env.key_config.clone(), + key_repeat_guard: KeyRepeatGuard::new(), options: env.options.clone(), repo: env.repo.clone(), } @@ -581,6 +583,17 @@ impl Status { } } + fn record_navigation_key(&mut self, key: &crossterm::event::KeyEvent) { + let keys = &self.key_config.keys; + if key_match(key, keys.move_up) + || key_match(key, keys.move_down) + || key_match(key, keys.move_left) + || key_match(key, keys.move_right) + { + self.key_repeat_guard.record_key_event(key); + } + } + fn fetch(&self) { if self.can_fetch() { self.queue.push(InternalEvent::FetchRemotes); @@ -820,6 +833,9 @@ impl Component for Status { if event_pump(ev, self.components_mut().as_mut_slice())? .is_consumed() { + if let Event::Key(k) = ev { + self.record_navigation_key(k); + } self.git_action_executed = true; return Ok(EventState::Consumed); } @@ -845,6 +861,25 @@ impl Component for Status { ) && self.can_focus_diff() { self.switch_focus(Focus::Diff).map(Into::into) + } else if key_match( + k, + self.key_config.keys.move_left, + ) && self.is_focus_on_diff() + { + let binding = self.key_config.keys.move_left; + let allow = self + .key_repeat_guard + .allow_edge_navigation(binding); + self.key_repeat_guard.record(binding); + if allow { + return self + .switch_focus(match self.diff_target { + DiffTarget::Stage => Focus::Stage, + DiffTarget::WorkingDir => Focus::WorkDir, + }) + .map(Into::into); + } + return Ok(EventState::Consumed); } else if key_match( k, self.key_config.keys.exit_popup, @@ -858,12 +893,32 @@ impl Component for Status { && self.focus == Focus::WorkDir && !self.index.is_empty() { - self.switch_focus(Focus::Stage).map(Into::into) + let binding = self.key_config.keys.move_down; + let allow = self + .key_repeat_guard + .allow_edge_navigation(binding); + self.key_repeat_guard.record(binding); + if allow { + return self + .switch_focus(Focus::Stage) + .map(Into::into); + } + return Ok(EventState::Consumed); } else if key_match(k, self.key_config.keys.move_up) && self.focus == Focus::Stage && !self.index_wd.is_empty() { - self.switch_focus(Focus::WorkDir).map(Into::into) + let binding = self.key_config.keys.move_up; + let allow = self + .key_repeat_guard + .allow_edge_navigation(binding); + self.key_repeat_guard.record(binding); + if allow { + return self + .switch_focus(Focus::WorkDir) + .map(Into::into); + } + return Ok(EventState::Consumed); } else if key_match( k, self.key_config.keys.select_branch,