Skip to content

Commit a95bea9

Browse files
committed
Add vim-like prefix support for navigation
1 parent 0d75a3c commit a95bea9

File tree

7 files changed

+330
-31
lines changed

7 files changed

+330
-31
lines changed

src/app.rs

Lines changed: 204 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::collections::HashMap;
22

33
use ratatui::{
44
backend::Backend,
5+
crossterm::event::{KeyCode, KeyEvent},
56
layout::{Constraint, Layout, Rect},
67
style::{Modifier, Style, Stylize},
78
text::Line,
@@ -12,7 +13,7 @@ use ratatui::{
1213
use crate::{
1314
color::{ColorTheme, GraphColorSet},
1415
config::{CoreConfig, CursorType, UiConfig},
15-
event::{AppEvent, Receiver, Sender, UserEvent},
16+
event::{AppEvent, Receiver, Sender, UserEvent, UserEventWithCount},
1617
external::copy_to_clipboard,
1718
git::Repository,
1819
graph::{CellWidthType, Graph, GraphImageManager},
@@ -43,6 +44,7 @@ pub struct App<'a> {
4344
color_theme: &'a ColorTheme,
4445
image_protocol: ImageProtocol,
4546
tx: Sender,
47+
numeric_prefix: String,
4648
}
4749

4850
impl<'a> App<'a> {
@@ -99,6 +101,7 @@ impl<'a> App<'a> {
99101
color_theme,
100102
image_protocol,
101103
tx,
104+
numeric_prefix: String::new(),
102105
}
103106
}
104107
}
@@ -132,13 +135,31 @@ impl App<'_> {
132135

133136
match self.keybind.get(&key) {
134137
Some(UserEvent::ForceQuit) => {
138+
self.numeric_prefix.clear();
135139
self.tx.send(AppEvent::Quit);
136140
}
137141
Some(ue) => {
138-
self.view.handle_event(*ue, key);
142+
let event_with_count = self.process_numeric_prefix(*ue, key);
143+
if let Some(event_with_count) = event_with_count {
144+
self.view.handle_event_with_count(event_with_count, key);
145+
self.numeric_prefix.clear();
146+
}
139147
}
140148
None => {
141-
self.view.handle_event(UserEvent::Unknown, key);
149+
if let KeyCode::Char(c) = key.code {
150+
if c.is_ascii_digit()
151+
&& (c != '0' || !self.numeric_prefix.is_empty())
152+
{
153+
self.numeric_prefix.push(c);
154+
continue;
155+
}
156+
}
157+
158+
self.numeric_prefix.clear();
159+
self.view.handle_event_with_count(
160+
UserEventWithCount::from_event(UserEvent::Unknown),
161+
key,
162+
);
142163
}
143164
}
144165
}
@@ -266,6 +287,38 @@ impl App<'_> {
266287
}
267288

