Skip to content

Commit b902d13

Browse files
committed
feat(tui): support counted main-page movement
- add scoped numeric prefixes for repeated vertical movement on the main page in default/custom and vim keymaps - clear pending counts when focus changes or modal surfaces take over so prefixes do not leak across interactions - document the count shortcuts and add TUI regression coverage for counted moves and scope resets Signed-off-by: Chao Liu <chao.liu.zevorn@gmail.com>
1 parent f7e9ca9 commit b902d13

File tree

5 files changed

+231
-11
lines changed

5 files changed

+231
-11
lines changed

README-zh.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ criew tui
198198

199199
- `:` 打开命令栏
200200
- 顶部状态栏会显示当前 keymap 方案:`default``vim``custom`
201+
- 数字前缀可重复垂直移动:`default/custom` 使用 `数字 + i/k``vim` 使用 `数字 + j/k`
201202
- `y` / `n` 启用或禁用当前订阅
202203
- `Enter` 打开当前 mailbox 或 thread,并自动切到 threads 或 preview pane
203204
- `a` apply 当前 patch series

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ Inside the TUI:
211211

212212
- `:` opens the command palette
213213
- the header shows the active keymap scheme (`default`, `vim`, or `custom`)
214+
- a numeric prefix repeats vertical movement: `count+i/k` for `default`/`custom`, `count+j/k` for `vim`
214215
- `y` / `n` enable or disable the selected subscription
215216
- `Enter` opens the selected mailbox or thread and moves focus to threads or preview
216217
- `a` applies the current patch series

