feat(controls): vim like keybindings for nav

This commit is contained in:
Blagoy Simandoff 2026-04-25 13:25:01 +01:00
parent 8619c07f3f
commit a6354dd204
22 changed files with 79 additions and 124 deletions

View file

@ -362,12 +362,9 @@ impl Component for DetailsComponent {
if self.focused { if self.focused {
if let Event::Key(e) = event { if let Event::Key(e) = event {
return Ok( return Ok(
if key_match(e, self.key_config.keys.move_up) { if self.key_config.is_nav_up(e) {
self.move_scroll_top(ScrollType::Up).into() self.move_scroll_top(ScrollType::Up).into()
} else if key_match( } else if self.key_config.is_nav_down(e) {
e,
self.key_config.keys.move_down,
) {
self.move_scroll_top(ScrollType::Down).into() self.move_scroll_top(ScrollType::Down).into()
} else if key_match( } else if key_match(
e, e,

View file

@ -9,7 +9,7 @@ use super::{
use crate::{ use crate::{
accessors, accessors,
app::Environment, app::Environment,
keys::{key_match, SharedKeyConfig}, keys::SharedKeyConfig,
strings, strings,
}; };
use anyhow::Result; use anyhow::Result;
@ -208,13 +208,13 @@ impl Component for CommitDetailsComponent {
if self.focused() { if self.focused() {
if let Event::Key(e) = ev { if let Event::Key(e) = ev {
return if key_match(e, self.key_config.keys.move_down) return if self.key_config.is_nav_down(e)
&& self.details_focused() && self.details_focused()
{ {
self.set_details_focus(false); self.set_details_focus(false);
self.file_tree.focus(true); self.file_tree.focus(true);
Ok(EventState::Consumed) Ok(EventState::Consumed)
} else if key_match(e, self.key_config.keys.move_up) } else if self.key_config.is_nav_up(e)
&& self.file_tree.focused() && self.file_tree.focused()
&& !self.is_compare() && !self.is_compare()
{ {

View file

@ -831,10 +831,9 @@ impl Component for CommitList {
fn event(&mut self, ev: &Event) -> Result<EventState> { fn event(&mut self, ev: &Event) -> Result<EventState> {
if let Event::Key(k) = ev { if let Event::Key(k) = ev {
let selection_changed = let selection_changed =
if key_match(k, self.key_config.keys.move_up) { if self.key_config.is_nav_up(k) {
self.move_selection(ScrollType::Up)? self.move_selection(ScrollType::Up)?
} else if key_match(k, self.key_config.keys.move_down) } else if self.key_config.is_nav_down(k) {
{
self.move_selection(ScrollType::Down)? self.move_selection(ScrollType::Down)?
} else if key_match(k, self.key_config.keys.shift_up) } else if key_match(k, self.key_config.keys.shift_up)
|| key_match(k, self.key_config.keys.home) || key_match(k, self.key_config.keys.home)

View file

@ -831,8 +831,7 @@ impl Component for DiffComponent {
fn event(&mut self, ev: &Event) -> Result<EventState> { fn event(&mut self, ev: &Event) -> Result<EventState> {
if self.focused() { if self.focused() {
if let Event::Key(e) = ev { if let Event::Key(e) = ev {
return if key_match(e, self.key_config.keys.move_down) return if self.key_config.is_nav_down(e) {
{
self.move_selection(ScrollType::Down); self.move_selection(ScrollType::Down);
Ok(EventState::Consumed) Ok(EventState::Consumed)
} else if key_match( } else if key_match(
@ -851,7 +850,7 @@ impl Component for DiffComponent {
} else if key_match(e, self.key_config.keys.home) { } else if key_match(e, self.key_config.keys.home) {
self.move_selection(ScrollType::Home); self.move_selection(ScrollType::Home);
Ok(EventState::Consumed) Ok(EventState::Consumed)
} else if key_match(e, self.key_config.keys.move_up) { } else if self.key_config.is_nav_up(e) {
self.move_selection(ScrollType::Up); self.move_selection(ScrollType::Up);
Ok(EventState::Consumed) Ok(EventState::Consumed)
} else if key_match(e, self.key_config.keys.page_up) { } else if key_match(e, self.key_config.keys.page_up) {
@ -861,15 +860,11 @@ impl Component for DiffComponent {
{ {
self.move_selection(ScrollType::PageDown); self.move_selection(ScrollType::PageDown);
Ok(EventState::Consumed) Ok(EventState::Consumed)
} else if key_match( } else if self.key_config.is_nav_right(e) {
e,
self.key_config.keys.move_right,
) {
self.horizontal_scroll self.horizontal_scroll
.move_right(HorizontalScrollType::Right); .move_right(HorizontalScrollType::Right);
Ok(EventState::Consumed) Ok(EventState::Consumed)
} else if key_match(e, self.key_config.keys.move_left) } else if key_match(e, self.key_config.keys.move_left) {
{
self.horizontal_scroll self.horizontal_scroll
.move_right(HorizontalScrollType::Left); .move_right(HorizontalScrollType::Left);
Ok(EventState::Consumed) Ok(EventState::Consumed)

View file

@ -496,8 +496,7 @@ impl Component for RevisionFilesComponent {
self.hide(); self.hide();
return Ok(EventState::Consumed); return Ok(EventState::Consumed);
} }
} else if key_match(key, self.key_config.keys.move_right) } else if self.key_config.is_nav_right(key) {
{
if is_tree_focused { if is_tree_focused {
self.focus = Focus::File; self.focus = Focus::File;
self.current_file.focus(true); self.current_file.focus(true);

View file

@ -518,12 +518,11 @@ impl Component for StatusTreeComponent {
} else if key_match(e, self.key_config.keys.copy) { } else if key_match(e, self.key_config.keys.copy) {
self.copy_file_path(); self.copy_file_path();
Ok(EventState::Consumed) Ok(EventState::Consumed)
} else if key_match(e, self.key_config.keys.move_down) } else if self.key_config.is_nav_down(e) {
{
Ok(self Ok(self
.move_selection(MoveSelection::Down) .move_selection(MoveSelection::Down)
.into()) .into())
} else if key_match(e, self.key_config.keys.move_up) { } else if self.key_config.is_nav_up(e) {
Ok(self.move_selection(MoveSelection::Up).into()) Ok(self.move_selection(MoveSelection::Up).into())
} else if key_match(e, self.key_config.keys.home) } else if key_match(e, self.key_config.keys.home)
|| key_match(e, self.key_config.keys.shift_up) || key_match(e, self.key_config.keys.shift_up)
@ -544,15 +543,11 @@ impl Component for StatusTreeComponent {
Ok(self Ok(self
.move_selection(MoveSelection::PageDown) .move_selection(MoveSelection::PageDown)
.into()) .into())
} else if key_match(e, self.key_config.keys.move_left) } else if key_match(e, self.key_config.keys.move_left) {
{
Ok(self Ok(self
.move_selection(MoveSelection::Left) .move_selection(MoveSelection::Left)
.into()) .into())
} else if key_match( } else if self.key_config.is_nav_right(e) {
e,
self.key_config.keys.move_right,
) {
Ok(self Ok(self
.move_selection(MoveSelection::Right) .move_selection(MoveSelection::Right)
.into()) .into())

View file

@ -1,11 +1,11 @@
use anyhow::Result; use anyhow::Result;
use crossterm::event::{KeyCode, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::{fs::canonicalize, path::PathBuf, rc::Rc}; use std::{fs::canonicalize, path::PathBuf, rc::Rc};
use crate::{args::get_app_config_path, strings::symbol}; use crate::{args::get_app_config_path, strings::symbol};
use super::{ use super::{
key_list::{GituiKeyEvent, KeysList}, key_list::{key_match, GituiKeyEvent, KeysList},
symbols::KeySymbols, symbols::KeySymbols,
}; };
@ -52,6 +52,24 @@ impl KeyConfig {
Ok(Self { keys, symbols }) Ok(Self { keys, symbols })
} }
pub fn is_nav_up(&self, key: &KeyEvent) -> bool {
key_match(key, self.keys.move_up)
|| (key.code == KeyCode::Char('k')
&& key.modifiers == KeyModifiers::NONE)
}
pub fn is_nav_down(&self, key: &KeyEvent) -> bool {
key_match(key, self.keys.move_down)
|| (key.code == KeyCode::Char('j')
&& key.modifiers == KeyModifiers::NONE)
}
pub fn is_nav_right(&self, key: &KeyEvent) -> bool {
key_match(key, self.keys.move_right)
|| (key.code == KeyCode::Char('l')
&& key.modifiers == KeyModifiers::NONE)
}
fn get_key_symbol(&self, k: KeyCode) -> &str { fn get_key_symbol(&self, k: KeyCode) -> &str {
match k { match k {
KeyCode::Enter => &self.symbols.enter, KeyCode::Enter => &self.symbols.enter,

View file

@ -257,13 +257,9 @@ impl Component for BlameFilePopup {
if let Event::Key(key) = event { if let Event::Key(key) = event {
if key_match(key, self.key_config.keys.exit_popup) { if key_match(key, self.key_config.keys.exit_popup) {
self.hide_stacked(false); self.hide_stacked(false);
} else if key_match(key, self.key_config.keys.move_up) } else if self.key_config.is_nav_up(key) {
{
self.move_selection(ScrollType::Up); self.move_selection(ScrollType::Up);
} else if key_match( } else if self.key_config.is_nav_down(key) {
key,
self.key_config.keys.move_down,
) {
self.move_selection(ScrollType::Down); self.move_selection(ScrollType::Down);
} else if key_match( } else if key_match(
key, key,
@ -289,10 +285,7 @@ impl Component for BlameFilePopup {
} else if key_match(key, self.key_config.keys.page_up) } else if key_match(key, self.key_config.keys.page_up)
{ {
self.move_selection(ScrollType::PageUp); self.move_selection(ScrollType::PageUp);
} else if key_match( } else if self.key_config.is_nav_right(key) {
key,
self.key_config.keys.move_right,
) {
if let Some(commit_id) = self.selected_commit() { if let Some(commit_id) = self.selected_commit() {
self.hide_stacked(true); self.hide_stacked(true);
self.queue.push(InternalEvent::OpenPopup( self.queue.push(InternalEvent::OpenPopup(

View file

@ -170,7 +170,7 @@ impl Component for BranchListPopup {
"rebase error:", "rebase error:",
self.rebase_branch() self.rebase_branch()
); );
} else if key_match(e, self.key_config.keys.move_right) } else if self.key_config.is_nav_right(e)
&& self.valid_selection() && self.valid_selection()
{ {
self.inspect_head_of_branch(); self.inspect_head_of_branch();
@ -258,11 +258,11 @@ impl BranchListPopup {
fn move_event(&mut self, e: &KeyEvent) -> Result<EventState> { fn move_event(&mut self, e: &KeyEvent) -> Result<EventState> {
if key_match(e, self.key_config.keys.exit_popup) { if key_match(e, self.key_config.keys.exit_popup) {
self.hide(); self.hide();
} else if key_match(e, self.key_config.keys.move_down) { } else if self.key_config.is_nav_down(e) {
return self return self
.move_selection(ScrollType::Up) .move_selection(ScrollType::Up)
.map(Into::into); .map(Into::into);
} else if key_match(e, self.key_config.keys.move_up) { } else if self.key_config.is_nav_up(e) {
return self return self
.move_selection(ScrollType::Down) .move_selection(ScrollType::Down)
.map(Into::into); .map(Into::into);

View file

@ -200,13 +200,9 @@ impl Component for CheckoutOptionPopup {
if let Event::Key(key) = &event { if let Event::Key(key) = &event {
if key_match(key, self.key_config.keys.exit_popup) { if key_match(key, self.key_config.keys.exit_popup) {
self.hide(); self.hide();
} else if key_match( } else if self.key_config.is_nav_down(key) {
key,
self.key_config.keys.move_down,
) {
self.change_kind(true); self.change_kind(true);
} else if key_match(key, self.key_config.keys.move_up) } else if self.key_config.is_nav_up(key) {
{
self.change_kind(false); self.change_kind(false);
} else if key_match(key, self.key_config.keys.enter) { } else if key_match(key, self.key_config.keys.enter) {
try_or_popup!( try_or_popup!(

View file

@ -124,15 +124,12 @@ impl Component for CompareCommitsPopup {
} else { } else {
self.hide_stacked(false); self.hide_stacked(false);
} }
} else if key_match( } else if self.key_config.is_nav_right(e)
e, && self.can_focus_diff()
self.key_config.keys.move_right,
) && self.can_focus_diff()
{ {
self.details.focus(false); self.details.focus(false);
self.diff.focus(true); self.diff.focus(true);
} else if key_match(e, self.key_config.keys.move_left) } else if key_match(e, self.key_config.keys.move_left) {
{
self.hide_stacked(false); self.hide_stacked(false);
} }

View file

@ -507,10 +507,8 @@ impl Component for FileRevlogPopup {
} else { } else {
self.hide_stacked(false); self.hide_stacked(false);
} }
} else if key_match( } else if self.key_config.is_nav_right(key)
key, && self.can_focus_diff()
self.key_config.keys.move_right,
) && self.can_focus_diff()
{ {
self.diff.focus(true); self.diff.focus(true);
} else if key_match(key, self.key_config.keys.enter) { } else if key_match(key, self.key_config.keys.enter) {
@ -537,13 +535,9 @@ impl Component for FileRevlogPopup {
), ),
)); ));
} }
} else if key_match(key, self.key_config.keys.move_up) } else if self.key_config.is_nav_up(key) {
{
self.move_selection(ScrollType::Up)?; self.move_selection(ScrollType::Up)?;
} else if key_match( } else if self.key_config.is_nav_down(key) {
key,
self.key_config.keys.move_down,
) {
self.move_selection(ScrollType::Down)?; self.move_selection(ScrollType::Down)?;
} else if key_match( } else if key_match(
key, key,

View file

@ -137,10 +137,9 @@ impl Component for HelpPopup {
if let Event::Key(e) = ev { if let Event::Key(e) = ev {
if key_match(e, self.key_config.keys.exit_popup) { if key_match(e, self.key_config.keys.exit_popup) {
self.hide(); self.hide();
} else if key_match(e, self.key_config.keys.move_down) } else if self.key_config.is_nav_down(e) {
{
self.move_selection(true); self.move_selection(true);
} else if key_match(e, self.key_config.keys.move_up) { } else if self.key_config.is_nav_up(e) {
self.move_selection(false); self.move_selection(false);
} }
} }

View file

@ -161,15 +161,12 @@ impl Component for InspectCommitPopup {
} else { } else {
self.hide_stacked(false); self.hide_stacked(false);
} }
} else if key_match( } else if self.key_config.is_nav_right(e)
e, && self.can_focus_diff()
self.key_config.keys.move_right,
) && self.can_focus_diff()
{ {
self.details.focus(false); self.details.focus(false);
self.diff.focus(true); self.diff.focus(true);
} else if key_match(e, self.key_config.keys.move_left) } else if key_match(e, self.key_config.keys.move_left) {
{
self.hide_stacked(false); self.hide_stacked(false);
} else if key_match( } else if key_match(
e, e,

View file

@ -322,23 +322,13 @@ impl Component for OptionsPopup {
if let Event::Key(key) = &event { if let Event::Key(key) = &event {
if key_match(key, self.key_config.keys.exit_popup) { if key_match(key, self.key_config.keys.exit_popup) {
self.hide(); self.hide();
} else if key_match(key, self.key_config.keys.move_up) } else if self.key_config.is_nav_up(key) {
{
self.move_selection(true); self.move_selection(true);
} else if key_match( } else if self.key_config.is_nav_down(key) {
key,
self.key_config.keys.move_down,
) {
self.move_selection(false); self.move_selection(false);
} else if key_match( } else if self.key_config.is_nav_right(key) {
key,
self.key_config.keys.move_right,
) {
self.switch_option(true); self.switch_option(true);
} else if key_match( } else if key_match(key, self.key_config.keys.move_left) {
key,
self.key_config.keys.move_left,
) {
self.switch_option(false); self.switch_option(false);
} }
} }

View file

@ -192,11 +192,11 @@ impl RemoteListPopup {
fn move_event(&mut self, e: &KeyEvent) -> Result<EventState> { fn move_event(&mut self, e: &KeyEvent) -> Result<EventState> {
if key_match(e, self.key_config.keys.exit_popup) { if key_match(e, self.key_config.keys.exit_popup) {
self.hide(); self.hide();
} else if key_match(e, self.key_config.keys.move_down) { } else if self.key_config.is_nav_down(e) {
return self return self
.move_selection(ScrollType::Up) .move_selection(ScrollType::Up)
.map(Into::into); .map(Into::into);
} else if key_match(e, self.key_config.keys.move_up) { } else if self.key_config.is_nav_up(e) {
return self return self
.move_selection(ScrollType::Down) .move_selection(ScrollType::Down)
.map(Into::into); .map(Into::into);

View file

@ -230,13 +230,9 @@ impl Component for ResetPopup {
if let Event::Key(key) = &event { if let Event::Key(key) = &event {
if key_match(key, self.key_config.keys.exit_popup) { if key_match(key, self.key_config.keys.exit_popup) {
self.hide(); self.hide();
} else if key_match( } else if self.key_config.is_nav_down(key) {
key,
self.key_config.keys.move_down,
) {
self.change_kind(true); self.change_kind(true);
} else if key_match(key, self.key_config.keys.move_up) } else if self.key_config.is_nav_up(key) {
{
self.change_kind(false); self.change_kind(false);
} else if key_match(key, self.key_config.keys.enter) { } else if key_match(key, self.key_config.keys.enter) {
self.reset(); self.reset();

View file

@ -151,11 +151,11 @@ impl Component for SubmodulesListPopup {
if let Event::Key(e) = ev { if let Event::Key(e) = ev {
if key_match(e, self.key_config.keys.exit_popup) { if key_match(e, self.key_config.keys.exit_popup) {
self.hide(); self.hide();
} else if key_match(e, self.key_config.keys.move_down) { } else if self.key_config.is_nav_down(e) {
return self return self
.move_selection(ScrollType::Up) .move_selection(ScrollType::Up)
.map(Into::into); .map(Into::into);
} else if key_match(e, self.key_config.keys.move_up) { } else if self.key_config.is_nav_up(e) {
return self return self
.move_selection(ScrollType::Down) .move_selection(ScrollType::Down)
.map(Into::into); .map(Into::into);

View file

@ -187,13 +187,9 @@ impl Component for TagListPopup {
if let Event::Key(key) = event { if let Event::Key(key) = event {
if key_match(key, self.key_config.keys.exit_popup) { if key_match(key, self.key_config.keys.exit_popup) {
self.hide(); self.hide();
} else if key_match(key, self.key_config.keys.move_up) } else if self.key_config.is_nav_up(key) {
{
self.move_selection(ScrollType::Up); self.move_selection(ScrollType::Up);
} else if key_match( } else if self.key_config.is_nav_down(key) {
key,
self.key_config.keys.move_down,
) {
self.move_selection(ScrollType::Down); self.move_selection(ScrollType::Down);
} else if key_match( } else if key_match(
key, key,
@ -219,10 +215,8 @@ impl Component for TagListPopup {
} else if key_match(key, self.key_config.keys.page_up) } else if key_match(key, self.key_config.keys.page_up)
{ {
self.move_selection(ScrollType::PageUp); self.move_selection(ScrollType::PageUp);
} else if key_match( } else if self.key_config.is_nav_right(key)
key, && self.can_show_annotation()
self.key_config.keys.move_right,
) && self.can_show_annotation()
{ {
self.show_annotation(); self.show_annotation();
} else if key_match( } else if key_match(

View file

@ -494,10 +494,8 @@ impl Component for Revlog {
Ok(EventState::Consumed) Ok(EventState::Consumed)
}, },
); );
} else if key_match( } else if self.key_config.is_nav_right(k)
k, && self.commit_details.is_visible()
self.key_config.keys.move_right,
) && self.commit_details.is_visible()
{ {
self.inspect_commit(); self.inspect_commit();
return Ok(EventState::Consumed); return Ok(EventState::Consumed);

View file

@ -839,10 +839,8 @@ impl Component for Status {
{ {
self.switch_focus(self.focus.toggled_focus()) self.switch_focus(self.focus.toggled_focus())
.map(Into::into) .map(Into::into)
} else if key_match( } else if self.key_config.is_nav_right(k)
k, && self.can_focus_diff()
self.key_config.keys.move_right,
) && self.can_focus_diff()
{ {
self.switch_focus(Focus::Diff).map(Into::into) self.switch_focus(Focus::Diff).map(Into::into)
} else if key_match( } else if key_match(
@ -854,12 +852,12 @@ impl Component for Status {
DiffTarget::WorkingDir => Focus::WorkDir, DiffTarget::WorkingDir => Focus::WorkDir,
}) })
.map(Into::into) .map(Into::into)
} else if key_match(k, self.key_config.keys.move_down) } else if self.key_config.is_nav_down(k)
&& self.focus == Focus::WorkDir && self.focus == Focus::WorkDir
&& !self.index.is_empty() && !self.index.is_empty()
{ {
self.switch_focus(Focus::Stage).map(Into::into) self.switch_focus(Focus::Stage).map(Into::into)
} else if key_match(k, self.key_config.keys.move_up) } else if self.key_config.is_nav_up(k)
&& self.focus == Focus::Stage && self.focus == Focus::Stage
&& !self.index_wd.is_empty() && !self.index_wd.is_empty()
{ {

View file

@ -128,15 +128,15 @@ pub fn common_nav(
key: &crossterm::event::KeyEvent, key: &crossterm::event::KeyEvent,
key_config: &SharedKeyConfig, key_config: &SharedKeyConfig,
) -> Option<MoveSelection> { ) -> Option<MoveSelection> {
if key_match(key, key_config.keys.move_down) { if key_config.is_nav_down(key) {
Some(MoveSelection::Down) Some(MoveSelection::Down)
} else if key_match(key, key_config.keys.move_up) { } else if key_config.is_nav_up(key) {
Some(MoveSelection::Up) Some(MoveSelection::Up)
} else if key_match(key, key_config.keys.page_up) { } else if key_match(key, key_config.keys.page_up) {
Some(MoveSelection::PageUp) Some(MoveSelection::PageUp)
} else if key_match(key, key_config.keys.page_down) { } else if key_match(key, key_config.keys.page_down) {
Some(MoveSelection::PageDown) Some(MoveSelection::PageDown)
} else if key_match(key, key_config.keys.move_right) { } else if key_config.is_nav_right(key) {
Some(MoveSelection::Right) Some(MoveSelection::Right)
} else if key_match(key, key_config.keys.move_left) { } else if key_match(key, key_config.keys.move_left) {
Some(MoveSelection::Left) Some(MoveSelection::Left)