268289
impl App<'_> {
290+
fn process_numeric_prefix(
291+
&self,
292+
user_event: UserEvent,
293+
_key: KeyEvent,
294+
) -> Option<UserEventWithCount> {
295+
let count = if self.numeric_prefix.is_empty() {
296+
1
297+
} else {
298+
self.numeric_prefix.parse::<usize>().unwrap_or(1)
299+
};
300+
301+
match user_event {
302+
UserEvent::NavigateUp
303+
| UserEvent::NavigateDown
304+
| UserEvent::NavigateLeft
305+
| UserEvent::NavigateRight
306+
| UserEvent::ScrollUp
307+
| UserEvent::ScrollDown
308+
| UserEvent::PageUp
309+
| UserEvent::PageDown
310+
| UserEvent::HalfPageUp
311+
| UserEvent::HalfPageDown => Some(UserEventWithCount::new(user_event, count)),
312+
_ => {
313+
if self.numeric_prefix.is_empty() {
314+
Some(UserEventWithCount::new(user_event, 1))
315+
} else {
316+
None
317+
}
318+
}
319+
}
320+
}
321+
269322
fn open_detail(&mut self) {
270323
if let View::List(ref mut view) = self.view {
271324
let commit_list_state = view.take_list_state();
@@ -398,3 +451,151 @@ impl App<'_> {
398451
}
399452
}
400453
}
454+
455+
#[cfg(test)]
456+
mod tests {
457+
use super::*;
458+
459+
// Helper function to test numeric prefix parsing logic
460+
fn test_process_numeric_prefix_logic(
461+
numeric_prefix: &str,
462+
user_event: UserEvent,
463+
) -> Option<UserEventWithCount> {
464+
let count = if numeric_prefix.is_empty() {
465+
1
466+
} else {
467+
numeric_prefix.parse::<usize>().unwrap_or(1)
468+
};
469+
470+
match user_event {
471+
UserEvent::NavigateUp
472+
| UserEvent::NavigateDown
473+
| UserEvent::NavigateLeft
474+
| UserEvent::NavigateRight
475+
| UserEvent::ScrollUp
476+
| UserEvent::ScrollDown
477+
| UserEvent::PageUp
478+
| UserEvent::PageDown
479+
| UserEvent::HalfPageUp
480+
| UserEvent::HalfPageDown => Some(UserEventWithCount::new(user_event, count)),
481+
_ => {
482+
if numeric_prefix.is_empty() {
483+
Some(UserEventWithCount::new(user_event, 1))
484+
} else {
485+
None
486+
}
487+
}
488+
}
489+
}
490+
491+
#[test]
492+
fn test_process_numeric_prefix_no_prefix() {
493+
let result = test_process_numeric_prefix_logic("", UserEvent::NavigateDown);
494+
495+
assert!(result.is_some());
496+
let event_with_count = result.unwrap();
497+
assert_eq!(event_with_count.event, UserEvent::NavigateDown);
498+
assert_eq!(event_with_count.count, 1);
499+
}
500+
501+
#[test]
502+
fn test_process_numeric_prefix_with_prefix() {
503+
let result = test_process_numeric_prefix_logic("5", UserEvent::NavigateDown);
504+
505+
assert!(result.is_some());
506+
let event_with_count = result.unwrap();
507+
assert_eq!(event_with_count.event, UserEvent::NavigateDown);
508+
assert_eq!(event_with_count.count, 5);
509+
}
510+
511+
#[test]
512+
fn test_process_numeric_prefix_invalid_number() {
513+
let result = test_process_numeric_prefix_logic("abc", UserEvent::NavigateDown);
514+
515+
assert!(result.is_some());
516+
let event_with_count = result.unwrap();
517+
assert_eq!(event_with_count.event, UserEvent::NavigateDown);
518+
assert_eq!(event_with_count.count, 1); // Should fallback to 1
519+
}
520+
521+
#[test]
522+
fn test_process_numeric_prefix_countable_events() {
523+
let countable_events = [
524+
UserEvent::NavigateUp,
525+
UserEvent::NavigateDown,
526+
UserEvent::NavigateLeft,
527+
UserEvent::NavigateRight,
528+
UserEvent::ScrollUp,
529+
UserEvent::ScrollDown,
530+
UserEvent::PageUp,
531+
UserEvent::PageDown,
532+
UserEvent::HalfPageUp,
533+
UserEvent::HalfPageDown,
534+
];
535+
536+
for event in countable_events {
537+
let result = test_process_numeric_prefix_logic("3", event);
538+
assert!(result.is_some());
539+
let event_with_count = result.unwrap();
540+
assert_eq!(event_with_count.event, event);
541+
assert_eq!(event_with_count.count, 3);
542+
}
543+
}
544+
545+
#[test]
546+
fn test_process_numeric_prefix_non_countable_events() {
547+
let non_countable_events = [
548+
UserEvent::Quit,
549+
UserEvent::Confirm,
550+
UserEvent::Cancel,
551+
UserEvent::HelpToggle,
552+
UserEvent::Search,
553+
UserEvent::ShortCopy,
554+
UserEvent::FullCopy,
555+
];
556+
557+
for event in non_countable_events {
558+
let result = test_process_numeric_prefix_logic("5", event);
559+
assert!(result.is_none()); // Should return None when prefix exists but event isn't countable
560+
}
561+
}
562+
563+
#[test]
564+
fn test_process_numeric_prefix_non_countable_events_no_prefix() {
565+
let result = test_process_numeric_prefix_logic("", UserEvent::Confirm);
566+
assert!(result.is_some());
567+
let event_with_count = result.unwrap();
568+
assert_eq!(event_with_count.event, UserEvent::Confirm);
569+
assert_eq!(event_with_count.count, 1);
570+
}
571+
572+
#[test]
573+
fn test_process_numeric_prefix_large_numbers() {
574+
let result = test_process_numeric_prefix_logic("999", UserEvent::NavigateDown);
575+
576+
assert!(result.is_some());
577+
let event_with_count = result.unwrap();
578+
assert_eq!(event_with_count.event, UserEvent::NavigateDown);
579+
assert_eq!(event_with_count.count, 999);
580+
}
581+
582+
#[test]
583+
fn test_process_numeric_prefix_zero() {
584+
let result = test_process_numeric_prefix_logic("0", UserEvent::NavigateUp);
585+
586+
assert!(result.is_some());
587+
let event_with_count = result.unwrap();
588+
assert_eq!(event_with_count.event, UserEvent::NavigateUp);
589+
assert_eq!(event_with_count.count, 1); // UserEventWithCount::new converts 0 to 1
590+
}
591+
592+
#[test]
593+
fn test_process_numeric_prefix_multi_digit() {
594+
let result = test_process_numeric_prefix_logic("42", UserEvent::ScrollDown);
595+
596+
assert!(result.is_some());
597+
let event_with_count = result.unwrap();
598+
assert_eq!(event_with_count.event, UserEvent::ScrollDown);
599+
assert_eq!(event_with_count.count, 42);
600+
}
601+
}