src/ui/tui.rs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ const CONFIG_EDITOR_FIELDS: &[ConfigEditorField] = &[
231231
},
232232
ConfigEditorField {
233233
key: "ui.keymap",
234-
description: "Main-page navigation scheme. default=j/l+i/k, vim=h/l+j/k+gg/G+qq, custom=default fallback with custom label.",
234+
description: "Main-page navigation scheme. default=j/l+i/k+count, vim=h/l+j/k+count+gg/G+qq, custom=default fallback with custom label.",
235235
},
236236
ConfigEditorField {
237237
key: "ui.inbox_auto_sync_interval_secs",
@@ -353,6 +353,14 @@ struct PendingMainPageChordState {
353353
code_focus: CodePaneFocus,
354354
}
355355

356+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357+
struct PendingMainPageCountState {
358+
count: u16,
359+
ui_page: UiPage,
360+
focus: Pane,
361+
code_focus: CodePaneFocus,
362+
}
363+
356364
#[derive(Debug, Clone)]
357365
enum StartupSyncEvent {
358366
MailboxStarted {
@@ -1249,6 +1257,7 @@ struct AppState {
12491257
manual_sync: Option<ManualSyncState>,
12501258
subscription_auto_sync: Option<SubscriptionAutoSyncState>,
12511259
pending_main_page_chord: Option<PendingMainPageChordState>,
1260+
pending_main_page_count: Option<PendingMainPageCountState>,
12521261
}
12531262

12541263
impl AppState {
@@ -1365,6 +1374,7 @@ impl AppState {
13651374
manual_sync: None,
13661375
subscription_auto_sync: None,
13671376
pending_main_page_chord: None,
1377+
pending_main_page_count: None,
13681378
};
13691379
if state.runtime.imap.is_complete() {
13701380
state.imap_defaults_initialized = true;
@@ -3809,6 +3819,53 @@ impl AppState {
38093819
}
38103820
}
38113821

3822+
fn pending_main_page_count_state(&self, count: u16) -> PendingMainPageCountState {
3823+
PendingMainPageCountState {
3824+
count,
3825+
ui_page: self.ui_page,
3826+
focus: self.focus,
3827+
code_focus: self.code_focus,
3828+
}
3829+
}
3830+
3831+
fn clear_pending_main_page_inputs(&mut self) {
3832+
self.pending_main_page_chord = None;
3833+
self.pending_main_page_count = None;
3834+
}
3835+
3836+
fn clear_pending_main_page_count(&mut self) {
3837+
self.pending_main_page_count = None;
3838+
}
3839+
3840+
fn has_pending_main_page_count(&self) -> bool {
3841+
self.pending_main_page_count.is_some_and(|state| {
3842+
state.ui_page == self.ui_page
3843+
&& state.focus == self.focus
3844+
&& state.code_focus == self.code_focus
3845+
})
3846+
}
3847+
3848+
fn push_pending_main_page_count_digit(&mut self, digit: u16) {
3849+
let next_count = self
3850+
.pending_main_page_count
3851+
.filter(|state| {
3852+
state.ui_page == self.ui_page
3853+
&& state.focus == self.focus
3854+
&& state.code_focus == self.code_focus
3855+
})
3856+
.map(|state| state.count.saturating_mul(10).saturating_add(digit))
3857+
.unwrap_or(digit);
3858+
self.pending_main_page_count = Some(self.pending_main_page_count_state(next_count));
3859+
}
3860+
3861+
fn take_pending_main_page_count(&mut self) -> Option<u16> {
3862+
let pending_state = self.pending_main_page_count.take()?;
3863+
let same_scope = pending_state.ui_page == self.ui_page
3864+
&& pending_state.focus == self.focus
3865+
&& pending_state.code_focus == self.code_focus;
3866+
same_scope.then_some(pending_state.count)
3867+
}
3868+
38123869
fn close_search(&mut self) {
38133870
self.search.active = false;
38143871
self.search.input.clear();

src/ui/tui/input.rs

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,49 +19,91 @@ pub(super) enum LoopAction {
1919
Restart,
2020
}
2121

22+
fn pending_main_page_move_count(state: &mut AppState) -> u16 {
23+
state.take_pending_main_page_count().unwrap_or(1)
24+
}
25+
2226
fn handle_main_page_navigation_key(state: &mut AppState, key: KeyEvent) -> bool {
2327
match state.runtime.ui_keymap {
2428
UiKeymap::Default | UiKeymap::Custom => match key.code {
2529
KeyCode::Char('j') => {
30+
state.clear_pending_main_page_count();
2631
state.move_focus_previous();
2732
true
2833
}
2934
KeyCode::Char('l') => {
35+
state.clear_pending_main_page_count();
3036
state.move_focus_next();
3137
true
3238
}
3339
KeyCode::Char('i') => {
34-
state.move_up();
40+
for _ in 0..pending_main_page_move_count(state) {
41+
state.move_up();
42+
}
3543
true
3644
}
3745
KeyCode::Char('k') => {
38-
state.move_down();
46+
for _ in 0..pending_main_page_move_count(state) {
47+
state.move_down();
48+
}
3949
true
4050
}
4151
_ => false,
4252
},
4353
UiKeymap::Vim => match key.code {
4454
KeyCode::Char('h') => {
55+
state.clear_pending_main_page_count();
4556
state.move_focus_previous();
4657
true
4758
}
4859
KeyCode::Char('l') => {
60+
state.clear_pending_main_page_count();
4961
state.move_focus_next();
5062
true
5163
}
5264
KeyCode::Char('k') => {
53-
state.move_up();
65+
for _ in 0..pending_main_page_move_count(state) {
66+
state.move_up();
67+
}
5468
true
5569
}
5670
KeyCode::Char('j') => {
57-
state.move_down();
71+
for _ in 0..pending_main_page_move_count(state) {
72+
state.move_down();
73+
}
5874
true
5975
}
6076
_ => false,
6177
},
6278
}
6379
}
6480

81+
fn handle_main_page_count_prefix(state: &mut AppState, key: KeyEvent) -> bool {
82+
if key
83+
.modifiers
84+
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
85+
{
86+
state.clear_pending_main_page_count();
87+
return false;
88+
}
89+
90+
let KeyCode::Char(character) = key.code else {
91+
return false;
92+
};
93+
if !character.is_ascii_digit() {
94+
return false;
95+
}
96+
if character == '0' && !state.has_pending_main_page_count() {
97+
return false;
98+
}
99+
100+
let digit = character
101+
.to_digit(10)
102+
.expect("ascii digit should convert to decimal") as u16;
103+
state.push_pending_main_page_count_digit(digit);
104+
true
105+
}
106+
65107
fn handle_vim_main_page_chord(state: &mut AppState, key: KeyEvent) -> Option<LoopAction> {
66108
if !matches!(state.runtime.ui_keymap, UiKeymap::Vim) {
67109
state.pending_main_page_chord = None;
@@ -72,7 +114,7 @@ fn handle_vim_main_page_chord(state: &mut AppState, key: KeyEvent) -> Option<Loo
72114
.modifiers
73115
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
74116
{
75-
state.pending_main_page_chord = None;
117+
state.clear_pending_main_page_inputs();
76118
return None;
77119
}
78120

@@ -96,15 +138,18 @@ fn handle_vim_main_page_chord(state: &mut AppState, key: KeyEvent) -> Option<Loo
96138

97139
match key.code {
98140
KeyCode::Char('g') => {
141+
state.clear_pending_main_page_count();
99142
state.pending_main_page_chord =
100143
Some(state.pending_main_page_chord_state(PendingMainPageChord::VimGoToFirstLine));
101144
Some(LoopAction::Continue)
102145
}
103146
KeyCode::Char('G') => {
147+
state.clear_pending_main_page_count();
104148
state.jump_current_pane_to_end();
105149
Some(LoopAction::Continue)
106150
}
107151
KeyCode::Char('q') => {
152+
state.clear_pending_main_page_count();
108153
state.pending_main_page_chord =
109154
Some(state.pending_main_page_chord_state(PendingMainPageChord::VimQuit));
110155
state.status = "press qq to quit or use command palette quit/exit".to_string();
@@ -130,12 +175,12 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio
130175
// Modal UI surfaces take precedence over the base page shortcuts so keys
131176
// keep local meaning while a dialog, editor, or search interaction is open.
132177
if state.config_editor.open {
133-
state.pending_main_page_chord = None;
178+
state.clear_pending_main_page_inputs();
134179
return handle_config_editor_key_event(state, key);
135180
}
136181

137182
if state.palette.open {
138-
state.pending_main_page_chord = None;
183+
state.clear_pending_main_page_inputs();
139184
if is_palette_toggle(key) {
140185
state.close_palette();
141186
return LoopAction::Continue;
@@ -144,25 +189,30 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio
144189
}
145190

146191
if state.search.active {
147-
state.pending_main_page_chord = None;
192+
state.clear_pending_main_page_inputs();
148193
return handle_search_key_event(state, key);
149194
}
150195

151196
if state.reply_panel.is_some() {
152-
state.pending_main_page_chord = None;
197+
state.clear_pending_main_page_inputs();
153198
return handle_reply_key_event(state, key);
154199
}
155200

156201
if state.is_code_edit_active() {
157-
state.pending_main_page_chord = None;
202+
state.clear_pending_main_page_inputs();
158203
return handle_code_edit_key_event(state, key);
159204
}
160205

161206
if let Some(action) = handle_vim_main_page_chord(state, key) {
162207
return action;
163208
}
164209

210+
if handle_main_page_count_prefix(state, key) {
211+
return LoopAction::Continue;
212+
}
213+
165214
if is_palette_open_shortcut(key) {
215+
state.clear_pending_main_page_count();
166216
state.toggle_palette();
167217
return LoopAction::Continue;
168218
}
@@ -171,6 +221,8 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio
171221
return LoopAction::Continue;
172222
}
173223

224+
state.clear_pending_main_page_count();
225+
174226
match key.code {
175227
KeyCode::Char('/') => {
176228
if matches!(state.ui_page, UiPage::Mail) {

0 commit comments

Comments
 (0)