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
+334 -4
View File
@@ -2,16 +2,20 @@ use std::collections::HashMap;
use gpui::prelude::*;
use crate::keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord, KeymapStack};
use crate::models::{FilterState, ProjectTree};
use crate::task::{self, TaskOverview, TaskService};
use crate::theme::ActiveTheme;
use crate::ui::{ROOT_PADDING, SECTION_GAP, SIDEBAR_WIDTH, card_style};
use crate::ui::{ROOT_PADDING, SECTION_GAP, SIDEBAR_WIDTH};
use crate::view::sidebar::{Sidebar, TagItem};
use crate::view::status_bar::{StatusBar, StatusBarEvent, SyncState};
use crate::view::task_table::TaskTable;
use gpui::div;
pub(crate) struct App {
focus_handle: gpui::FocusHandle,
focus_target: FocusTarget,
keymap: KeymapStack,
sidebar: gpui::Entity<Sidebar>,
filter_state: gpui::Entity<FilterState>,
status_bar: gpui::Entity<StatusBar>,
@@ -27,19 +31,69 @@ impl gpui::Render for App {
) -> impl gpui::IntoElement {
let theme = cx.theme();
let sidebar = card_style(div(), theme)
let sidebar_focused = self.focus_target.is_sidebar();
let sidebar_border_color = if sidebar_focused {
theme.focus_ring
} else {
theme.divider
};
let sidebar = div()
.bg(theme.card)
.border_2()
.border_color(sidebar_border_color)
.rounded(crate::ui::CARD_RADIUS)
.p(crate::ui::CARD_PADDING)
.w(SIDEBAR_WIDTH)
.h_full()
.flex_shrink_0()
.overflow_hidden()
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|app, _event, _window, cx| {
if !app.focus_target.is_sidebar() {
app.focus_target = FocusTarget::SidebarProjects;
app.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx);
});
cx.notify();
}
}),
)
.child(self.sidebar.clone());
let main = card_style(div(), theme)
let table_focused = matches!(
self.focus_target,
FocusTarget::Table | FocusTarget::TableHeaders
);
let table_border_color = if table_focused {
theme.focus_ring
} else {
theme.divider
};
let main = div()
.bg(theme.card)
.border_2()
.border_color(table_border_color)
.rounded(crate::ui::CARD_RADIUS)
.p(crate::ui::CARD_PADDING)
.flex_1()
.h_full()
.min_w_0()
.overflow_hidden()
.p_0()
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|app, _event, _window, cx| {
if !matches!(app.focus_target, FocusTarget::Table) {
app.focus_target = FocusTarget::Table;
cx.notify();
}
}),
)
.child(self.task_table.clone());
let content = div()
@@ -57,11 +111,187 @@ impl gpui::Render for App {
.bg(theme.background)
.p(ROOT_PADDING)
.gap(SECTION_GAP)
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(|app, event, window, cx| {
app.handle_key_down(event, window, cx);
}))
.child(content)
.child(self.status_bar.clone())
}
}
impl CommandDispatcher for App {
fn dispatch(&mut self, command: Command, cx: &mut gpui::Context<Self>) -> bool {
match command {
Command::Sync => {
self.handle_sync(cx);
true
}
Command::FocusSearch => false,
Command::FocusTable => {
self.focus_target = match self.focus_target {
FocusTarget::Table => {
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx);
});
FocusTarget::SidebarProjects
}
FocusTarget::SidebarProjects => {
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx);
});
FocusTarget::SidebarTags
}
_ => FocusTarget::Table,
};
cx.notify();
true
}
Command::FocusSidebar => {
self.focus_target = match self.focus_target {
FocusTarget::Table => {
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx);
});
FocusTarget::SidebarTags
}
FocusTarget::SidebarTags => {
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx);
});
FocusTarget::SidebarProjects
}
_ => FocusTarget::Table,
};
cx.notify();
true
}
Command::FocusSidebarProjects => {
self.focus_target = FocusTarget::SidebarProjects;
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx);
});
cx.notify();
true
}
Command::FocusSidebarTags => {
self.focus_target = FocusTarget::SidebarTags;
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx);
});
cx.notify();
true
}
Command::SelectNextRow
| Command::SelectPrevRow
| Command::SelectFirstRow
| Command::SelectLastRow => {
match self.focus_target {
FocusTarget::SidebarProjects | FocusTarget::SidebarTags => {
self.sidebar
.update(cx, |sidebar, cx| sidebar.dispatch(command, cx));
}
_ => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
}
}
true
}
Command::OpenSelectedTask => {
match self.focus_target {
FocusTarget::SidebarProjects | FocusTarget::SidebarTags => {
self.sidebar
.update(cx, |sidebar, cx| sidebar.dispatch(command, cx));
}
_ => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
}
}
true
}
Command::ExpandProject | Command::CollapseProject => {
match self.focus_target {
FocusTarget::SidebarProjects => {
self.sidebar
.update(cx, |sidebar, cx| sidebar.dispatch(command, cx));
}
_ => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
}
}
true
}
Command::NextPage | Command::PrevPage | Command::ClearSelection => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
true
}
Command::ToggleDropdown
| Command::SelectNextOption
| Command::SelectPrevOption
| Command::BlurInput => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
true
}
Command::ClearAllFilters => {
self.filter_state.update(cx, |state, cx| {
state.clear();
cx.notify();
});
true
}
Command::ClearProjectFilter => {
self.filter_state.update(cx, |state, cx| {
state.clear_project();
cx.notify();
});
true
}
Command::ClearTagFilter => {
self.filter_state.update(cx, |state, cx| {
state.clear_tags();
cx.notify();
});
true
}
Command::ClearSearchAndDropdowns => {
self.filter_state.update(cx, |state, cx| {
state.clear_search_and_dropdowns();
cx.notify();
});
self.task_table.update(cx, |table, cx| {
table.clear_search_input(cx);
table.reset_dropdowns(cx);
});
true
}
Command::HeaderMoveNext => {
self.task_table.update(cx, |table, cx| {
table.header_move_next(cx);
});
true
}
Command::HeaderMovePrev => {
self.task_table.update(cx, |table, cx| {
table.header_move_prev(cx);
});
true
}
Command::HeaderCycleSortOrder => {
self.task_table.update(cx, |table, cx| {
table.header_cycle_sort_order(cx);
});
true
}
_ => false,
}
}
}
impl App {
fn build_sidebar_data(tasks: &[task::Task]) -> (Vec<(String, usize)>, Vec<TagItem>) {
let mut project_counts: HashMap<String, usize> = HashMap::new();
@@ -154,6 +384,98 @@ impl App {
}
}
fn handle_key_down(
&mut self,
event: &gpui::KeyDownEvent,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) {
if let Some(chord) = KeyChord::from_gpui(event) {
let context = self.active_context(cx);
if let Some(command) = self.keymap.resolve(context, &chord) {
match command {
Command::FocusSearch => {
let from_headers = matches!(self.focus_target, FocusTarget::TableHeaders);
self.focus_target = FocusTarget::Table;
self.task_table.update(cx, |table, cx| {
if from_headers {
table.blur_table_headers(cx);
}
table.focus_search_input(window, cx);
});
cx.notify();
}
Command::FocusTableHeaders => {
self.focus_target = FocusTarget::TableHeaders;
self.task_table.update(cx, |table, cx| {
table.blur_search_input(window, cx);
table.set_filter_bar_focus(
crate::view::task_table::FilterBarFocus::None,
cx,
);
table.focus_table_headers(window, cx);
});
cx.notify();
}
Command::FocusTable => {
self.focus_target = FocusTarget::Table;
self.task_table.update(cx, |table, cx| match context {
ContextId::TextInput | ContextId::FilterBar => {
table.blur_search_input(window, cx);
table.set_filter_bar_focus(
crate::view::task_table::FilterBarFocus::None,
cx,
);
}
ContextId::TableHeaders => {
table.blur_table_headers(cx);
}
_ => {}
});
cx.notify();
}
Command::FocusFilterNext | Command::FocusFilterPrev => {
self.task_table.update(cx, |table, cx| {
use crate::view::task_table::FilterBarFocus;
let was_on_input =
matches!(table.get_filter_bar_focus(), FilterBarFocus::SearchInput);
if command == Command::FocusFilterNext {
table.focus_filter_next(cx);
} else {
table.focus_filter_prev(cx);
}
if was_on_input {
table.blur_search_input(window, cx);
}
let now_on_input =
matches!(table.get_filter_bar_focus(), FilterBarFocus::SearchInput);
if now_on_input && !was_on_input {
table.focus_search_input(window, cx);
}
});
}
_ => {
self.dispatch(command, cx);
}
}
}
}
}
fn active_context(&self, cx: &gpui::Context<Self>) -> ContextId {
if matches!(self.focus_target, FocusTarget::Table) {
let filter_context = self.task_table.read(cx).get_active_filter_context();
if let Some(context) = filter_context {
return context;
}
}
self.focus_target.to_context()
}
pub fn run() -> () {
let app = gpui::Application::new();
@@ -161,7 +483,7 @@ impl App {
app.set_global(crate::theme::Theme::dark());
app.open_window(
gpui::WindowOptions::default(),
|_window: &mut gpui::Window, app: &mut gpui::App| {
|window: &mut gpui::Window, app: &mut gpui::App| {
app.new(|cx: &mut gpui::Context<'_, App>| {
let filter_state = cx.new(|_cx| FilterState::new());
@@ -201,7 +523,13 @@ impl App {
table.reload_tasks(&mut task_service, cx);
});
let mut keymap = KeymapStack::new();
keymap.push_layer(crate::keymap::defaults::build_default_keymap());
let app = App {
focus_handle: cx.focus_handle(),
focus_target: FocusTarget::Table,
keymap,
sidebar,
filter_state: filter_state.clone(),
status_bar: status_bar.clone(),
@@ -209,6 +537,8 @@ impl App {
task_service,
};
window.focus(&app.focus_handle);
cx.observe(&filter_state, |app, _, cx| {
app.reload_tasks(cx);
})