feat: Add complete keyboard navigation system

Implemented a full keyboard navigation system with directional commands (C-h/j/k/l) for moving between views,
vim-style navigation (j/k for rows, h/l for columns), and TAB cycling.
Added table header navigation mode where users can move between columns and change sort order with keyboard shortcuts.
The navigation follows visual position: left sidebar, table, and filter bar can all be reached with directional keys.

Created a keymap system with context-aware bindings that resolve commands based on
current focus (Table, TableHeaders, FilterBar, Sidebar).
Commands are dispatched through a centralized handler that manages focus transitions and view state.
Added helper methods to cycle through column headers with proper wrapping.

Auto-select first task on load when tasks exist, and set initial window focus so keyboard shortcuts
work immediately without requiring a mouse click.
This commit is contained in:
Ignacio Perez
2025-12-28 20:48:19 -03:00
parent 785e8ee932
commit 893e382d7f
18 changed files with 2445 additions and 87 deletions
+32
View File
@@ -0,0 +1,32 @@
use super::ContextId;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusTarget {
App,
Table,
TableHeaders,
SidebarProjects,
SidebarTags,
}
impl FocusTarget {
pub fn to_context(&self) -> ContextId {
match self {
Self::App => ContextId::Global,
Self::Table => ContextId::Table,
Self::TableHeaders => ContextId::TableHeaders,
Self::SidebarProjects => ContextId::SidebarProjects,
Self::SidebarTags => ContextId::SidebarTags,
}
}
pub fn is_sidebar(&self) -> bool {
matches!(self, Self::SidebarProjects | Self::SidebarTags)
}
}
impl Default for FocusTarget {
fn default() -> Self {
Self::App
}
}
+296
View File
@@ -0,0 +1,296 @@
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Key {
Char(char),
Enter,
Escape,
Backspace,
Delete,
Tab,
Space,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
PageUp,
PageDown,
Home,
End,
F1,
F2,
F3,
F4,
F5,
F6,
F7,
F8,
F9,
F10,
F11,
F12,
}
impl Key {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"enter" | "return" => Some(Self::Enter),
"esc" | "escape" => Some(Self::Escape),
"backspace" => Some(Self::Backspace),
"delete" | "del" => Some(Self::Delete),
"tab" => Some(Self::Tab),
"space" => Some(Self::Space),
"up" | "arrowup" => Some(Self::ArrowUp),
"down" | "arrowdown" => Some(Self::ArrowDown),
"left" | "arrowleft" => Some(Self::ArrowLeft),
"right" | "arrowright" => Some(Self::ArrowRight),
"pageup" => Some(Self::PageUp),
"pagedown" => Some(Self::PageDown),
"home" => Some(Self::Home),
"end" => Some(Self::End),
"f1" => Some(Self::F1),
"f2" => Some(Self::F2),
"f3" => Some(Self::F3),
"f4" => Some(Self::F4),
"f5" => Some(Self::F5),
"f6" => Some(Self::F6),
"f7" => Some(Self::F7),
"f8" => Some(Self::F8),
"f9" => Some(Self::F9),
"f10" => Some(Self::F10),
"f11" => Some(Self::F11),
"f12" => Some(Self::F12),
s if s.len() == 1 => s.chars().next().map(Self::Char),
_ => None,
}
}
}
impl fmt::Display for Key {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Char(c) => write!(f, "{}", c),
Self::Enter => write!(f, "Enter"),
Self::Escape => write!(f, "Esc"),
Self::Backspace => write!(f, "Backspace"),
Self::Delete => write!(f, "Del"),
Self::Tab => write!(f, "Tab"),
Self::Space => write!(f, "Space"),
Self::ArrowUp => write!(f, "Up"),
Self::ArrowDown => write!(f, "Down"),
Self::ArrowLeft => write!(f, "Left"),
Self::ArrowRight => write!(f, "Right"),
Self::PageUp => write!(f, "PageUp"),
Self::PageDown => write!(f, "PageDown"),
Self::Home => write!(f, "Home"),
Self::End => write!(f, "End"),
Self::F1 => write!(f, "F1"),
Self::F2 => write!(f, "F2"),
Self::F3 => write!(f, "F3"),
Self::F4 => write!(f, "F4"),
Self::F5 => write!(f, "F5"),
Self::F6 => write!(f, "F6"),
Self::F7 => write!(f, "F7"),
Self::F8 => write!(f, "F8"),
Self::F9 => write!(f, "F9"),
Self::F10 => write!(f, "F10"),
Self::F11 => write!(f, "F11"),
Self::F12 => write!(f, "F12"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Mods {
pub ctrl: bool,
pub alt: bool,
pub shift: bool,
pub platform: bool,
}
impl Mods {
pub fn none() -> Self {
Self::default()
}
pub fn ctrl() -> Self {
Self {
ctrl: true,
..Default::default()
}
}
pub fn alt() -> Self {
Self {
alt: true,
..Default::default()
}
}
pub fn shift() -> Self {
Self {
shift: true,
..Default::default()
}
}
pub fn platform() -> Self {
Self {
platform: true,
..Default::default()
}
}
}
impl fmt::Display for Mods {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut parts = Vec::new();
if self.platform {
parts.push("Cmd");
}
if self.ctrl {
parts.push("Ctrl");
}
if self.alt {
parts.push("Alt");
}
if self.shift {
parts.push("Shift");
}
write!(f, "{}", parts.join("+"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct KeyChord {
pub key: Key,
pub mods: Mods,
}
impl KeyChord {
pub fn new(key: Key, mods: Mods) -> Self {
Self { key, mods }
}
pub fn from_gpui(event: &gpui::KeyDownEvent) -> Option<Self> {
let key = Self::key_from_gpui(&event.keystroke.key)?;
let mods = Mods {
ctrl: event.keystroke.modifiers.control,
alt: event.keystroke.modifiers.alt,
shift: event.keystroke.modifiers.shift,
platform: event.keystroke.modifiers.platform,
};
Some(Self { key, mods })
}
fn key_from_gpui(key_str: &str) -> Option<Key> {
let normalized = key_str.to_lowercase();
match normalized.as_str() {
"enter" | "return" => Some(Key::Enter),
"escape" | "esc" => Some(Key::Escape),
"backspace" => Some(Key::Backspace),
"delete" | "del" => Some(Key::Delete),
"tab" => Some(Key::Tab),
" " | "space" => Some(Key::Space),
"arrowup" | "up" => Some(Key::ArrowUp),
"arrowdown" | "down" => Some(Key::ArrowDown),
"arrowleft" | "left" => Some(Key::ArrowLeft),
"arrowright" | "right" => Some(Key::ArrowRight),
"pageup" => Some(Key::PageUp),
"pagedown" => Some(Key::PageDown),
"home" => Some(Key::Home),
"end" => Some(Key::End),
"f1" => Some(Key::F1),
"f2" => Some(Key::F2),
"f3" => Some(Key::F3),
"f4" => Some(Key::F4),
"f5" => Some(Key::F5),
"f6" => Some(Key::F6),
"f7" => Some(Key::F7),
"f8" => Some(Key::F8),
"f9" => Some(Key::F9),
"f10" => Some(Key::F10),
"f11" => Some(Key::F11),
"f12" => Some(Key::F12),
s if s.len() == 1 => s.chars().next().map(Key::Char),
_ => {
if key_str.len() == 1 {
key_str.chars().next().map(Key::Char)
} else {
None
}
}
}
}
pub fn parse(s: &str) -> Option<Self> {
let parts: Vec<&str> = s.split('+').collect();
if parts.is_empty() {
return None;
}
let mut mods = Mods::none();
let key_str = parts.last()?;
for part in &parts[..parts.len() - 1] {
match part.to_lowercase().as_str() {
"ctrl" => mods.ctrl = true,
"alt" => mods.alt = true,
"shift" => mods.shift = true,
"cmd" | "super" | "platform" => mods.platform = true,
_ => return None,
}
}
let key = Key::from_str(key_str)?;
Some(Self { key, mods })
}
}
impl fmt::Display for KeyChord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.mods.ctrl || self.mods.alt || self.mods.shift || self.mods.platform {
write!(f, "{}+{}", self.mods, self.key)
} else {
write!(f, "{}", self.key)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_key() {
let chord = KeyChord::parse("j").unwrap();
assert_eq!(chord.key, Key::Char('j'));
assert_eq!(chord.mods, Mods::none());
}
#[test]
fn test_parse_ctrl_key() {
let chord = KeyChord::parse("ctrl+f").unwrap();
assert_eq!(chord.key, Key::Char('f'));
assert!(chord.mods.ctrl);
}
#[test]
fn test_parse_platform_key() {
let chord = KeyChord::parse("cmd+r").unwrap();
assert_eq!(chord.key, Key::Char('r'));
assert!(chord.mods.platform);
}
#[test]
fn test_parse_special_key() {
let chord = KeyChord::parse("enter").unwrap();
assert_eq!(chord.key, Key::Enter);
}
#[test]
fn test_to_string() {
let chord = KeyChord::new(Key::Char('f'), Mods::ctrl());
assert_eq!(chord.to_string(), "Ctrl+f");
}
}
+131
View File
@@ -0,0 +1,131 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Command {
// Navigation
SelectNextRow,
SelectPrevRow,
SelectFirstRow,
SelectLastRow,
NextPage,
PrevPage,
ClearSelection,
// Actions
OpenSelectedTask,
Sync,
// Focus
FocusSearch,
FocusTable,
FocusTableHeaders,
FocusSidebar,
FocusSidebarProjects,
FocusSidebarTags,
BlurInput,
// Modal
CloseModal,
SaveModal,
// Filter
ApplySearch,
ClearFilters,
ClearAllFilters,
ClearProjectFilter,
ClearTagFilter,
ClearSearchAndDropdowns,
FocusFilterNext,
FocusFilterPrev,
ToggleDropdown,
SelectNextOption,
SelectPrevOption,
// Projects
ExpandProject,
CollapseProject,
// Table Headers
HeaderMoveNext,
HeaderMovePrev,
HeaderCycleSortOrder,
}
impl Command {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"SelectNextRow" => Some(Self::SelectNextRow),
"SelectPrevRow" => Some(Self::SelectPrevRow),
"SelectFirstRow" => Some(Self::SelectFirstRow),
"SelectLastRow" => Some(Self::SelectLastRow),
"NextPage" => Some(Self::NextPage),
"PrevPage" => Some(Self::PrevPage),
"ClearSelection" => Some(Self::ClearSelection),
"OpenSelectedTask" => Some(Self::OpenSelectedTask),
"Sync" => Some(Self::Sync),
"FocusSearch" => Some(Self::FocusSearch),
"FocusTable" => Some(Self::FocusTable),
"FocusTableHeaders" => Some(Self::FocusTableHeaders),
"FocusSidebar" => Some(Self::FocusSidebar),
"FocusSidebarProjects" => Some(Self::FocusSidebarProjects),
"FocusSidebarTags" => Some(Self::FocusSidebarTags),
"BlurInput" => Some(Self::BlurInput),
"CloseModal" => Some(Self::CloseModal),
"SaveModal" => Some(Self::SaveModal),
"ApplySearch" => Some(Self::ApplySearch),
"ClearFilters" => Some(Self::ClearFilters),
"ClearAllFilters" => Some(Self::ClearAllFilters),
"ClearProjectFilter" => Some(Self::ClearProjectFilter),
"ClearTagFilter" => Some(Self::ClearTagFilter),
"ClearSearchAndDropdowns" => Some(Self::ClearSearchAndDropdowns),
"FocusFilterNext" => Some(Self::FocusFilterNext),
"FocusFilterPrev" => Some(Self::FocusFilterPrev),
"ToggleDropdown" => Some(Self::ToggleDropdown),
"SelectNextOption" => Some(Self::SelectNextOption),
"SelectPrevOption" => Some(Self::SelectPrevOption),
"ExpandProject" => Some(Self::ExpandProject),
"CollapseProject" => Some(Self::CollapseProject),
"HeaderMoveNext" => Some(Self::HeaderMoveNext),
"HeaderMovePrev" => Some(Self::HeaderMovePrev),
"HeaderCycleSortOrder" => Some(Self::HeaderCycleSortOrder),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::SelectNextRow => "SelectNextRow",
Self::SelectPrevRow => "SelectPrevRow",
Self::SelectFirstRow => "SelectFirstRow",
Self::SelectLastRow => "SelectLastRow",
Self::NextPage => "NextPage",
Self::PrevPage => "PrevPage",
Self::ClearSelection => "ClearSelection",
Self::OpenSelectedTask => "OpenSelectedTask",
Self::Sync => "Sync",
Self::FocusSearch => "FocusSearch",
Self::FocusTable => "FocusTable",
Self::FocusTableHeaders => "FocusTableHeaders",
Self::FocusSidebar => "FocusSidebar",
Self::FocusSidebarProjects => "FocusSidebarProjects",
Self::FocusSidebarTags => "FocusSidebarTags",
Self::BlurInput => "BlurInput",
Self::CloseModal => "CloseModal",
Self::SaveModal => "SaveModal",
Self::ApplySearch => "ApplySearch",
Self::ClearFilters => "ClearFilters",
Self::ClearAllFilters => "ClearAllFilters",
Self::ClearProjectFilter => "ClearProjectFilter",
Self::ClearTagFilter => "ClearTagFilter",
Self::ClearSearchAndDropdowns => "ClearSearchAndDropdowns",
Self::FocusFilterNext => "FocusFilterNext",
Self::FocusFilterPrev => "FocusFilterPrev",
Self::ToggleDropdown => "ToggleDropdown",
Self::SelectNextOption => "SelectNextOption",
Self::SelectPrevOption => "SelectPrevOption",
Self::ExpandProject => "ExpandProject",
Self::CollapseProject => "CollapseProject",
Self::HeaderMoveNext => "HeaderMoveNext",
Self::HeaderMovePrev => "HeaderMovePrev",
Self::HeaderCycleSortOrder => "HeaderCycleSortOrder",
}
}
}
+40
View File
@@ -0,0 +1,40 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ContextId {
Global,
Table,
TableHeaders,
SidebarProjects,
SidebarTags,
Modal,
FilterBar,
TextInput,
}
impl ContextId {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"global" | "Global" => Some(Self::Global),
"table" | "Table" => Some(Self::Table),
"tableheaders" | "TableHeaders" => Some(Self::TableHeaders),
"sidebarprojects" | "SidebarProjects" => Some(Self::SidebarProjects),
"sidebartags" | "SidebarTags" => Some(Self::SidebarTags),
"modal" | "Modal" => Some(Self::Modal),
"filterbar" | "FilterBar" => Some(Self::FilterBar),
"textinput" | "TextInput" => Some(Self::TextInput),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Global => "Global",
Self::Table => "Table",
Self::TableHeaders => "TableHeaders",
Self::SidebarProjects => "SidebarProjects",
Self::SidebarTags => "SidebarTags",
Self::Modal => "Modal",
Self::FilterBar => "FilterBar",
Self::TextInput => "TextInput",
}
}
}
+413
View File
@@ -0,0 +1,413 @@
use super::{Command, ContextId, Key, KeyChord, KeymapLayer, Mods};
pub fn build_default_keymap() -> KeymapLayer {
let mut layer = KeymapLayer::new();
// Global bindings
layer.bind(
ContextId::Global,
KeyChord::new(Key::Char('r'), Mods::ctrl()),
Command::Sync,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Char('f'), Mods::ctrl()),
Command::FocusSearch,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Escape, Mods::none()),
Command::CloseModal,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Tab, Mods::none()),
Command::FocusTable,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Tab, Mods::shift()),
Command::FocusSidebar,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Char('c'), Mods::ctrl()),
Command::ClearAllFilters,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Char('p'), Mods::ctrl()),
Command::ClearProjectFilter,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Char('t'), Mods::ctrl()),
Command::ClearTagFilter,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Char('x'), Mods::ctrl()),
Command::ClearSearchAndDropdowns,
);
// Table navigation
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('h'), Mods::ctrl()),
Command::FocusSidebarProjects,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('k'), Mods::ctrl()),
Command::FocusTableHeaders,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('j'), Mods::none()),
Command::SelectNextRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('k'), Mods::none()),
Command::SelectPrevRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::ArrowDown, Mods::none()),
Command::SelectNextRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::ArrowUp, Mods::none()),
Command::SelectPrevRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('g'), Mods::none()),
Command::SelectFirstRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('g'), Mods::shift()),
Command::SelectLastRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Home, Mods::none()),
Command::SelectFirstRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::End, Mods::none()),
Command::SelectLastRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::PageDown, Mods::none()),
Command::NextPage,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::PageUp, Mods::none()),
Command::PrevPage,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('l'), Mods::none()),
Command::NextPage,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('h'), Mods::none()),
Command::PrevPage,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Escape, Mods::none()),
Command::ClearSelection,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Enter, Mods::none()),
Command::OpenSelectedTask,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::ArrowLeft, Mods::none()),
Command::CollapseProject,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::ArrowRight, Mods::none()),
Command::ExpandProject,
);
// Table Headers navigation
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Char('h'), Mods::none()),
Command::HeaderMovePrev,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Char('l'), Mods::none()),
Command::HeaderMoveNext,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::ArrowLeft, Mods::none()),
Command::HeaderMovePrev,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::ArrowRight, Mods::none()),
Command::HeaderMoveNext,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Char('j'), Mods::none()),
Command::HeaderCycleSortOrder,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Char('k'), Mods::none()),
Command::HeaderCycleSortOrder,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Enter, Mods::none()),
Command::HeaderCycleSortOrder,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Space, Mods::none()),
Command::HeaderCycleSortOrder,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Char('j'), Mods::ctrl()),
Command::FocusTable,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Char('k'), Mods::ctrl()),
Command::FocusSearch,
);
// Sidebar Projects navigation
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('l'), Mods::ctrl()),
Command::FocusTable,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('j'), Mods::ctrl()),
Command::FocusSidebarTags,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('j'), Mods::none()),
Command::SelectNextRow,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('k'), Mods::none()),
Command::SelectPrevRow,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::ArrowDown, Mods::none()),
Command::SelectNextRow,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::ArrowUp, Mods::none()),
Command::SelectPrevRow,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('g'), Mods::none()),
Command::SelectFirstRow,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('g'), Mods::shift()),
Command::SelectLastRow,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('h'), Mods::none()),
Command::CollapseProject,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('l'), Mods::none()),
Command::ExpandProject,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::ArrowLeft, Mods::none()),
Command::CollapseProject,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::ArrowRight, Mods::none()),
Command::ExpandProject,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Enter, Mods::none()),
Command::OpenSelectedTask,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Space, Mods::none()),
Command::OpenSelectedTask,
);
// Sidebar Tags navigation
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Char('l'), Mods::ctrl()),
Command::FocusTable,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Char('k'), Mods::ctrl()),
Command::FocusSidebarProjects,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Char('j'), Mods::none()),
Command::SelectNextRow,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Char('k'), Mods::none()),
Command::SelectPrevRow,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::ArrowDown, Mods::none()),
Command::SelectNextRow,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::ArrowUp, Mods::none()),
Command::SelectPrevRow,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Char('g'), Mods::none()),
Command::SelectFirstRow,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Char('g'), Mods::shift()),
Command::SelectLastRow,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Enter, Mods::none()),
Command::OpenSelectedTask,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Space, Mods::none()),
Command::OpenSelectedTask,
);
// TextInput / FilterBar
layer.bind(
ContextId::TextInput,
KeyChord::new(Key::Escape, Mods::none()),
Command::BlurInput,
);
layer.bind(
ContextId::TextInput,
KeyChord::new(Key::Enter, Mods::none()),
Command::ApplySearch,
);
layer.bind(
ContextId::TextInput,
KeyChord::new(Key::Char('l'), Mods::ctrl()),
Command::FocusFilterNext,
);
layer.bind(
ContextId::TextInput,
KeyChord::new(Key::Char('h'), Mods::ctrl()),
Command::FocusFilterPrev,
);
layer.bind(
ContextId::TextInput,
KeyChord::new(Key::Char('j'), Mods::ctrl()),
Command::FocusTableHeaders,
);
// FilterBar (when focus is on dropdowns)
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Enter, Mods::none()),
Command::ToggleDropdown,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Space, Mods::none()),
Command::ToggleDropdown,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Char('j'), Mods::none()),
Command::SelectNextOption,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Char('k'), Mods::none()),
Command::SelectPrevOption,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::ArrowDown, Mods::none()),
Command::SelectNextOption,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::ArrowUp, Mods::none()),
Command::SelectPrevOption,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Char('l'), Mods::ctrl()),
Command::FocusFilterNext,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Char('h'), Mods::ctrl()),
Command::FocusFilterPrev,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Char('j'), Mods::ctrl()),
Command::FocusTableHeaders,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Escape, Mods::none()),
Command::BlurInput,
);
// Modal
layer.bind(
ContextId::Modal,
KeyChord::new(Key::Escape, Mods::none()),
Command::CloseModal,
);
layer.bind(
ContextId::Modal,
KeyChord::new(Key::Enter, Mods::ctrl()),
Command::SaveModal,
);
layer
}
+5
View File
@@ -0,0 +1,5 @@
use super::Command;
pub trait CommandDispatcher: Sized {
fn dispatch(&mut self, command: Command, cx: &mut gpui::Context<Self>) -> bool;
}
+131
View File
@@ -0,0 +1,131 @@
use std::collections::HashMap;
use super::{Command, ContextId, KeyChord};
#[derive(Debug, Clone)]
pub struct KeymapLayer {
bindings: HashMap<ContextId, HashMap<KeyChord, Command>>,
}
impl KeymapLayer {
pub fn new() -> Self {
Self {
bindings: HashMap::new(),
}
}
pub fn bind(&mut self, context: ContextId, chord: KeyChord, command: Command) {
self.bindings
.entry(context)
.or_insert_with(HashMap::new)
.insert(chord, command);
}
pub fn resolve(&self, context: ContextId, chord: &KeyChord) -> Option<Command> {
self.bindings
.get(&context)
.and_then(|map| map.get(chord))
.copied()
}
}
impl Default for KeymapLayer {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct KeymapStack {
layers: Vec<KeymapLayer>,
}
impl KeymapStack {
pub fn new() -> Self {
Self { layers: Vec::new() }
}
pub fn push_layer(&mut self, layer: KeymapLayer) {
self.layers.push(layer);
}
pub fn pop_layer(&mut self) -> Option<KeymapLayer> {
self.layers.pop()
}
pub fn resolve(&self, context: ContextId, chord: &KeyChord) -> Option<Command> {
for layer in self.layers.iter().rev() {
if let Some(cmd) = layer.resolve(context, chord) {
return Some(cmd);
}
}
if context != ContextId::Global {
for layer in self.layers.iter().rev() {
if let Some(cmd) = layer.resolve(ContextId::Global, chord) {
return Some(cmd);
}
}
}
None
}
}
impl Default for KeymapStack {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keymap::{Key, Mods};
#[test]
fn test_layer_bind_and_resolve() {
let mut layer = KeymapLayer::new();
let chord = KeyChord::new(Key::Char('j'), Mods::none());
layer.bind(ContextId::Table, chord, Command::SelectNextRow);
assert_eq!(
layer.resolve(ContextId::Table, &chord),
Some(Command::SelectNextRow)
);
assert_eq!(layer.resolve(ContextId::Global, &chord), None);
}
#[test]
fn test_stack_fallback_to_global() {
let mut stack = KeymapStack::new();
let mut layer = KeymapLayer::new();
let chord = KeyChord::new(Key::Char('r'), Mods::platform());
layer.bind(ContextId::Global, chord, Command::Sync);
stack.push_layer(layer);
assert_eq!(stack.resolve(ContextId::Table, &chord), Some(Command::Sync));
}
#[test]
fn test_stack_user_override() {
let mut stack = KeymapStack::new();
let mut default_layer = KeymapLayer::new();
let chord = KeyChord::new(Key::Char('j'), Mods::none());
default_layer.bind(ContextId::Table, chord, Command::SelectNextRow);
stack.push_layer(default_layer);
let mut user_layer = KeymapLayer::new();
user_layer.bind(ContextId::Table, chord, Command::SelectPrevRow);
stack.push_layer(user_layer);
assert_eq!(
stack.resolve(ContextId::Table, &chord),
Some(Command::SelectPrevRow)
);
}
}
+109
View File
@@ -0,0 +1,109 @@
mod active_context;
mod chord;
mod command;
mod context;
pub mod defaults;
mod dispatcher;
mod keymap;
pub use active_context::FocusTarget;
pub use chord::{Key, KeyChord, Mods};
pub use command::Command;
pub use context::ContextId;
pub use dispatcher::CommandDispatcher;
pub use keymap::{KeymapLayer, KeymapStack};
// Legacy compatibility - will be removed after refactor
use gpui::Modifiers;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyBinding {
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
PageUp,
PageDown,
Home,
End,
Enter,
Escape,
Refresh,
Focus,
}
impl KeyBinding {
pub fn from_keystroke(key: &str, modifiers: &Modifiers) -> Option<Self> {
if modifiers.platform {
return match key {
"r" | "R" => Some(Self::Refresh),
"f" | "F" => Some(Self::Focus),
_ => None,
};
}
if modifiers.control || modifiers.alt || modifiers.shift {
return None;
}
match key {
"ArrowUp" => Some(Self::ArrowUp),
"ArrowDown" => Some(Self::ArrowDown),
"ArrowLeft" => Some(Self::ArrowLeft),
"ArrowRight" => Some(Self::ArrowRight),
"PageUp" => Some(Self::PageUp),
"PageDown" => Some(Self::PageDown),
"Home" => Some(Self::Home),
"End" => Some(Self::End),
"Enter" => Some(Self::Enter),
"Escape" => Some(Self::Escape),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TableAction {
SelectNext,
SelectPrevious,
NextPage,
PreviousPage,
SelectFirst,
SelectLast,
ClearSelection,
ExpandProject,
CollapseProject,
}
impl TableAction {
pub fn from_binding(binding: &KeyBinding) -> Option<Self> {
match binding {
KeyBinding::ArrowUp => Some(Self::SelectPrevious),
KeyBinding::ArrowDown => Some(Self::SelectNext),
KeyBinding::PageUp => Some(Self::PreviousPage),
KeyBinding::PageDown => Some(Self::NextPage),
KeyBinding::Home => Some(Self::SelectFirst),
KeyBinding::End => Some(Self::SelectLast),
KeyBinding::Escape => Some(Self::ClearSelection),
KeyBinding::ArrowLeft => Some(Self::CollapseProject),
KeyBinding::ArrowRight => Some(Self::ExpandProject),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GlobalAction {
Refresh,
FocusSearch,
}
impl GlobalAction {
pub fn from_binding(binding: &KeyBinding) -> Option<Self> {
match binding {
KeyBinding::Refresh => Some(Self::Refresh),
KeyBinding::Focus => Some(Self::FocusSearch),
_ => None,
}
}
}