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
+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
}