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
Generated
+78 -46
View File
@@ -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",
]
+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);
})
+40
View File
@@ -155,6 +155,46 @@ impl Dropdown {
cx.notify();
}
pub fn open(&mut self, cx: &mut gpui::Context<Self>) {
if !self.disabled && !self.loading && !self.items.is_empty() {
self.open = true;
cx.notify();
}
}
pub fn close(&mut self, cx: &mut gpui::Context<Self>) {
self.open = false;
cx.notify();
}
pub fn select_next_item(&mut self, cx: &mut gpui::Context<Self>) {
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<Self>) {
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<Self>) {
if let Some(index) = self.selected_index {
self.select_item(index, cx);
}
}
fn select_item(&mut self, index: usize, cx: &mut gpui::Context<Self>) {
if self.disabled || self.loading {
return;
+9
View File
@@ -88,6 +88,11 @@ impl Input {
cx.notify();
}
pub fn blur(&self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) {
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 {
+32
View File
@@ -0,0 +1,32 @@
use super::ContextId;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusTarget {
App,
Table,
TableHeaders,
SidebarProjects,
SidebarTags,
}
impl FocusTarget {
pub fn to_context(&self) -> ContextId {
match self {
Self::App => ContextId::Global,
Self::Table => ContextId::Table,
Self::TableHeaders => ContextId::TableHeaders,
Self::SidebarProjects => ContextId::SidebarProjects,
Self::SidebarTags => ContextId::SidebarTags,
}
}
pub fn is_sidebar(&self) -> bool {
matches!(self, Self::SidebarProjects | Self::SidebarTags)
}
}
impl Default for FocusTarget {
fn default() -> Self {
Self::App
}
}
+296
View File
@@ -0,0 +1,296 @@
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Key {
Char(char),
Enter,
Escape,
Backspace,
Delete,
Tab,
Space,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
PageUp,
PageDown,
Home,
End,
F1,
F2,
F3,
F4,
F5,
F6,
F7,
F8,
F9,
F10,
F11,
F12,
}
impl Key {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"enter" | "return" => Some(Self::Enter),
"esc" | "escape" => Some(Self::Escape),
"backspace" => Some(Self::Backspace),
"delete" | "del" => Some(Self::Delete),
"tab" => Some(Self::Tab),
"space" => Some(Self::Space),
"up" | "arrowup" => Some(Self::ArrowUp),
"down" | "arrowdown" => Some(Self::ArrowDown),
"left" | "arrowleft" => Some(Self::ArrowLeft),
"right" | "arrowright" => Some(Self::ArrowRight),
"pageup" => Some(Self::PageUp),
"pagedown" => Some(Self::PageDown),
"home" => Some(Self::Home),
"end" => Some(Self::End),
"f1" => Some(Self::F1),
"f2" => Some(Self::F2),
"f3" => Some(Self::F3),
"f4" => Some(Self::F4),
"f5" => Some(Self::F5),
"f6" => Some(Self::F6),
"f7" => Some(Self::F7),
"f8" => Some(Self::F8),
"f9" => Some(Self::F9),
"f10" => Some(Self::F10),
"f11" => Some(Self::F11),
"f12" => Some(Self::F12),
s if s.len() == 1 => s.chars().next().map(Self::Char),
_ => None,
}
}
}
impl fmt::Display for Key {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Char(c) => write!(f, "{}", c),
Self::Enter => write!(f, "Enter"),
Self::Escape => write!(f, "Esc"),
Self::Backspace => write!(f, "Backspace"),
Self::Delete => write!(f, "Del"),
Self::Tab => write!(f, "Tab"),
Self::Space => write!(f, "Space"),
Self::ArrowUp => write!(f, "Up"),
Self::ArrowDown => write!(f, "Down"),
Self::ArrowLeft => write!(f, "Left"),
Self::ArrowRight => write!(f, "Right"),
Self::PageUp => write!(f, "PageUp"),
Self::PageDown => write!(f, "PageDown"),
Self::Home => write!(f, "Home"),
Self::End => write!(f, "End"),
Self::F1 => write!(f, "F1"),
Self::F2 => write!(f, "F2"),
Self::F3 => write!(f, "F3"),
Self::F4 => write!(f, "F4"),
Self::F5 => write!(f, "F5"),
Self::F6 => write!(f, "F6"),
Self::F7 => write!(f, "F7"),
Self::F8 => write!(f, "F8"),
Self::F9 => write!(f, "F9"),
Self::F10 => write!(f, "F10"),
Self::F11 => write!(f, "F11"),
Self::F12 => write!(f, "F12"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Mods {
pub ctrl: bool,
pub alt: bool,
pub shift: bool,
pub platform: bool,
}
impl Mods {
pub fn none() -> Self {
Self::default()
}
pub fn ctrl() -> Self {
Self {
ctrl: true,
..Default::default()
}
}
pub fn alt() -> Self {
Self {
alt: true,
..Default::default()
}
}
pub fn shift() -> Self {
Self {
shift: true,
..Default::default()
}
}
pub fn platform() -> Self {
Self {
platform: true,
..Default::default()
}
}
}
impl fmt::Display for Mods {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut parts = Vec::new();
if self.platform {
parts.push("Cmd");
}
if self.ctrl {
parts.push("Ctrl");
}
if self.alt {
parts.push("Alt");
}
if self.shift {
parts.push("Shift");
}
write!(f, "{}", parts.join("+"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct KeyChord {
pub key: Key,
pub mods: Mods,
}
impl KeyChord {
pub fn new(key: Key, mods: Mods) -> Self {
Self { key, mods }
}
pub fn from_gpui(event: &gpui::KeyDownEvent) -> Option<Self> {
let key = Self::key_from_gpui(&event.keystroke.key)?;
let mods = Mods {
ctrl: event.keystroke.modifiers.control,
alt: event.keystroke.modifiers.alt,
shift: event.keystroke.modifiers.shift,
platform: event.keystroke.modifiers.platform,
};
Some(Self { key, mods })
}
fn key_from_gpui(key_str: &str) -> Option<Key> {
let normalized = key_str.to_lowercase();
match normalized.as_str() {
"enter" | "return" => Some(Key::Enter),
"escape" | "esc" => Some(Key::Escape),
"backspace" => Some(Key::Backspace),
"delete" | "del" => Some(Key::Delete),
"tab" => Some(Key::Tab),
" " | "space" => Some(Key::Space),
"arrowup" | "up" => Some(Key::ArrowUp),
"arrowdown" | "down" => Some(Key::ArrowDown),
"arrowleft" | "left" => Some(Key::ArrowLeft),
"arrowright" | "right" => Some(Key::ArrowRight),
"pageup" => Some(Key::PageUp),
"pagedown" => Some(Key::PageDown),
"home" => Some(Key::Home),
"end" => Some(Key::End),
"f1" => Some(Key::F1),
"f2" => Some(Key::F2),
"f3" => Some(Key::F3),
"f4" => Some(Key::F4),
"f5" => Some(Key::F5),
"f6" => Some(Key::F6),
"f7" => Some(Key::F7),
"f8" => Some(Key::F8),
"f9" => Some(Key::F9),
"f10" => Some(Key::F10),
"f11" => Some(Key::F11),
"f12" => Some(Key::F12),
s if s.len() == 1 => s.chars().next().map(Key::Char),
_ => {
if key_str.len() == 1 {
key_str.chars().next().map(Key::Char)
} else {
None
}
}
}
}
pub fn parse(s: &str) -> Option<Self> {
let parts: Vec<&str> = s.split('+').collect();
if parts.is_empty() {
return None;
}
let mut mods = Mods::none();
let key_str = parts.last()?;
for part in &parts[..parts.len() - 1] {
match part.to_lowercase().as_str() {
"ctrl" => mods.ctrl = true,
"alt" => mods.alt = true,
"shift" => mods.shift = true,
"cmd" | "super" | "platform" => mods.platform = true,
_ => return None,
}
}
let key = Key::from_str(key_str)?;
Some(Self { key, mods })
}
}
impl fmt::Display for KeyChord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.mods.ctrl || self.mods.alt || self.mods.shift || self.mods.platform {
write!(f, "{}+{}", self.mods, self.key)
} else {
write!(f, "{}", self.key)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_key() {
let chord = KeyChord::parse("j").unwrap();
assert_eq!(chord.key, Key::Char('j'));
assert_eq!(chord.mods, Mods::none());
}
#[test]
fn test_parse_ctrl_key() {
let chord = KeyChord::parse("ctrl+f").unwrap();
assert_eq!(chord.key, Key::Char('f'));
assert!(chord.mods.ctrl);
}
#[test]
fn test_parse_platform_key() {
let chord = KeyChord::parse("cmd+r").unwrap();
assert_eq!(chord.key, Key::Char('r'));
assert!(chord.mods.platform);
}
#[test]
fn test_parse_special_key() {
let chord = KeyChord::parse("enter").unwrap();
assert_eq!(chord.key, Key::Enter);
}
#[test]
fn test_to_string() {
let chord = KeyChord::new(Key::Char('f'), Mods::ctrl());
assert_eq!(chord.to_string(), "Ctrl+f");
}
}
+131
View File
@@ -0,0 +1,131 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Command {
// Navigation
SelectNextRow,
SelectPrevRow,
SelectFirstRow,
SelectLastRow,
NextPage,
PrevPage,
ClearSelection,
// Actions
OpenSelectedTask,
Sync,
// Focus
FocusSearch,
FocusTable,
FocusTableHeaders,
FocusSidebar,
FocusSidebarProjects,
FocusSidebarTags,
BlurInput,
// Modal
CloseModal,
SaveModal,
// Filter
ApplySearch,
ClearFilters,
ClearAllFilters,
ClearProjectFilter,
ClearTagFilter,
ClearSearchAndDropdowns,
FocusFilterNext,
FocusFilterPrev,
ToggleDropdown,
SelectNextOption,
SelectPrevOption,
// Projects
ExpandProject,
CollapseProject,
// Table Headers
HeaderMoveNext,
HeaderMovePrev,
HeaderCycleSortOrder,
}
impl Command {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"SelectNextRow" => Some(Self::SelectNextRow),
"SelectPrevRow" => Some(Self::SelectPrevRow),
"SelectFirstRow" => Some(Self::SelectFirstRow),
"SelectLastRow" => Some(Self::SelectLastRow),
"NextPage" => Some(Self::NextPage),
"PrevPage" => Some(Self::PrevPage),
"ClearSelection" => Some(Self::ClearSelection),
"OpenSelectedTask" => Some(Self::OpenSelectedTask),
"Sync" => Some(Self::Sync),
"FocusSearch" => Some(Self::FocusSearch),
"FocusTable" => Some(Self::FocusTable),
"FocusTableHeaders" => Some(Self::FocusTableHeaders),
"FocusSidebar" => Some(Self::FocusSidebar),
"FocusSidebarProjects" => Some(Self::FocusSidebarProjects),
"FocusSidebarTags" => Some(Self::FocusSidebarTags),
"BlurInput" => Some(Self::BlurInput),
"CloseModal" => Some(Self::CloseModal),
"SaveModal" => Some(Self::SaveModal),
"ApplySearch" => Some(Self::ApplySearch),
"ClearFilters" => Some(Self::ClearFilters),
"ClearAllFilters" => Some(Self::ClearAllFilters),
"ClearProjectFilter" => Some(Self::ClearProjectFilter),
"ClearTagFilter" => Some(Self::ClearTagFilter),
"ClearSearchAndDropdowns" => Some(Self::ClearSearchAndDropdowns),
"FocusFilterNext" => Some(Self::FocusFilterNext),
"FocusFilterPrev" => Some(Self::FocusFilterPrev),
"ToggleDropdown" => Some(Self::ToggleDropdown),
"SelectNextOption" => Some(Self::SelectNextOption),
"SelectPrevOption" => Some(Self::SelectPrevOption),
"ExpandProject" => Some(Self::ExpandProject),
"CollapseProject" => Some(Self::CollapseProject),
"HeaderMoveNext" => Some(Self::HeaderMoveNext),
"HeaderMovePrev" => Some(Self::HeaderMovePrev),
"HeaderCycleSortOrder" => Some(Self::HeaderCycleSortOrder),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::SelectNextRow => "SelectNextRow",
Self::SelectPrevRow => "SelectPrevRow",
Self::SelectFirstRow => "SelectFirstRow",
Self::SelectLastRow => "SelectLastRow",
Self::NextPage => "NextPage",
Self::PrevPage => "PrevPage",
Self::ClearSelection => "ClearSelection",
Self::OpenSelectedTask => "OpenSelectedTask",
Self::Sync => "Sync",
Self::FocusSearch => "FocusSearch",
Self::FocusTable => "FocusTable",
Self::FocusTableHeaders => "FocusTableHeaders",
Self::FocusSidebar => "FocusSidebar",
Self::FocusSidebarProjects => "FocusSidebarProjects",
Self::FocusSidebarTags => "FocusSidebarTags",
Self::BlurInput => "BlurInput",
Self::CloseModal => "CloseModal",
Self::SaveModal => "SaveModal",
Self::ApplySearch => "ApplySearch",
Self::ClearFilters => "ClearFilters",
Self::ClearAllFilters => "ClearAllFilters",
Self::ClearProjectFilter => "ClearProjectFilter",
Self::ClearTagFilter => "ClearTagFilter",
Self::ClearSearchAndDropdowns => "ClearSearchAndDropdowns",
Self::FocusFilterNext => "FocusFilterNext",
Self::FocusFilterPrev => "FocusFilterPrev",
Self::ToggleDropdown => "ToggleDropdown",
Self::SelectNextOption => "SelectNextOption",
Self::SelectPrevOption => "SelectPrevOption",
Self::ExpandProject => "ExpandProject",
Self::CollapseProject => "CollapseProject",
Self::HeaderMoveNext => "HeaderMoveNext",
Self::HeaderMovePrev => "HeaderMovePrev",
Self::HeaderCycleSortOrder => "HeaderCycleSortOrder",
}
}
}
+40
View File
@@ -0,0 +1,40 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ContextId {
Global,
Table,
TableHeaders,
SidebarProjects,
SidebarTags,
Modal,
FilterBar,
TextInput,
}
impl ContextId {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"global" | "Global" => Some(Self::Global),
"table" | "Table" => Some(Self::Table),
"tableheaders" | "TableHeaders" => Some(Self::TableHeaders),
"sidebarprojects" | "SidebarProjects" => Some(Self::SidebarProjects),
"sidebartags" | "SidebarTags" => Some(Self::SidebarTags),
"modal" | "Modal" => Some(Self::Modal),
"filterbar" | "FilterBar" => Some(Self::FilterBar),
"textinput" | "TextInput" => Some(Self::TextInput),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Global => "Global",
Self::Table => "Table",
Self::TableHeaders => "TableHeaders",
Self::SidebarProjects => "SidebarProjects",
Self::SidebarTags => "SidebarTags",
Self::Modal => "Modal",
Self::FilterBar => "FilterBar",
Self::TextInput => "TextInput",
}
}
}
+413
View File
@@ -0,0 +1,413 @@
use super::{Command, ContextId, Key, KeyChord, KeymapLayer, Mods};
pub fn build_default_keymap() -> KeymapLayer {
let mut layer = KeymapLayer::new();
// Global bindings
layer.bind(
ContextId::Global,
KeyChord::new(Key::Char('r'), Mods::ctrl()),
Command::Sync,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Char('f'), Mods::ctrl()),
Command::FocusSearch,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Escape, Mods::none()),
Command::CloseModal,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Tab, Mods::none()),
Command::FocusTable,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Tab, Mods::shift()),
Command::FocusSidebar,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Char('c'), Mods::ctrl()),
Command::ClearAllFilters,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Char('p'), Mods::ctrl()),
Command::ClearProjectFilter,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Char('t'), Mods::ctrl()),
Command::ClearTagFilter,
);
layer.bind(
ContextId::Global,
KeyChord::new(Key::Char('x'), Mods::ctrl()),
Command::ClearSearchAndDropdowns,
);
// Table navigation
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('h'), Mods::ctrl()),
Command::FocusSidebarProjects,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('k'), Mods::ctrl()),
Command::FocusTableHeaders,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('j'), Mods::none()),
Command::SelectNextRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('k'), Mods::none()),
Command::SelectPrevRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::ArrowDown, Mods::none()),
Command::SelectNextRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::ArrowUp, Mods::none()),
Command::SelectPrevRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('g'), Mods::none()),
Command::SelectFirstRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('g'), Mods::shift()),
Command::SelectLastRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Home, Mods::none()),
Command::SelectFirstRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::End, Mods::none()),
Command::SelectLastRow,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::PageDown, Mods::none()),
Command::NextPage,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::PageUp, Mods::none()),
Command::PrevPage,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('l'), Mods::none()),
Command::NextPage,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('h'), Mods::none()),
Command::PrevPage,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Escape, Mods::none()),
Command::ClearSelection,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Enter, Mods::none()),
Command::OpenSelectedTask,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::ArrowLeft, Mods::none()),
Command::CollapseProject,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::ArrowRight, Mods::none()),
Command::ExpandProject,
);
// Table Headers navigation
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Char('h'), Mods::none()),
Command::HeaderMovePrev,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Char('l'), Mods::none()),
Command::HeaderMoveNext,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::ArrowLeft, Mods::none()),
Command::HeaderMovePrev,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::ArrowRight, Mods::none()),
Command::HeaderMoveNext,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Char('j'), Mods::none()),
Command::HeaderCycleSortOrder,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Char('k'), Mods::none()),
Command::HeaderCycleSortOrder,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Enter, Mods::none()),
Command::HeaderCycleSortOrder,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Space, Mods::none()),
Command::HeaderCycleSortOrder,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Char('j'), Mods::ctrl()),
Command::FocusTable,
);
layer.bind(
ContextId::TableHeaders,
KeyChord::new(Key::Char('k'), Mods::ctrl()),
Command::FocusSearch,
);
// Sidebar Projects navigation
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('l'), Mods::ctrl()),
Command::FocusTable,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('j'), Mods::ctrl()),
Command::FocusSidebarTags,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('j'), Mods::none()),
Command::SelectNextRow,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('k'), Mods::none()),
Command::SelectPrevRow,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::ArrowDown, Mods::none()),
Command::SelectNextRow,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::ArrowUp, Mods::none()),
Command::SelectPrevRow,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('g'), Mods::none()),
Command::SelectFirstRow,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('g'), Mods::shift()),
Command::SelectLastRow,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('h'), Mods::none()),
Command::CollapseProject,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Char('l'), Mods::none()),
Command::ExpandProject,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::ArrowLeft, Mods::none()),
Command::CollapseProject,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::ArrowRight, Mods::none()),
Command::ExpandProject,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Enter, Mods::none()),
Command::OpenSelectedTask,
);
layer.bind(
ContextId::SidebarProjects,
KeyChord::new(Key::Space, Mods::none()),
Command::OpenSelectedTask,
);
// Sidebar Tags navigation
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Char('l'), Mods::ctrl()),
Command::FocusTable,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Char('k'), Mods::ctrl()),
Command::FocusSidebarProjects,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Char('j'), Mods::none()),
Command::SelectNextRow,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Char('k'), Mods::none()),
Command::SelectPrevRow,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::ArrowDown, Mods::none()),
Command::SelectNextRow,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::ArrowUp, Mods::none()),
Command::SelectPrevRow,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Char('g'), Mods::none()),
Command::SelectFirstRow,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Char('g'), Mods::shift()),
Command::SelectLastRow,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Enter, Mods::none()),
Command::OpenSelectedTask,
);
layer.bind(
ContextId::SidebarTags,
KeyChord::new(Key::Space, Mods::none()),
Command::OpenSelectedTask,
);
// TextInput / FilterBar
layer.bind(
ContextId::TextInput,
KeyChord::new(Key::Escape, Mods::none()),
Command::BlurInput,
);
layer.bind(
ContextId::TextInput,
KeyChord::new(Key::Enter, Mods::none()),
Command::ApplySearch,
);
layer.bind(
ContextId::TextInput,
KeyChord::new(Key::Char('l'), Mods::ctrl()),
Command::FocusFilterNext,
);
layer.bind(
ContextId::TextInput,
KeyChord::new(Key::Char('h'), Mods::ctrl()),
Command::FocusFilterPrev,
);
layer.bind(
ContextId::TextInput,
KeyChord::new(Key::Char('j'), Mods::ctrl()),
Command::FocusTableHeaders,
);
// FilterBar (when focus is on dropdowns)
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Enter, Mods::none()),
Command::ToggleDropdown,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Space, Mods::none()),
Command::ToggleDropdown,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Char('j'), Mods::none()),
Command::SelectNextOption,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Char('k'), Mods::none()),
Command::SelectPrevOption,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::ArrowDown, Mods::none()),
Command::SelectNextOption,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::ArrowUp, Mods::none()),
Command::SelectPrevOption,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Char('l'), Mods::ctrl()),
Command::FocusFilterNext,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Char('h'), Mods::ctrl()),
Command::FocusFilterPrev,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Char('j'), Mods::ctrl()),
Command::FocusTableHeaders,
);
layer.bind(
ContextId::FilterBar,
KeyChord::new(Key::Escape, Mods::none()),
Command::BlurInput,
);
// Modal
layer.bind(
ContextId::Modal,
KeyChord::new(Key::Escape, Mods::none()),
Command::CloseModal,
);
layer.bind(
ContextId::Modal,
KeyChord::new(Key::Enter, Mods::ctrl()),
Command::SaveModal,
);
layer
}
+5
View File
@@ -0,0 +1,5 @@
use super::Command;
pub trait CommandDispatcher: Sized {
fn dispatch(&mut self, command: Command, cx: &mut gpui::Context<Self>) -> bool;
}
+131
View File
@@ -0,0 +1,131 @@
use std::collections::HashMap;
use super::{Command, ContextId, KeyChord};
#[derive(Debug, Clone)]
pub struct KeymapLayer {
bindings: HashMap<ContextId, HashMap<KeyChord, Command>>,
}
impl KeymapLayer {
pub fn new() -> Self {
Self {
bindings: HashMap::new(),
}
}
pub fn bind(&mut self, context: ContextId, chord: KeyChord, command: Command) {
self.bindings
.entry(context)
.or_insert_with(HashMap::new)
.insert(chord, command);
}
pub fn resolve(&self, context: ContextId, chord: &KeyChord) -> Option<Command> {
self.bindings
.get(&context)
.and_then(|map| map.get(chord))
.copied()
}
}
impl Default for KeymapLayer {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct KeymapStack {
layers: Vec<KeymapLayer>,
}
impl KeymapStack {
pub fn new() -> Self {
Self { layers: Vec::new() }
}
pub fn push_layer(&mut self, layer: KeymapLayer) {
self.layers.push(layer);
}
pub fn pop_layer(&mut self) -> Option<KeymapLayer> {
self.layers.pop()
}
pub fn resolve(&self, context: ContextId, chord: &KeyChord) -> Option<Command> {
for layer in self.layers.iter().rev() {
if let Some(cmd) = layer.resolve(context, chord) {
return Some(cmd);
}
}
if context != ContextId::Global {
for layer in self.layers.iter().rev() {
if let Some(cmd) = layer.resolve(ContextId::Global, chord) {
return Some(cmd);
}
}
}
None
}
}
impl Default for KeymapStack {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keymap::{Key, Mods};
#[test]
fn test_layer_bind_and_resolve() {
let mut layer = KeymapLayer::new();
let chord = KeyChord::new(Key::Char('j'), Mods::none());
layer.bind(ContextId::Table, chord, Command::SelectNextRow);
assert_eq!(
layer.resolve(ContextId::Table, &chord),
Some(Command::SelectNextRow)
);
assert_eq!(layer.resolve(ContextId::Global, &chord), None);
}
#[test]
fn test_stack_fallback_to_global() {
let mut stack = KeymapStack::new();
let mut layer = KeymapLayer::new();
let chord = KeyChord::new(Key::Char('r'), Mods::platform());
layer.bind(ContextId::Global, chord, Command::Sync);
stack.push_layer(layer);
assert_eq!(stack.resolve(ContextId::Table, &chord), Some(Command::Sync));
}
#[test]
fn test_stack_user_override() {
let mut stack = KeymapStack::new();
let mut default_layer = KeymapLayer::new();
let chord = KeyChord::new(Key::Char('j'), Mods::none());
default_layer.bind(ContextId::Table, chord, Command::SelectNextRow);
stack.push_layer(default_layer);
let mut user_layer = KeymapLayer::new();
user_layer.bind(ContextId::Table, chord, Command::SelectPrevRow);
stack.push_layer(user_layer);
assert_eq!(
stack.resolve(ContextId::Table, &chord),
Some(Command::SelectPrevRow)
);
}
}
+109
View File
@@ -0,0 +1,109 @@
mod active_context;
mod chord;
mod command;
mod context;
pub mod defaults;
mod dispatcher;
mod keymap;
pub use active_context::FocusTarget;
pub use chord::{Key, KeyChord, Mods};
pub use command::Command;
pub use context::ContextId;
pub use dispatcher::CommandDispatcher;
pub use keymap::{KeymapLayer, KeymapStack};
// Legacy compatibility - will be removed after refactor
use gpui::Modifiers;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyBinding {
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
PageUp,
PageDown,
Home,
End,
Enter,
Escape,
Refresh,
Focus,
}
impl KeyBinding {
pub fn from_keystroke(key: &str, modifiers: &Modifiers) -> Option<Self> {
if modifiers.platform {
return match key {
"r" | "R" => Some(Self::Refresh),
"f" | "F" => Some(Self::Focus),
_ => None,
};
}
if modifiers.control || modifiers.alt || modifiers.shift {
return None;
}
match key {
"ArrowUp" => Some(Self::ArrowUp),
"ArrowDown" => Some(Self::ArrowDown),
"ArrowLeft" => Some(Self::ArrowLeft),
"ArrowRight" => Some(Self::ArrowRight),
"PageUp" => Some(Self::PageUp),
"PageDown" => Some(Self::PageDown),
"Home" => Some(Self::Home),
"End" => Some(Self::End),
"Enter" => Some(Self::Enter),
"Escape" => Some(Self::Escape),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TableAction {
SelectNext,
SelectPrevious,
NextPage,
PreviousPage,
SelectFirst,
SelectLast,
ClearSelection,
ExpandProject,
CollapseProject,
}
impl TableAction {
pub fn from_binding(binding: &KeyBinding) -> Option<Self> {
match binding {
KeyBinding::ArrowUp => Some(Self::SelectPrevious),
KeyBinding::ArrowDown => Some(Self::SelectNext),
KeyBinding::PageUp => Some(Self::PreviousPage),
KeyBinding::PageDown => Some(Self::NextPage),
KeyBinding::Home => Some(Self::SelectFirst),
KeyBinding::End => Some(Self::SelectLast),
KeyBinding::Escape => Some(Self::ClearSelection),
KeyBinding::ArrowLeft => Some(Self::CollapseProject),
KeyBinding::ArrowRight => Some(Self::ExpandProject),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GlobalAction {
Refresh,
FocusSearch,
}
impl GlobalAction {
pub fn from_binding(binding: &KeyBinding) -> Option<Self> {
match binding {
KeyBinding::Refresh => Some(Self::Refresh),
KeyBinding::Focus => Some(Self::FocusSearch),
_ => None,
}
}
}
+1
View File
@@ -2,6 +2,7 @@ use crate::app::App;
mod app;
mod components;
mod keymap;
mod models;
mod task;
mod theme;
+15
View File
@@ -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()
+15
View File
@@ -152,6 +152,21 @@ impl ProjectTree {
}
}
pub fn get_expanded_paths(&self) -> HashMap<String, bool> {
self.expanded_paths.clone()
}
pub fn restore_expanded_paths(&mut self, paths: HashMap<String, bool>) {
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();
+283 -11
View File
@@ -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<TagItem>,
filter_state: Entity<FilterState>,
selected_section: SidebarSection,
selected_index: Option<usize>,
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<Self>) {
pub fn update_projects(&mut self, mut project_tree: ProjectTree, cx: &mut Context<Self>) {
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>) {
self.selected_section = section;
self.selected_index = Some(0);
self.scroll_to_selected();
cx.notify();
}
fn handle_project_click(
&mut self,
full_path: Option<String>,
@@ -87,29 +114,207 @@ impl Sidebar {
cx.notify();
}
fn render_projects(&self, cx: &mut Context<Self>) -> Vec<Div> {
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<Self>) {
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<Self>) {
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>) {
self.selected_index = Some(0);
self.scroll_to_selected();
cx.notify();
}
fn select_last(&mut self, cx: &mut Context<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) -> Vec<Stateful<Div>> {
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<Self>) -> Vec<Div> {
fn render_tags(&self, cx: &mut Context<Self>) -> Vec<Stateful<Div>> {
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<Self>) -> 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<Self>) -> 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),
),
)
+1 -5
View File
@@ -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()
+512 -21
View File
@@ -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<FilterState>,
@@ -232,6 +273,10 @@ pub struct TaskTable {
status_dropdown: gpui::Entity<Dropdown>,
priority_dropdown: gpui::Entity<Dropdown>,
due_dropdown: gpui::Entity<Dropdown>,
filter_bar_focus: FilterBarFocus,
filter_bar_focus_handle: gpui::FocusHandle,
focused_header: Option<SortColumn>,
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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>) {
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>) {
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<Self>) {
window.focus(&self.filter_bar_focus_handle);
cx.notify();
}
pub fn clear_search_input(&mut self, cx: &mut gpui::Context<Self>) {
self.search_input.update(cx, |input, cx| {
input.clear(cx);
});
}
pub fn reset_dropdowns(&mut self, cx: &mut gpui::Context<Self>) {
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>) {
self.filter_bar_focus = focus;
cx.notify();
}
pub fn focus_table_headers(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) {
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>) {
self.focused_header = None;
cx.notify();
}
pub fn header_move_next(&mut self, cx: &mut gpui::Context<Self>) {
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>) {
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<Self>) {
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>) {
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<Self>) {
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<Self>) {
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<Self>) {
use FilterBarFocus::*;
let toggle = |d: &gpui::Entity<Dropdown>, cx: &mut gpui::Context<Self>| {
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<Self>) {
use FilterBarFocus::*;
let select_next = |d: &gpui::Entity<Dropdown>, cx: &mut gpui::Context<Self>| {
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<Self>) {
use FilterBarFocus::*;
let select_prev = |d: &gpui::Entity<Dropdown>, cx: &mut gpui::Context<Self>| {
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>) {
self.close_all_dropdowns(cx);
self.filter_bar_focus = FilterBarFocus::None;
cx.notify();
}
pub fn get_active_filter_context(&self) -> Option<crate::keymap::ContextId> {
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>) {
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<Self>) -> 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),
),
)
}
}