From 893e382d7fbc120d08490e72109d302b470d82d6 Mon Sep 17 00:00:00 2001 From: Ignacio Perez Date: Sun, 28 Dec 2025 20:48:19 -0300 Subject: [PATCH] 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. --- Cargo.lock | 124 ++++--- src/app.rs | 338 ++++++++++++++++++- src/components/button/dropdown.rs | 40 +++ src/components/input/mod.rs | 9 + src/keymap/active_context.rs | 32 ++ src/keymap/chord.rs | 296 +++++++++++++++++ src/keymap/command.rs | 131 ++++++++ src/keymap/context.rs | 40 +++ src/keymap/defaults.rs | 413 +++++++++++++++++++++++ src/keymap/dispatcher.rs | 5 + src/keymap/keymap.rs | 131 ++++++++ src/keymap/mod.rs | 109 ++++++ src/main.rs | 1 + src/models/filter_state.rs | 15 + src/models/project_tree.rs | 15 + src/view/sidebar.rs | 294 +++++++++++++++- src/view/status_bar.rs | 6 +- src/view/task_table.rs | 533 ++++++++++++++++++++++++++++-- 18 files changed, 2445 insertions(+), 87 deletions(-) create mode 100644 src/keymap/active_context.rs create mode 100644 src/keymap/chord.rs create mode 100644 src/keymap/command.rs create mode 100644 src/keymap/context.rs create mode 100644 src/keymap/defaults.rs create mode 100644 src/keymap/dispatcher.rs create mode 100644 src/keymap/keymap.rs create mode 100644 src/keymap/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 45534c3..fd28880 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -344,7 +344,7 @@ dependencies = [ "futures-lite 2.6.1", "parking", "polling", - "rustix 1.1.2", + "rustix 1.1.3", "slab", "windows-sys 0.61.2", ] @@ -386,7 +386,7 @@ dependencies = [ "cfg-if", "event-listener 5.4.1", "futures-lite 2.6.1", - "rustix 1.1.2", + "rustix 1.1.3", ] [[package]] @@ -412,7 +412,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.2", + "rustix 1.1.3", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -1313,9 +1313,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.50" +version = "1.2.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" dependencies = [ "find-msvc-tools", "jobserver", @@ -2372,9 +2372,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" [[package]] name = "flate2" @@ -2670,7 +2670,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.2", + "rustix 1.1.3", "windows-link 0.2.1", ] @@ -3470,7 +3470,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -3605,7 +3605,7 @@ dependencies = [ "rgb", "tiff", "zune-core 0.5.0", - "zune-jpeg 0.5.7", + "zune-jpeg 0.5.8", ] [[package]] @@ -3754,9 +3754,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" @@ -3911,13 +3911,13 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall 0.6.0", + "redox_syscall 0.7.0", ] [[package]] @@ -4839,7 +4839,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.2", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -4851,9 +4851,9 @@ checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" [[package]] name = "portable-atomic" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" [[package]] name = "portable-atomic-util" @@ -4948,9 +4948,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" dependencies = [ "unicode-ident", ] @@ -5279,9 +5279,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" dependencies = [ "bitflags 2.10.0", ] @@ -5561,9 +5561,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.10.0", "errno", @@ -5696,9 +5696,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "same-file" @@ -5720,9 +5720,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" dependencies = [ "dyn-clone", "indexmap", @@ -5734,9 +5734,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301858a4023d78debd2353c7426dc486001bddc91ae31a76fb1f55132f7e2633" +checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" dependencies = [ "proc-macro2", "quote", @@ -5896,9 +5896,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.147" +version = "1.0.148" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af14725505314343e673e9ecb7cd7e8a36aa9791eb936235a3567cc31447ae4" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" dependencies = [ "indexmap", "itoa", @@ -5998,10 +5998,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -6496,7 +6497,7 @@ dependencies = [ [[package]] name = "task-warrior-gpui" -version = "0.1.0" +version = "0.1.2" dependencies = [ "chrono", "dirs 6.0.0", @@ -6539,14 +6540,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand 2.3.0", "getrandom 0.3.4", "once_cell", - "rustix 1.1.2", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -7386,7 +7387,7 @@ checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.2", + "rustix 1.1.3", "scoped-tls", "smallvec", "wayland-sys", @@ -7399,7 +7400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ "bitflags 2.10.0", - "rustix 1.1.2", + "rustix 1.1.3", "wayland-backend", "wayland-scanner", ] @@ -7410,7 +7411,7 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" dependencies = [ - "rustix 1.1.2", + "rustix 1.1.3", "wayland-client", "xcursor", ] @@ -7632,6 +7633,19 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-future" version = "0.2.1" @@ -7749,6 +7763,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.3.1" @@ -7767,6 +7790,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -8082,7 +8114,7 @@ dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "rustix 1.1.2", + "rustix 1.1.3", "x11rb-protocol", "xcursor", ] @@ -8502,9 +8534,9 @@ dependencies = [ [[package]] name = "zmij" -version = "0.1.7" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e404bcd8afdaf006e529269d3e85a743f9480c3cef60034d77860d02964f3ba" +checksum = "0f4a4e8e9dc5c62d159f04fcdbe07f4c3fb710415aab4754bf11505501e3251d" [[package]] name = "zune-core" @@ -8538,9 +8570,9 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d915729b0e7d5fe35c2f294c5dc10b30207cc637920e5b59077bfa3da63f28" +checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5" dependencies = [ "zune-core 0.5.0", ] diff --git a/src/app.rs b/src/app.rs index 64ae730..8fe5acb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, filter_state: gpui::Entity, status_bar: gpui::Entity, @@ -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) -> 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) { let mut project_counts: HashMap = 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, + ) { + 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) -> 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); }) diff --git a/src/components/button/dropdown.rs b/src/components/button/dropdown.rs index dd5c6b7..ed5f638 100644 --- a/src/components/button/dropdown.rs +++ b/src/components/button/dropdown.rs @@ -155,6 +155,46 @@ impl Dropdown { cx.notify(); } + pub fn open(&mut self, cx: &mut gpui::Context) { + if !self.disabled && !self.loading && !self.items.is_empty() { + self.open = true; + cx.notify(); + } + } + + pub fn close(&mut self, cx: &mut gpui::Context) { + self.open = false; + cx.notify(); + } + + pub fn select_next_item(&mut self, cx: &mut gpui::Context) { + if self.items.is_empty() { + return; + } + let next_index = self + .selected_index + .map(|i| (i + 1) % self.items.len()) + .unwrap_or(0); + self.set_selected_index(next_index, cx); + } + + pub fn select_prev_item(&mut self, cx: &mut gpui::Context) { + if self.items.is_empty() { + return; + } + let prev_index = self + .selected_index + .map(|i| if i == 0 { self.items.len() - 1 } else { i - 1 }) + .unwrap_or(self.items.len() - 1); + self.set_selected_index(prev_index, cx); + } + + pub fn accept_selection(&mut self, cx: &mut gpui::Context) { + if let Some(index) = self.selected_index { + self.select_item(index, cx); + } + } + fn select_item(&mut self, index: usize, cx: &mut gpui::Context) { if self.disabled || self.loading { return; diff --git a/src/components/input/mod.rs b/src/components/input/mod.rs index 6291dc7..a71bade 100644 --- a/src/components/input/mod.rs +++ b/src/components/input/mod.rs @@ -88,6 +88,11 @@ impl Input { cx.notify(); } + pub fn blur(&self, window: &mut gpui::Window, cx: &mut gpui::Context) { + window.blur(); + cx.notify(); + } + fn word_start_before(&self, pos: usize) -> usize { if pos == 0 { return 0; @@ -300,6 +305,10 @@ impl Input { let ctrl = event.keystroke.modifiers.control; let shift = event.keystroke.modifiers.shift; + if ctrl && (key == "h" || key == "l") { + return; + } + match key { "enter" => { if self.suggestions_open { diff --git a/src/keymap/active_context.rs b/src/keymap/active_context.rs new file mode 100644 index 0000000..e3aa96d --- /dev/null +++ b/src/keymap/active_context.rs @@ -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 + } +} diff --git a/src/keymap/chord.rs b/src/keymap/chord.rs new file mode 100644 index 0000000..d28c57a --- /dev/null +++ b/src/keymap/chord.rs @@ -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 { + 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 { + 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 { + 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 { + 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"); + } +} diff --git a/src/keymap/command.rs b/src/keymap/command.rs new file mode 100644 index 0000000..fac1ae4 --- /dev/null +++ b/src/keymap/command.rs @@ -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 { + 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", + } + } +} diff --git a/src/keymap/context.rs b/src/keymap/context.rs new file mode 100644 index 0000000..3ce49b3 --- /dev/null +++ b/src/keymap/context.rs @@ -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 { + 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", + } + } +} diff --git a/src/keymap/defaults.rs b/src/keymap/defaults.rs new file mode 100644 index 0000000..d57b8dd --- /dev/null +++ b/src/keymap/defaults.rs @@ -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 +} diff --git a/src/keymap/dispatcher.rs b/src/keymap/dispatcher.rs new file mode 100644 index 0000000..3c16e3f --- /dev/null +++ b/src/keymap/dispatcher.rs @@ -0,0 +1,5 @@ +use super::Command; + +pub trait CommandDispatcher: Sized { + fn dispatch(&mut self, command: Command, cx: &mut gpui::Context) -> bool; +} diff --git a/src/keymap/keymap.rs b/src/keymap/keymap.rs new file mode 100644 index 0000000..7fed72d --- /dev/null +++ b/src/keymap/keymap.rs @@ -0,0 +1,131 @@ +use std::collections::HashMap; + +use super::{Command, ContextId, KeyChord}; + +#[derive(Debug, Clone)] +pub struct KeymapLayer { + bindings: HashMap>, +} + +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 { + 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, +} + +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 { + self.layers.pop() + } + + pub fn resolve(&self, context: ContextId, chord: &KeyChord) -> Option { + 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) + ); + } +} diff --git a/src/keymap/mod.rs b/src/keymap/mod.rs new file mode 100644 index 0000000..508b658 --- /dev/null +++ b/src/keymap/mod.rs @@ -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 { + 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 { + 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 { + match binding { + KeyBinding::Refresh => Some(Self::Refresh), + KeyBinding::Focus => Some(Self::FocusSearch), + _ => None, + } + } +} diff --git a/src/main.rs b/src/main.rs index 26061b4..424b46f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use crate::app::App; mod app; mod components; +mod keymap; mod models; mod task; mod theme; diff --git a/src/models/filter_state.rs b/src/models/filter_state.rs index 170a05e..fe9613f 100644 --- a/src/models/filter_state.rs +++ b/src/models/filter_state.rs @@ -206,6 +206,21 @@ impl FilterState { self.due_filter = DueFilter::default(); } + pub fn clear_project(&mut self) { + self.selected_project = None; + } + + pub fn clear_tags(&mut self) { + self.active_tags.clear(); + } + + pub fn clear_search_and_dropdowns(&mut self) { + self.search_text.clear(); + self.status_filter = StatusFilter::default(); + self.priority_filter = PriorityFilter::default(); + self.due_filter = DueFilter::default(); + } + pub fn has_active_filters(&self) -> bool { self.selected_project.is_some() || !self.active_tags.is_empty() diff --git a/src/models/project_tree.rs b/src/models/project_tree.rs index c1685f9..e25033c 100644 --- a/src/models/project_tree.rs +++ b/src/models/project_tree.rs @@ -152,6 +152,21 @@ impl ProjectTree { } } + pub fn get_expanded_paths(&self) -> HashMap { + self.expanded_paths.clone() + } + + pub fn restore_expanded_paths(&mut self, paths: HashMap) { + for (path, is_expanded) in &paths { + if *is_expanded { + if let Some(&idx) = self.path_to_index.get(path) { + self.nodes[idx].is_expanded = true; + } + } + } + self.expanded_paths = paths; + } + pub fn expand_path(&mut self, full_path: &str) { let segments: Vec<&str> = full_path.split('.').collect(); let mut current_path = String::new(); diff --git a/src/view/sidebar.rs b/src/view/sidebar.rs index 925fc60..89070d7 100644 --- a/src/view/sidebar.rs +++ b/src/view/sidebar.rs @@ -1,7 +1,10 @@ +use crate::keymap::{Command, CommandDispatcher}; use crate::models::{FilterState, ProjectTree}; use crate::theme::ActiveTheme; use crate::ui::{divider_h, section_header}; -use gpui::{Context, Div, Entity, IntoElement, Window, div, prelude::*, px}; +use gpui::{ + Context, Div, Entity, IntoElement, ScrollHandle, Stateful, Window, div, prelude::*, px, +}; #[derive(Debug, Clone)] pub struct TagItem { @@ -9,10 +12,20 @@ pub struct TagItem { pub task_count: usize, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SidebarSection { + Projects, + Tags, +} + pub struct Sidebar { project_tree: ProjectTree, tags: Vec, filter_state: Entity, + selected_section: SidebarSection, + selected_index: Option, + projects_scroll_handle: ScrollHandle, + tags_scroll_handle: ScrollHandle, } impl Sidebar { @@ -31,10 +44,17 @@ impl Sidebar { project_tree, tags, filter_state, + selected_section: SidebarSection::Projects, + selected_index: Some(0), + projects_scroll_handle: ScrollHandle::new(), + tags_scroll_handle: ScrollHandle::new(), } } - pub fn update_projects(&mut self, project_tree: ProjectTree, cx: &mut Context) { + pub fn update_projects(&mut self, mut project_tree: ProjectTree, cx: &mut Context) { + let expanded_paths = self.project_tree.get_expanded_paths(); + project_tree.restore_expanded_paths(expanded_paths); + self.project_tree = project_tree; cx.notify(); } @@ -44,6 +64,13 @@ impl Sidebar { cx.notify(); } + pub fn set_section(&mut self, section: SidebarSection, cx: &mut Context) { + self.selected_section = section; + self.selected_index = Some(0); + self.scroll_to_selected(); + cx.notify(); + } + fn handle_project_click( &mut self, full_path: Option, @@ -87,29 +114,207 @@ impl Sidebar { cx.notify(); } - fn render_projects(&self, cx: &mut Context) -> Vec
{ + fn get_items_count(&self) -> usize { + match self.selected_section { + SidebarSection::Projects => 1 + self.project_tree.iter_visible().len(), + SidebarSection::Tags => self.tags.len(), + } + } + + fn scroll_to_selected(&mut self) { + if let Some(idx) = self.selected_index { + match self.selected_section { + SidebarSection::Projects => { + self.projects_scroll_handle.scroll_to_item(idx); + } + SidebarSection::Tags => { + self.tags_scroll_handle.scroll_to_item(idx); + } + } + } + } + + fn select_next(&mut self, cx: &mut Context) { + let count = self.get_items_count(); + if count == 0 { + return; + } + + if let Some(idx) = self.selected_index { + if idx + 1 < count { + self.selected_index = Some(idx + 1); + } + } else { + self.selected_index = Some(0); + } + self.scroll_to_selected(); + cx.notify(); + } + + fn select_prev(&mut self, cx: &mut Context) { + if let Some(idx) = self.selected_index { + if idx > 0 { + self.selected_index = Some(idx - 1); + } + } else { + let count = self.get_items_count(); + if count > 0 { + self.selected_index = Some(count - 1); + } + } + self.scroll_to_selected(); + cx.notify(); + } + + fn select_first(&mut self, cx: &mut Context) { + self.selected_index = Some(0); + self.scroll_to_selected(); + cx.notify(); + } + + fn select_last(&mut self, cx: &mut Context) { + let count = self.get_items_count(); + if count > 0 { + self.selected_index = Some(count - 1); + } + self.scroll_to_selected(); + cx.notify(); + } + + fn expand_selected(&mut self, cx: &mut Context) { + if self.selected_section != SidebarSection::Projects { + return; + } + + let idx = match self.selected_index { + Some(i) => i, + None => return, + }; + + if idx > 0 { + let visible = self.project_tree.iter_visible(); + let should_expand = if let Some((_, node)) = visible.get(idx - 1) { + if node.has_children() && !node.is_expanded { + Some(node.full_path.clone()) + } else { + None + } + } else { + None + }; + drop(visible); + + if let Some(path) = should_expand { + self.project_tree.toggle_expansion(&path); + cx.notify(); + } + } + } + + fn collapse_selected(&mut self, cx: &mut Context) { + if self.selected_section != SidebarSection::Projects { + return; + } + + let idx = match self.selected_index { + Some(i) => i, + None => return, + }; + + if idx > 0 { + let visible = self.project_tree.iter_visible(); + let should_collapse = if let Some((_, node)) = visible.get(idx - 1) { + if node.has_children() && node.is_expanded { + Some(node.full_path.clone()) + } else { + None + } + } else { + None + }; + drop(visible); + + if let Some(path) = should_collapse { + self.project_tree.toggle_expansion(&path); + cx.notify(); + } + } + } + + fn activate_selected(&mut self, cx: &mut Context) { + let idx = match self.selected_index { + Some(i) => i, + None => return, + }; + + match self.selected_section { + SidebarSection::Projects => { + if idx == 0 { + self.filter_state.update(cx, |filter, cx| { + filter.select_project(None); + cx.notify(); + }); + } else { + let visible = self.project_tree.iter_visible(); + if let Some((_, node)) = visible.get(idx - 1) { + self.filter_state.update(cx, |filter, cx| { + filter.select_project(Some(node.full_path.clone())); + cx.notify(); + }); + } + } + } + SidebarSection::Tags => { + if let Some(tag) = self.tags.get(idx) { + let tag_name = tag.name.clone(); + self.filter_state.update(cx, |filter, cx| { + filter.toggle_tag(tag_name); + cx.notify(); + }); + } + } + } + cx.notify(); + } + + fn render_projects(&self, cx: &mut Context) -> Vec> { let theme = cx.theme(); let filter = self.filter_state.read(cx); let mut elements = Vec::new(); let is_all_selected = filter.selected_project.is_none(); + let is_keyboard_selected = + self.selected_section == SidebarSection::Projects && self.selected_index == Some(0); + elements.push( div() + .id(("project", 0usize)) .flex() .items_center() - .gap_2() + .gap_1() .px_3() .py_1() .rounded_sm() .cursor_pointer() .when(is_all_selected, |this| this.bg(theme.selection)) - .when(!is_all_selected, |this| this.hover(|s| s.bg(theme.hover))) + .when(!is_all_selected && is_keyboard_selected, |this| { + this.bg(theme.hover) + }) + .when(!is_all_selected && !is_keyboard_selected, |this| { + this.hover(|s| s.bg(theme.hover)) + }) .on_mouse_down( gpui::MouseButton::Left, cx.listener(|view, _event, window, cx| { view.handle_project_click(None, window, cx); }), ) + .child( + div() + .w_4() + .text_color(theme.accent) + .child(if is_keyboard_selected { ">" } else { " " }), + ) .child( div() .w_3() @@ -130,13 +335,16 @@ impl Sidebar { ), ); - for (_idx, node) in self.project_tree.iter_visible() { + for (idx, (_tree_idx, node)) in self.project_tree.iter_visible().iter().enumerate() { let is_selected = filter .selected_project .as_ref() .map(|p| p == &node.full_path) .unwrap_or(false); + let is_keyboard_selected = self.selected_section == SidebarSection::Projects + && self.selected_index == Some(idx + 1); + let indent = node.level * 16; let full_path = node.full_path.clone(); let full_path_for_expand = node.full_path.clone(); @@ -145,6 +353,7 @@ impl Sidebar { elements.push( div() + .id(("project", idx + 1)) .flex() .items_center() .gap_1() @@ -153,7 +362,18 @@ impl Sidebar { .rounded_sm() .cursor_pointer() .when(is_selected, |this| this.bg(theme.selection)) - .when(!is_selected, |this| this.hover(|s| s.bg(theme.hover))) + .when(!is_selected && is_keyboard_selected, |this| { + this.bg(theme.hover) + }) + .when(!is_selected && !is_keyboard_selected, |this| { + this.hover(|s| s.bg(theme.hover)) + }) + .child( + div() + .w_4() + .text_color(theme.accent) + .child(if is_keyboard_selected { ">" } else { " " }), + ) .child(div().w(px(indent as f32))) .child( div() @@ -216,32 +436,46 @@ impl Sidebar { elements } - fn render_tags(&self, cx: &mut Context) -> Vec
{ + fn render_tags(&self, cx: &mut Context) -> Vec> { let theme = cx.theme(); let filter = self.filter_state.read(cx); let mut elements = Vec::new(); - for tag in &self.tags { + for (idx, tag) in self.tags.iter().enumerate() { let is_active = filter.active_tags.contains(&tag.name); + let is_keyboard_selected = + self.selected_section == SidebarSection::Tags && self.selected_index == Some(idx); let tag_name = tag.name.clone(); elements.push( div() + .id(("tag", idx)) .flex() .items_center() - .gap_2() + .gap_1() .px_3() .py_1() .rounded_sm() .cursor_pointer() .when(is_active, |this| this.bg(theme.selection)) - .when(!is_active, |this| this.hover(|s| s.bg(theme.hover))) + .when(!is_active && is_keyboard_selected, |this| { + this.bg(theme.hover) + }) + .when(!is_active && !is_keyboard_selected, |this| { + this.hover(|s| s.bg(theme.hover)) + }) .on_mouse_down( gpui::MouseButton::Left, cx.listener(move |view, _event, window, cx| { view.handle_tag_click(tag_name.clone(), window, cx); }), ) + .child( + div() + .w_4() + .text_color(theme.accent) + .child(if is_keyboard_selected { ">" } else { " " }), + ) .child( div() .w_3() @@ -273,6 +507,42 @@ impl Sidebar { } } +impl CommandDispatcher for Sidebar { + fn dispatch(&mut self, command: Command, cx: &mut Context) -> bool { + match command { + Command::SelectNextRow => { + self.select_next(cx); + true + } + Command::SelectPrevRow => { + self.select_prev(cx); + true + } + Command::SelectFirstRow => { + self.select_first(cx); + true + } + Command::SelectLastRow => { + self.select_last(cx); + true + } + Command::OpenSelectedTask => { + self.activate_selected(cx); + true + } + Command::ExpandProject => { + self.expand_selected(cx); + true + } + Command::CollapseProject => { + self.collapse_selected(cx); + true + } + _ => false, + } + } +} + impl Render for Sidebar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); @@ -326,6 +596,7 @@ impl Render for Sidebar { .flex_1() .min_h_0() .overflow_y_scroll() + .track_scroll(&self.projects_scroll_handle) .children(projects), ), ) @@ -370,6 +641,7 @@ impl Render for Sidebar { .flex_1() .min_h_0() .overflow_y_scroll() + .track_scroll(&self.tags_scroll_handle) .children(tags), ), ) diff --git a/src/view/status_bar.rs b/src/view/status_bar.rs index 2b52716..5a2826c 100644 --- a/src/view/status_bar.rs +++ b/src/view/status_bar.rs @@ -129,11 +129,7 @@ impl Render for StatusBar { .items_center() .gap_2() .child(Label::new("✕").text_color(theme.error).text_sm()) - .child( - Label::new(error.clone()) - .text_color(theme.error) - .text_sm(), - ), + .child(Label::new(error.clone()).text_color(theme.error).text_sm()), ) .child( div() diff --git a/src/view/task_table.rs b/src/view/task_table.rs index 725fe25..6901c09 100644 --- a/src/view/task_table.rs +++ b/src/view/task_table.rs @@ -10,13 +10,14 @@ use crate::{ button::{Dropdown, DropdownItem}, input::Input, }, + keymap::{Command, CommandDispatcher}, models::{DueFilter, FilterState, PriorityFilter, StatusFilter}, task::{self, TaskFilter, TaskService}, theme::{self, ActiveTheme}, ui::{ - priority_badge, table_col_desc_min_width, table_col_due_width, table_col_id_width, + DATE_FORMAT, TABLE_FILTER_BAR_INITIAL_HEIGHT, TABLE_MAX_DESCRIPTION_LENGTH, priority_badge, + table_col_desc_min_width, table_col_due_width, table_col_id_width, table_col_priority_width, table_col_project_width, table_col_status_width, - DATE_FORMAT, TABLE_FILTER_BAR_INITIAL_HEIGHT, TABLE_MAX_DESCRIPTION_LENGTH, }, }; @@ -31,6 +32,15 @@ pub enum SortColumn { } impl SortColumn { + const COLUMN_ORDER: [Self; 6] = [ + Self::Id, + Self::Description, + Self::Project, + Self::Due, + Self::Priority, + Self::Status, + ]; + pub fn label(&self) -> &'static str { match self { SortColumn::Id => "ID", @@ -41,6 +51,22 @@ impl SortColumn { SortColumn::Status => "Status", } } + + fn next(self) -> Self { + let idx = Self::COLUMN_ORDER + .iter() + .position(|&c| c == self) + .unwrap_or(0); + Self::COLUMN_ORDER[(idx + 1) % Self::COLUMN_ORDER.len()] + } + + fn prev(self) -> Self { + let idx = Self::COLUMN_ORDER + .iter() + .position(|&c| c == self) + .unwrap_or(0); + Self::COLUMN_ORDER[(idx + Self::COLUMN_ORDER.len() - 1) % Self::COLUMN_ORDER.len()] + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -217,6 +243,21 @@ impl From<&task::Task> for TaskRow { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilterBarFocus { + None, + SearchInput, + StatusDropdown, + PriorityDropdown, + DueDropdown, +} + +impl Default for FilterBarFocus { + fn default() -> Self { + Self::None + } +} + pub struct TaskTable { id: gpui::ElementId, filter_state: gpui::Entity, @@ -232,6 +273,10 @@ pub struct TaskTable { status_dropdown: gpui::Entity, priority_dropdown: gpui::Entity, due_dropdown: gpui::Entity, + filter_bar_focus: FilterBarFocus, + filter_bar_focus_handle: gpui::FocusHandle, + focused_header: Option, + header_focus_handle: gpui::FocusHandle, } impl TaskTable { @@ -329,6 +374,10 @@ impl TaskTable { status_dropdown, priority_dropdown, due_dropdown, + filter_bar_focus: FilterBarFocus::None, + filter_bar_focus_handle: cx.focus_handle(), + focused_header: None, + header_focus_handle: cx.focus_handle(), } } @@ -420,6 +469,12 @@ impl TaskTable { self.selected_page_idx = None; self.recalculate_rows(); + + if !self.cached_rows.is_empty() { + self.selected_page_idx = Some(0); + self.selected_global_idx = Some(0); + } + self.sync_filter_dropdowns(&due_tasks, &filter_state, cx); self.need_reload = false; @@ -598,6 +653,304 @@ impl TaskTable { cx.notify(); } + pub fn select_next_row(&mut self, cx: &mut gpui::Context) { + if self.cached_rows.is_empty() { + return; + } + + if self.selected_page_idx.is_none() { + self.selected_page_idx = Some(0); + self.selected_global_idx = Some(self.pagination.first_item_index()); + cx.notify(); + return; + } + + let current_page_idx = self.selected_page_idx.unwrap(); + let current_global_idx = self.selected_global_idx.unwrap(); + + if current_global_idx + 1 >= self.cached_rows.len() { + return; + } + + let next_global_idx = current_global_idx + 1; + let page_last_idx = self.pagination.last_item_index() - 1; + + if next_global_idx > page_last_idx { + self.pagination.next_page(); + self.selected_page_idx = Some(0); + self.selected_global_idx = Some(next_global_idx); + } else { + self.selected_page_idx = Some(current_page_idx + 1); + self.selected_global_idx = Some(next_global_idx); + } + + cx.notify(); + } + + pub fn select_previous_row(&mut self, cx: &mut gpui::Context) { + if self.cached_rows.is_empty() { + return; + } + + if self.selected_page_idx.is_none() { + let last_global_idx = self.cached_rows.len() - 1; + let last_page = (self.cached_rows.len() + self.pagination.page_size - 1) + / self.pagination.page_size; + + self.pagination.current_page(last_page); + + let page_first_idx = self.pagination.first_item_index(); + let page_idx = last_global_idx - page_first_idx; + + self.selected_page_idx = Some(page_idx); + self.selected_global_idx = Some(last_global_idx); + cx.notify(); + return; + } + + let current_page_idx = self.selected_page_idx.unwrap(); + let current_global_idx = self.selected_global_idx.unwrap(); + + if current_global_idx == 0 { + return; + } + + let prev_global_idx = current_global_idx - 1; + let page_first_idx = self.pagination.first_item_index(); + + if prev_global_idx < page_first_idx { + self.pagination.previous_page(); + let new_page_size = (self.pagination.last_item_index() + - self.pagination.first_item_index()) + .min(self.pagination.page_size); + self.selected_page_idx = Some(new_page_size - 1); + self.selected_global_idx = Some(prev_global_idx); + } else { + self.selected_page_idx = Some(current_page_idx.saturating_sub(1)); + self.selected_global_idx = Some(prev_global_idx); + } + + cx.notify(); + } + + pub fn select_first_row(&mut self, cx: &mut gpui::Context) { + if self.cached_rows.is_empty() { + return; + } + + self.pagination.current_page(1); + self.selected_page_idx = Some(0); + self.selected_global_idx = Some(0); + cx.notify(); + } + + pub fn select_last_row(&mut self, cx: &mut gpui::Context) { + if self.cached_rows.is_empty() { + return; + } + + let last_global_idx = self.cached_rows.len() - 1; + let last_page = + (self.cached_rows.len() + self.pagination.page_size - 1) / self.pagination.page_size; + + self.pagination.current_page(last_page); + + let page_first_idx = self.pagination.first_item_index(); + let page_idx = last_global_idx - page_first_idx; + + self.selected_page_idx = Some(page_idx); + self.selected_global_idx = Some(last_global_idx); + cx.notify(); + } + + pub fn clear_selection(&mut self, cx: &mut gpui::Context) { + self.selected_page_idx = None; + self.selected_global_idx = None; + cx.notify(); + } + + pub fn focus_search_input(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context) { + self.filter_bar_focus = FilterBarFocus::SearchInput; + self.search_input.update(cx, |input, cx| { + input.focus(window, cx); + }); + cx.notify(); + } + + pub fn blur_search_input(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context) { + window.focus(&self.filter_bar_focus_handle); + cx.notify(); + } + + pub fn clear_search_input(&mut self, cx: &mut gpui::Context) { + self.search_input.update(cx, |input, cx| { + input.clear(cx); + }); + } + + pub fn reset_dropdowns(&mut self, cx: &mut gpui::Context) { + self.status_dropdown.update(cx, |dropdown, cx| { + dropdown.set_selected_index(1, cx); + }); + self.priority_dropdown.update(cx, |dropdown, cx| { + dropdown.set_selected_index(0, cx); + }); + self.due_dropdown.update(cx, |dropdown, cx| { + dropdown.set_selected_index(0, cx); + }); + } + + pub fn get_filter_bar_focus(&self) -> FilterBarFocus { + self.filter_bar_focus + } + + pub fn set_filter_bar_focus(&mut self, focus: FilterBarFocus, cx: &mut gpui::Context) { + self.filter_bar_focus = focus; + cx.notify(); + } + + pub fn focus_table_headers(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context) { + self.focused_header = Some(SortColumn::Id); + window.focus(&self.header_focus_handle); + cx.notify(); + } + + pub fn blur_table_headers(&mut self, cx: &mut gpui::Context) { + self.focused_header = None; + cx.notify(); + } + + pub fn header_move_next(&mut self, cx: &mut gpui::Context) { + self.focused_header = Some(self.focused_header.unwrap_or(SortColumn::Id).next()); + cx.notify(); + } + + pub fn header_move_prev(&mut self, cx: &mut gpui::Context) { + self.focused_header = Some(self.focused_header.unwrap_or(SortColumn::Id).prev()); + cx.notify(); + } + + pub fn header_cycle_sort_order(&mut self, cx: &mut gpui::Context) { + if let Some(column) = self.focused_header { + if self.sort_state.column == column { + self.sort_state.direction = self.sort_state.direction.toggle(); + } else { + self.sort_state.column = column; + self.sort_state.direction = SortDirection::Desc; + } + self.apply_sort(); + self.recalculate_rows(); + cx.notify(); + } + } + + fn close_all_dropdowns(&mut self, cx: &mut gpui::Context) { + self.status_dropdown.update(cx, |d, cx| d.close(cx)); + self.priority_dropdown.update(cx, |d, cx| d.close(cx)); + self.due_dropdown.update(cx, |d, cx| d.close(cx)); + } + + pub fn focus_filter_next(&mut self, cx: &mut gpui::Context) { + use FilterBarFocus::*; + self.close_all_dropdowns(cx); + + self.filter_bar_focus = match self.filter_bar_focus { + None | SearchInput => StatusDropdown, + StatusDropdown => PriorityDropdown, + PriorityDropdown => DueDropdown, + DueDropdown => SearchInput, + }; + cx.notify(); + } + + pub fn focus_filter_prev(&mut self, cx: &mut gpui::Context) { + use FilterBarFocus::*; + self.close_all_dropdowns(cx); + + self.filter_bar_focus = match self.filter_bar_focus { + None | SearchInput => DueDropdown, + DueDropdown => PriorityDropdown, + PriorityDropdown => StatusDropdown, + StatusDropdown => SearchInput, + }; + cx.notify(); + } + + pub fn toggle_focused_dropdown(&mut self, cx: &mut gpui::Context) { + use FilterBarFocus::*; + let toggle = |d: &gpui::Entity, cx: &mut gpui::Context| { + d.update(cx, |d, cx| { + if d.is_open() { + d.accept_selection(cx); + } else { + d.open(cx); + } + }); + cx.notify(); + }; + match self.filter_bar_focus { + StatusDropdown => toggle(&self.status_dropdown, cx), + PriorityDropdown => toggle(&self.priority_dropdown, cx), + DueDropdown => toggle(&self.due_dropdown, cx), + _ => {} + } + } + + pub fn select_next_dropdown_option(&mut self, cx: &mut gpui::Context) { + use FilterBarFocus::*; + let select_next = |d: &gpui::Entity, cx: &mut gpui::Context| { + d.update(cx, |d, cx| { + if !d.is_open() { + d.open(cx); + } + d.select_next_item(cx); + }); + cx.notify(); + }; + match self.filter_bar_focus { + StatusDropdown => select_next(&self.status_dropdown, cx), + PriorityDropdown => select_next(&self.priority_dropdown, cx), + DueDropdown => select_next(&self.due_dropdown, cx), + _ => {} + } + } + + pub fn select_prev_dropdown_option(&mut self, cx: &mut gpui::Context) { + use FilterBarFocus::*; + let select_prev = |d: &gpui::Entity, cx: &mut gpui::Context| { + d.update(cx, |d, cx| { + if !d.is_open() { + d.open(cx); + } + d.select_prev_item(cx); + }); + cx.notify(); + }; + match self.filter_bar_focus { + StatusDropdown => select_prev(&self.status_dropdown, cx), + PriorityDropdown => select_prev(&self.priority_dropdown, cx), + DueDropdown => select_prev(&self.due_dropdown, cx), + _ => {} + } + } + + pub fn blur_filter_bar(&mut self, cx: &mut gpui::Context) { + self.close_all_dropdowns(cx); + self.filter_bar_focus = FilterBarFocus::None; + cx.notify(); + } + + pub fn get_active_filter_context(&self) -> Option { + use FilterBarFocus::*; + match self.filter_bar_focus { + SearchInput => Some(crate::keymap::ContextId::TextInput), + StatusDropdown | PriorityDropdown | DueDropdown => { + Some(crate::keymap::ContextId::FilterBar) + } + FilterBarFocus::None => Option::None, + } + } + fn handle_clear_filters(&mut self, cx: &mut gpui::Context) { self.search_input.update(cx, |input, cx| { input.clear(cx); @@ -643,6 +996,63 @@ impl TaskTable { btn }; + use FilterBarFocus::*; + + let status_has_focus = matches!(self.filter_bar_focus, StatusDropdown); + let priority_has_focus = matches!(self.filter_bar_focus, PriorityDropdown); + let due_has_focus = matches!(self.filter_bar_focus, DueDropdown); + + let status_wrapper = gpui::div() + .min_w(gpui::rems(11.0)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _event, _window, cx| { + this.filter_bar_focus = FilterBarFocus::StatusDropdown; + cx.notify(); + }), + ) + .when(status_has_focus, |div| { + div.border_2() + .border_color(theme.focus_ring) + .rounded_md() + .p_px() + }) + .child(self.status_dropdown.clone()); + + let priority_wrapper = gpui::div() + .min_w(gpui::rems(10.0)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _event, _window, cx| { + this.filter_bar_focus = FilterBarFocus::PriorityDropdown; + cx.notify(); + }), + ) + .when(priority_has_focus, |div| { + div.border_2() + .border_color(theme.focus_ring) + .rounded_md() + .p_px() + }) + .child(self.priority_dropdown.clone()); + + let due_wrapper = gpui::div() + .min_w(gpui::rems(8.0)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _event, _window, cx| { + this.filter_bar_focus = FilterBarFocus::DueDropdown; + cx.notify(); + }), + ) + .when(due_has_focus, |div| { + div.border_2() + .border_color(theme.focus_ring) + .rounded_md() + .p_px() + }) + .child(self.due_dropdown.clone()); + let bar = gpui::div() .id("filter-bar") .flex() @@ -654,11 +1064,19 @@ impl TaskTable { gpui::div() .flex_1() .min_w(gpui::rems(12.0)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _event, _window, cx| { + this.close_all_dropdowns(cx); + this.filter_bar_focus = FilterBarFocus::SearchInput; + cx.notify(); + }), + ) .child(self.search_input.clone()), ) - .child(self.status_dropdown.clone()) - .child(self.priority_dropdown.clone()) - .child(self.due_dropdown.clone()) + .child(status_wrapper) + .child(priority_wrapper) + .child(due_wrapper) .child(clear_button); gpui::div() @@ -685,6 +1103,7 @@ impl TaskTable { ) -> impl gpui::IntoElement { let theme = cx.theme(); let is_sorted = self.sort_state.column == column; + let is_focused = self.focused_header == Some(column); let arrow = if is_sorted { self.sort_state.direction.arrow() } else { @@ -697,6 +1116,13 @@ impl TaskTable { .items_center() .gap_1() .cursor_pointer() + .when(is_focused, |div| { + div.border_2() + .border_color(theme.focus_ring) + .rounded_md() + .px_1() + .mx(gpui::px(-1.0)) + }) .hover(|s| s.text_color(theme.foreground)) .on_mouse_down( gpui::MouseButton::Left, @@ -720,6 +1146,7 @@ impl TaskTable { let theme = cx.theme(); gpui::div() + .track_focus(&self.header_focus_handle) .flex() .flex_shrink_0() .items_center() @@ -932,6 +1359,60 @@ impl TaskTable { } } +impl CommandDispatcher for TaskTable { + fn dispatch(&mut self, command: Command, cx: &mut gpui::Context) -> bool { + match command { + Command::SelectNextRow => { + self.select_next_row(cx); + true + } + Command::SelectPrevRow => { + self.select_previous_row(cx); + true + } + Command::SelectFirstRow => { + self.select_first_row(cx); + true + } + Command::SelectLastRow => { + self.select_last_row(cx); + true + } + Command::NextPage => { + self.go_next_page(cx); + true + } + Command::PrevPage => { + self.go_previous_page(cx); + true + } + Command::ClearSelection => { + self.clear_selection(cx); + true + } + Command::FocusFilterNext | Command::FocusFilterPrev => false, + Command::ToggleDropdown => { + self.toggle_focused_dropdown(cx); + true + } + Command::SelectNextOption => { + self.select_next_dropdown_option(cx); + true + } + Command::SelectPrevOption => { + self.select_prev_dropdown_option(cx); + true + } + Command::BlurInput => { + self.blur_filter_bar(cx); + true + } + Command::ExpandProject | Command::CollapseProject => false, + _ => false, + } + } +} + impl gpui::Render for TaskTable { fn render( &mut self, @@ -945,13 +1426,17 @@ impl gpui::Render for TaskTable { .flex_col(); if self.need_reload { - return panel - .flex() - .flex_col() - .size_full() - .items_center() - .justify_center() - .child(components::label::Label::new("Loading...").text_color(theme.foreground)); + return gpui::div().size_full().child( + panel + .flex() + .flex_col() + .size_full() + .items_center() + .justify_center() + .child( + components::label::Label::new("Loading...").text_color(theme.foreground), + ), + ); } let current_page = self.get_current_page_rows(); @@ -972,7 +1457,8 @@ impl gpui::Render for TaskTable { .min_h_0() .overflow_hidden() .bg(theme.background) - .child(gpui::div().h(self.filter_bar_height).mb_4()) + .child(gpui::div().h(self.filter_bar_height)) + .child(gpui::div().h_4()) .child(header) .child( gpui::div() @@ -984,13 +1470,18 @@ impl gpui::Render for TaskTable { ) .child(footer); - panel.child(body).child( - gpui::div() - .absolute() - .top_0() - .left_0() - .right_0() - .child(filter_bar), - ) + gpui::div() + .size_full() + .track_focus(&self.filter_bar_focus_handle) + .child( + panel.child(body).child( + gpui::div() + .absolute() + .top_0() + .left_0() + .right_0() + .child(filter_bar), + ), + ) } }