Skip to content

Commit b2aa699

Browse files
committed
🚸 feat: improve tui navigation with vim keybindings
1 parent c18844c commit b2aa699

4 files changed

Lines changed: 96 additions & 15 deletions

File tree

src/tui/app.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,9 @@ pub struct App<'a> {
240240

241241
/// Unified budget dialog state (period budget + target)
242242
pub budget_dialog_state: BudgetDialogState,
243+
244+
/// Pending 'g' keypress for Vim-style gg (go to top)
245+
pub pending_g: bool,
243246
}
244247

245248
impl<'a> App<'a> {
@@ -287,6 +290,7 @@ impl<'a> App<'a> {
287290
category_form: CategoryFormState::new(),
288291
group_form: GroupFormState::new(),
289292
budget_dialog_state: BudgetDialogState::new(),
293+
pending_g: false,
290294
}
291295
}
292296

src/tui/dialogs/budget.rs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -750,7 +750,7 @@ fn render_target_tab(frame: &mut Frame, area: Rect, app: &App) {
750750
Span::raw(" Cancel "),
751751
Span::styled("[Del]", Style::default().fg(Color::Magenta)),
752752
Span::raw(" Remove "),
753-
Span::styled("[↑↓]", Style::default().fg(Color::Cyan)),
753+
Span::styled("[j/k]", Style::default().fg(Color::Cyan)),
754754
Span::raw(" Fields"),
755755
]);
756756
frame.render_widget(Paragraph::new(instructions), chunks[row]);
@@ -861,7 +861,7 @@ fn render_selector_field(frame: &mut Frame, area: Rect, label: &str, value: &str
861861
Style::default().fg(Color::White)
862862
};
863863

864-
let hint = if focused { " ← j/k →" } else { "" };
864+
let hint = if focused { " ← h/l →" } else { "" };
865865

