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:
+334
-4
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use super::Command;
|
||||
|
||||
pub trait CommandDispatcher: Sized {
|
||||
fn dispatch(&mut self, command: Command, cx: &mut gpui::Context<Self>) -> bool;
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use crate::app::App;
|
||||
|
||||
mod app;
|
||||
mod components;
|
||||
mod keymap;
|
||||
mod models;
|
||||
mod task;
|
||||
mod theme;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user