src/event.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,58 @@ pub enum UserEvent {
117117
FullCopy,
118118
Unknown,
119119
}
120+
121+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122+
pub struct UserEventWithCount {
123+
pub event: UserEvent,
124+
pub count: usize,
125+
}
126+
127+
impl UserEventWithCount {
128+
pub fn new(event: UserEvent, count: usize) -> Self {
129+
Self {
130+
event,
131+
count: if count == 0 { 1 } else { count },
132+
}
133+
}
134+
135+
pub fn from_event(event: UserEvent) -> Self {
136+
Self::new(event, 1)
137+
}
138+
}
139+
140+
#[cfg(test)]
141+
mod tests {
142+
use super::*;
143+
144+
#[test]
145+
fn test_user_event_with_count_new() {
146+
let event = UserEventWithCount::new(UserEvent::NavigateUp, 5);
147+
assert_eq!(event.event, UserEvent::NavigateUp);
148+
assert_eq!(event.count, 5);
149+
}
150+
151+
#[test]
152+
fn test_user_event_with_count_new_zero_count() {
153+
let event = UserEventWithCount::new(UserEvent::NavigateDown, 0);
154+
assert_eq!(event.event, UserEvent::NavigateDown);
155+
assert_eq!(event.count, 1); // zero should be converted to 1
156+
}
157+
158+
#[test]
159+
fn test_user_event_with_count_from_event() {
160+
let event = UserEventWithCount::from_event(UserEvent::NavigateLeft);
161+
assert_eq!(event.event, UserEvent::NavigateLeft);
162+
assert_eq!(event.count, 1);
163+
}
164+
165+
#[test]
166+
fn test_user_event_with_count_equality() {
167+
let event1 = UserEventWithCount::new(UserEvent::ScrollUp, 3);
168+
let event2 = UserEventWithCount::new(UserEvent::ScrollUp, 3);
169+
let event3 = UserEventWithCount::new(UserEvent::ScrollDown, 3);
170+
171+
assert_eq!(event1, event2);
172+
assert_ne!(event1, event3);
173+
}
174+
}

src/view/detail.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use ratatui::{
88
use crate::{
99
color::ColorTheme,
1010
config::UiConfig,
11-
event::{AppEvent, Sender, UserEvent},
11+
event::{AppEvent, Sender, UserEvent, UserEventWithCount},
1212
git::{Commit, FileChange, Ref},
1313
protocol::ImageProtocol,
1414
widget::{
@@ -58,13 +58,20 @@ impl<'a> DetailView<'a> {
5858
}
5959
}
6060

61-
pub fn handle_event(&mut self, event: UserEvent, _: KeyEvent) {
61+
pub fn handle_event_with_count(&mut self, event_with_count: UserEventWithCount, _: KeyEvent) {
62+
let event = event_with_count.event;
63+
let count = event_with_count.count;
64+
6265
match event {
6366
UserEvent::NavigateDown => {
64-
self.commit_detail_state.scroll_down();
67+
for _ in 0..count {
68+
self.commit_detail_state.scroll_down();
69+
}
6570
}
6671
UserEvent::NavigateUp => {
67-
self.commit_detail_state.scroll_up();
72+
for _ in 0..count {
73+
self.commit_detail_state.scroll_up();
74+
}
6875
}
6976
UserEvent::GoToTop => {
7077
self.commit_detail_state.select_first();

src/view/help.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use ratatui::{
99

1010
use crate::{
1111
color::ColorTheme,
12-
event::{AppEvent, Sender, UserEvent},
12+
event::{AppEvent, Sender, UserEvent, UserEventWithCount},
1313
keybind::KeyBind,
1414
protocol::ImageProtocol,
1515
view::View,
@@ -56,7 +56,10 @@ impl HelpView<'_> {
5656
}
5757
}
5858

59-
pub fn handle_event(&mut self, event: UserEvent, _: KeyEvent) {
59+
pub fn handle_event_with_count(&mut self, event_with_count: UserEventWithCount, _: KeyEvent) {
60+
let event = event_with_count.event;
61+
let count = event_with_count.count;
62+
6063
match event {
6164
UserEvent::Quit => {
6265
self.tx.send(AppEvent::Quit);
@@ -66,10 +69,14 @@ impl HelpView<'_> {
6669
self.tx.send(AppEvent::CloseHelp);
6770
}
6871
UserEvent::NavigateDown => {
69-
self.scroll_down();
72+
for _ in 0..count {
73+
self.scroll_down();
74+
}
7075
}
7176
UserEvent::NavigateUp => {
72-
self.scroll_up();
77+
for _ in 0..count {
78+
self.scroll_up();
79+
}
7380
}
7481
UserEvent::GoToTop => {
7582
self.select_first();

0 commit comments

Comments
 (0)