866866
let line = Line::from(vec![
867867
Span::styled(format!("{}: ", label), label_style),
@@ -963,22 +963,24 @@ fn handle_target_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
963963
use crossterm::event::{KeyCode, KeyModifiers};
964964

965965
match key.code {
966-
KeyCode::Down => {
966+
// Field navigation: j/k or up/down arrows
967+
KeyCode::Down | KeyCode::Char('j') => {
967968
app.budget_dialog_state.target_next_field();
968969
true
969970
}
970971

971-
KeyCode::Up => {
972+
KeyCode::Up | KeyCode::Char('k') => {
972973
app.budget_dialog_state.target_prev_field();
973974
true
974975
}
975976

976-
KeyCode::Char('j') if app.budget_dialog_state.target_field == TargetField::Cadence => {
977+
// Cadence cycling: h/l when on cadence field
978+
KeyCode::Char('l') if app.budget_dialog_state.target_field == TargetField::Cadence => {
977979
app.budget_dialog_state.next_cadence();
978980
true
979981
}
980982

981-
KeyCode::Char('k') if app.budget_dialog_state.target_field == TargetField::Cadence => {
983+
KeyCode::Char('h') if app.budget_dialog_state.target_field == TargetField::Cadence => {
982984
app.budget_dialog_state.prev_cadence();
983985
true
984986
}

src/tui/handler.rs

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -245,23 +245,26 @@ fn handle_register_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
245245
match key.code {
246246
// Navigation
247247
KeyCode::Char('j') | KeyCode::Down => {
248+
app.pending_g = false;
248249
app.move_down(txn_count);
249250
// Update selected transaction from sorted list
250251
if let Some(txn) = txns.get(app.selected_transaction_index) {
251252
app.selected_transaction = Some(txn.id);
252253
}
253254
}
254255
KeyCode::Char('k') | KeyCode::Up => {
256+
app.pending_g = false;
255257
app.move_up();
256258
// Update selected transaction from sorted list
257259
if let Some(txn) = txns.get(app.selected_transaction_index) {
258260
app.selected_transaction = Some(txn.id);
259261
}
260262
}
261263

262-
// Page navigation
264+
// Page navigation (Vim-style)
263265
KeyCode::Char('G') => {
264-
// Go to bottom
266+
// Shift-G: Go to bottom
267+
app.pending_g = false;
265268
if txn_count > 0 {
266269
app.selected_transaction_index = txn_count - 1;
267270
if let Some(txn) = txns.get(app.selected_transaction_index) {
@@ -270,20 +273,29 @@ fn handle_register_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
270273
}
271274
}
272275
KeyCode::Char('g') => {
273-
// Go to top (gg in vim, but we'll use single g)
274-
app.selected_transaction_index = 0;
275-
if let Some(txn) = txns.first() {
276-
app.selected_transaction = Some(txn.id);
276+
// gg: Go to top (requires double-g press)
277+
if app.pending_g {
278+
// Second 'g' pressed - go to top
279+
app.pending_g = false;
280+
app.selected_transaction_index = 0;
281+
if let Some(txn) = txns.first() {
282+
app.selected_transaction = Some(txn.id);
283+
}
284+
} else {
285+
// First 'g' pressed - wait for second
286+
app.pending_g = true;
277287
}
278288
}
279289

280290
// Add transaction
281291
KeyCode::Char('a') | KeyCode::Char('n') => {
292+
app.pending_g = false;
282293
app.open_dialog(ActiveDialog::AddTransaction);
283294
}
284295

285296
// Edit transaction
286297
KeyCode::Char('e') => {
298+
app.pending_g = false;
287299
// DEBUG: Force initialize selection and try edit
288300
if app.selected_transaction.is_none() {
289301
let txns = get_sorted_transactions(app);
@@ -296,6 +308,7 @@ fn handle_register_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
296308
}
297309
}
298310
KeyCode::Enter => {
311+
app.pending_g = false;
299312
if app.selected_transaction.is_none() {
300313
let txns = get_sorted_transactions(app);
301314
if let Some(txn) = txns.get(app.selected_transaction_index) {
@@ -309,6 +322,7 @@ fn handle_register_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
309322

310323
// Clear transaction (toggle)
311324
KeyCode::Char('c') => {
325+
app.pending_g = false;
312326
if let Some(txn_id) = app.selected_transaction {
313327
// Toggle cleared status
314328
if let Ok(Some(txn)) = app.storage.transactions.get(txn_id) {
@@ -331,6 +345,7 @@ fn handle_register_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
331345

332346
// Delete transaction
333347
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
348+
app.pending_g = false;
334349
if app.selected_transaction.is_some() {
335350
app.open_dialog(ActiveDialog::Confirm(
336351
"Delete this transaction?".to_string(),
@@ -340,6 +355,7 @@ fn handle_register_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
340355

341356
// Multi-select mode
342357
KeyCode::Char('v') => {
358+
app.pending_g = false;
343359
app.toggle_multi_select();
344360
if app.multi_select_mode {
345361
app.set_status("Multi-select mode ON");
@@ -350,16 +366,19 @@ fn handle_register_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
350366

351367
// Toggle selection in multi-select mode
352368
KeyCode::Char(' ') if app.multi_select_mode => {
369+
app.pending_g = false;
353370
app.toggle_transaction_selection();
354371
}
355372

356373
// Bulk categorize
357374
KeyCode::Char('C') if app.multi_select_mode && !app.selected_transactions.is_empty() => {
375+
app.pending_g = false;
358376
app.open_dialog(ActiveDialog::BulkCategorize);
359377
}
360378

361379
// Bulk delete
362380
KeyCode::Char('D') if app.multi_select_mode && !app.selected_transactions.is_empty() => {
381+
app.pending_g = false;
363382
let count = app.selected_transactions.len();
364383
app.open_dialog(ActiveDialog::Confirm(format!(
365384
"Delete {} transaction{}?",
@@ -368,7 +387,9 @@ fn handle_register_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
368387
)));
369388
}
370389

371-
_ => {}
390+
_ => {
391+
app.pending_g = false;
392+
}
372393
}
373394

374395
Ok(())
@@ -404,58 +425,95 @@ fn handle_budget_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
404425
match key.code {
405426
// Navigation
406427
KeyCode::Char('j') | KeyCode::Down => {
428+
app.pending_g = false;
407429
app.move_down(category_count);
408430
if let Some(cat) = categories.get(app.selected_category_index) {
409431
app.selected_category = Some(cat.id);
410432
}
411433
}
412434
KeyCode::Char('k') | KeyCode::Up => {
435+
app.pending_g = false;
413436
app.move_up();
414437
if let Some(cat) = categories.get(app.selected_category_index) {
415438
app.selected_category = Some(cat.id);
416439
}
417440
}
418441

442+
// Page navigation (Vim-style)
443+
KeyCode::Char('G') => {
444+
// Shift-G: Go to bottom
445+
app.pending_g = false;
446+
if category_count > 0 {
447+
app.selected_category_index = category_count - 1;
448+
if let Some(cat) = categories.get(app.selected_category_index) {
449+
app.selected_category = Some(cat.id);
450+
}
451+
}
452+
}
453+
KeyCode::Char('g') => {
454+
// gg: Go to top (requires double-g press)
455+
if app.pending_g {
456+
// Second 'g' pressed - go to top
457+
app.pending_g = false;
458+
app.selected_category_index = 0;
459+
if let Some(cat) = categories.first() {
460+
app.selected_category = Some(cat.id);
461+
}
462+
} else {
463+
// First 'g' pressed - wait for second
464+
app.pending_g = true;
465+
}
466+
}
467+
419468
// Period navigation
420469
KeyCode::Char('[') | KeyCode::Char('H') => {
470+
app.pending_g = false;
421471
app.prev_period();
422472
}
423473
KeyCode::Char(']') | KeyCode::Char('L') => {
474+
app.pending_g = false;
424475
app.next_period();
425476
}
426477

427478
// Header display toggle (cycle through account types)
428479
KeyCode::Char('<') | KeyCode::Char(',') => {
480+
app.pending_g = false;
429481
app.budget_header_display = app.budget_header_display.prev();
430482
}
431483
KeyCode::Char('>') | KeyCode::Char('.') => {
484+
app.pending_g = false;
432485
app.budget_header_display = app.budget_header_display.next();
433486
}
434487

435488
// Move funds
436489
KeyCode::Char('m') => {
490+
app.pending_g = false;
437491
app.open_dialog(ActiveDialog::MoveFunds);
438492
}
439493

440494
// Add new category
441495
KeyCode::Char('a') => {
496+
app.pending_g = false;
442497
app.open_dialog(ActiveDialog::AddCategory);
443498
}
444499

445500
// Add new category group
446501
KeyCode::Char('A') => {
502+
app.pending_g = false;
447503
app.open_dialog(ActiveDialog::AddGroup);
448504
}
449505

450506
// Edit category group (Shift+E)
451507
KeyCode::Char('E') => {
508+
app.pending_g = false;
452509
if let Some(cat) = categories.get(app.selected_category_index) {
453510
app.open_dialog(ActiveDialog::EditGroup(cat.group_id));
454511
}
455512
}
456513

457514
// Delete category group (Shift+D)
458515
KeyCode::Char('D') => {
516+
app.pending_g = false;
459517
if let Some(cat) = categories.get(app.selected_category_index) {
460518
if let Ok(Some(group)) = app.storage.categories.get_group(cat.group_id) {
461519
let group_categories = app
@@ -479,6 +537,7 @@ fn handle_budget_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
479537

480538
// Edit category
481539
KeyCode::Char('e') => {
540+
app.pending_g = false;
482541
if let Some(cat) = categories.get(app.selected_category_index) {
483542
app.selected_category = Some(cat.id);
484543
app.open_dialog(ActiveDialog::EditCategory(cat.id));
@@ -487,6 +546,7 @@ fn handle_budget_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
487546

488547
// Delete category
489548
KeyCode::Char('d') => {
549+
app.pending_g = false;
490550
if let Some(cat) = categories.get(app.selected_category_index) {
491551
app.selected_category = Some(cat.id);
492552
if let Ok(Some(category)) = app.storage.categories.get_category(cat.id) {
@@ -500,13 +560,16 @@ fn handle_budget_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
500560

501561
// Open unified budget dialog (period budget + target)
502562
KeyCode::Enter | KeyCode::Char('b') | KeyCode::Char('t') => {
563+
app.pending_g = false;
503564
if let Some(cat) = categories.get(app.selected_category_index) {
504565
app.selected_category = Some(cat.id);
505566
app.open_dialog(ActiveDialog::Budget);
506567
}
507568
}
508569

509-
_ => {}
570+
_ => {
571+
app.pending_g = false;
572+
}
510573
}
511574

512575
Ok(())

src/tui/keybindings.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ pub static KEYBINDINGS: &[Keybinding] = &[
162162
Keybinding {
163163
key: KeyCode::Char('g'),
164164
modifiers: KeyModifiers::NONE,
165-
description: "Go to top",
165+
description: "Go to top (gg)",
166166
context: KeyContext::Register,
167167
},
168168
Keybinding {
@@ -172,6 +172,18 @@ pub static KEYBINDINGS: &[Keybinding] = &[
172172
context: KeyContext::Register,
173173
},
174174
// Budget
175+
Keybinding {
176+
key: KeyCode::Char('g'),
177+
modifiers: KeyModifiers::NONE,
178+
description: "Go to top (gg)",
179+
context: KeyContext::Budget,
180+
},
181+
Keybinding {
182+
key: KeyCode::Char('G'),
183+
modifiers: KeyModifiers::SHIFT,
184+
description: "Go to bottom",
185+
context: KeyContext::Budget,
186+
},
175187
Keybinding {
176188
key: KeyCode::Char('['),
177189
modifiers: KeyModifiers::NONE,

0 commit comments

Comments
 (0)