mirror of
https://github.com/gitui-org/gitui
synced 2026-05-23 17:08:21 +00:00
feat(status): debounce arrow-key edge navigation (#2898)
Add KeyRepeatGuard so panel switches at scroll edges only fire if the same arrow key was idle for 500ms, reducing accidental jumps from autorepeat. Applies to status tab focus changes and diff horizontal exit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8619c07f3f
commit
d7dddc7b64
4 changed files with 158 additions and 6 deletions
|
|
@ -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,
|
||||
|
|
|
|||
90
src/keys/key_repeat_guard.rs
Normal file
90
src/keys/key_repeat_guard.rs
Normal file
|
|
@ -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<GituiKeyEvent> for KeyId {
|
||||
fn from(key: GituiKeyEvent) -> Self {
|
||||
Self {
|
||||
code: key.code,
|
||||
modifiers: key.modifiers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
pub struct KeyRepeatGuard {
|
||||
last: HashMap<KeyId, Instant>,
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue