diff --git a/Cargo.lock b/Cargo.lock index 0595063..1bc9f43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "task-warrior-gpui" -version = "0.2.0" +version = "0.3.0" dependencies = [ "chrono", "dirs 6.0.0", diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..5ad0534 --- /dev/null +++ b/package.nix @@ -0,0 +1,59 @@ +{ + lib, + rustPlatform, + fetchFromGitHub, + pkg-config, + cmake, + fontconfig, + libxkbcommon, + wayland, + vulkan-loader, + libxcb, +}: + +rustPlatform.buildRustPackage rec { + pname = "task-warrior-gpui"; + version = "0.3.0"; + + src = fetchFromGitHub { + owner = "0xErwin1"; + repo = "taskwarrior-gpui"; + rev = "v${version}"; + hash = "sha256-6nndsC5Q+4zmWFESpjas0c6BRb8medtLXoKzrPzpUus="; + }; + + cargoHash = "sha256-VPKLkLmMbzcILT8w1DDbvwOpeOraz2vSRiztWfR49nc="; + + nativeBuildInputs = [ + pkg-config + cmake + rustPlatform.bindgenHook + ]; + + buildInputs = [ + fontconfig + libxkbcommon + wayland + libxcb + ]; + + postFixup = '' + patchelf $out/bin/task-warrior-gpui --add-rpath ${ + lib.makeLibraryPath [ + wayland + vulkan-loader + ] + } + ''; + + meta = { + description = "A desktop GUI for TaskWarrior built with GPUI"; + homepage = "https://github.com/0xErwin1/taskwarrior-gpui"; + license = with lib.licenses; [ + mit + asl20 + ]; + mainProgram = "task-warrior-gpui"; + platforms = lib.platforms.linux; + }; +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..278a871 --- /dev/null +++ b/shell.nix @@ -0,0 +1,17 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + buildInputs = [ + pkgs.wayland + pkgs.libxkbcommon + pkgs.vulkan-loader + pkgs.fontconfig + ]; + + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ + pkgs.wayland + pkgs.libxkbcommon + pkgs.vulkan-loader + pkgs.fontconfig + ]; +} diff --git a/src/components/date_picker.rs b/src/components/date_picker.rs new file mode 100644 index 0000000..d8d9aca --- /dev/null +++ b/src/components/date_picker.rs @@ -0,0 +1,326 @@ +use std::sync::Arc; + +use chrono::{Datelike, NaiveDate, Utc}; +use gpui::prelude::*; +use gpui::{Corner, anchored, deferred, point, px}; + +use crate::theme::ActiveTheme; +use crate::ui::{DATE_FORMAT, clickable_control_style}; + +pub struct DatePicker { + id: gpui::ElementId, + value: Option, + view_month: NaiveDate, + open: bool, + placeholder: gpui::SharedString, + on_select: Option, &mut gpui::Context) + Send + Sync>>, +} + +impl DatePicker { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + value: None, + view_month: Utc::now().date_naive().with_day(1).unwrap(), + open: false, + placeholder: "Select date".into(), + on_select: None, + } + } + + pub fn on_select( + mut self, + handler: Arc, &mut gpui::Context) + Send + Sync>, + ) -> Self { + self.on_select = Some(handler); + self + } + + pub fn is_open(&self) -> bool { + self.open + } + + pub fn open(&mut self, cx: &mut gpui::Context) { + self.view_month = self + .value + .unwrap_or_else(|| Utc::now().date_naive()) + .with_day(1) + .unwrap(); + self.open = true; + cx.notify(); + } + + pub fn close(&mut self, cx: &mut gpui::Context) { + self.open = false; + cx.notify(); + } + + fn toggle_open(&mut self, cx: &mut gpui::Context) { + if self.open { + self.close(cx); + } else { + self.open(cx); + } + } + + pub fn set_value(&mut self, value: Option, cx: &mut gpui::Context) { + self.value = value; + cx.notify(); + } + + fn select(&mut self, date: Option, cx: &mut gpui::Context) { + self.value = date; + self.open = false; + if let Some(handler) = self.on_select.clone() { + handler(date, cx); + } + cx.notify(); + } + + fn shift_month(&mut self, delta: i32, cx: &mut gpui::Context) { + let total = self.view_month.year() * 12 + (self.view_month.month() as i32 - 1) + delta; + let year = total.div_euclid(12); + let month = total.rem_euclid(12) + 1; + if let Some(date) = NaiveDate::from_ymd_opt(year, month as u32, 1) { + self.view_month = date; + cx.notify(); + } + } + + fn handle_mouse_down_out( + &mut self, + _event: &gpui::MouseDownEvent, + _window: &mut gpui::Window, + cx: &mut gpui::Context, + ) { + if self.open { + self.open = false; + cx.notify(); + } + } + + fn render_calendar(&self, cx: &gpui::Context) -> gpui::AnyElement { + if !self.open { + return gpui::div().into_any_element(); + } + + let theme = cx.theme(); + let today = Utc::now().date_naive(); + let month_label = self.view_month.format("%B %Y").to_string(); + + let nav_button = |id: &'static str, label: &'static str, delta: i32| { + gpui::div() + .id(id) + .px_2() + .py_1() + .rounded_md() + .cursor_pointer() + .text_color(theme.muted) + .hover(|s| s.bg(theme.hover).text_color(theme.foreground)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, _, _, cx| this.shift_month(delta, cx)), + ) + .child(label) + }; + + let header = gpui::div() + .flex() + .items_center() + .justify_between() + .pb_1() + .child(nav_button("date-picker-prev", "‹", -1)) + .child( + gpui::div() + .text_sm() + .text_color(theme.foreground) + .child(month_label), + ) + .child(nav_button("date-picker-next", "›", 1)); + + let weekday_row = gpui::div().flex().children( + ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] + .into_iter() + .map(|d| { + gpui::div() + .w(px(28.0)) + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(theme.muted) + .child(d) + .into_any_element() + }), + ); + + let leading = self.view_month.weekday().num_days_from_monday() as i64; + let grid_start = self.view_month - chrono::Duration::days(leading); + + let mut weeks = Vec::with_capacity(6); + for week in 0..6i64 { + let mut row = gpui::div().flex(); + for day in 0..7i64 { + let date = grid_start + chrono::Duration::days(week * 7 + day); + let in_month = date.month() == self.view_month.month() + && date.year() == self.view_month.year(); + let is_today = date == today; + let is_selected = self.value == Some(date); + + let mut cell = gpui::div() + .id(("date-picker-day", date.num_days_from_ce() as u64)) + .w(px(28.0)) + .h(px(28.0)) + .flex() + .items_center() + .justify_center() + .rounded_md() + .text_sm() + .cursor_pointer(); + + cell = if is_selected { + cell.bg(theme.accent).text_color(theme.selection_foreground) + } else if is_today { + cell.border_1() + .border_color(theme.accent) + .text_color(theme.foreground) + } else if in_month { + cell.text_color(theme.foreground).hover(|s| s.bg(theme.hover)) + } else { + cell.text_color(theme.muted).hover(|s| s.bg(theme.hover)) + }; + + cell = cell.on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, _, _, cx| { + this.select(Some(date), cx); + }), + ); + + row = row.child(cell.child(date.day().to_string())); + } + weeks.push(row.into_any_element()); + } + + let today_button = gpui::div() + .id("date-picker-today") + .px_2() + .py_1() + .rounded_md() + .text_xs() + .cursor_pointer() + .text_color(theme.muted) + .hover(|s| s.text_color(theme.accent)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| { + let today = Utc::now().date_naive(); + this.select(Some(today), cx); + }), + ) + .child("Today"); + + let clear_button = gpui::div() + .id("date-picker-clear") + .px_2() + .py_1() + .rounded_md() + .text_xs() + .cursor_pointer() + .text_color(theme.muted) + .hover(|s| s.text_color(theme.error)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.select(None, cx); + }), + ) + .child("Clear"); + + let footer = gpui::div() + .flex() + .items_center() + .justify_between() + .pt_1() + .mt_1() + .border_t_1() + .border_color(theme.divider) + .child(today_button) + .child(clear_button); + + let popup = gpui::div() + .w(px(224.0)) + .p_2() + .border_1() + .border_color(theme.border) + .bg(theme.background) + .rounded_md() + .overflow_hidden() + .shadow_lg() + .occlude() + .child(header) + .child(weekday_row) + .children(weeks) + .child(footer); + + deferred( + anchored() + .anchor(Corner::TopLeft) + .offset(point(px(0.0), px(4.0))) + .snap_to_window() + .child(popup), + ) + .with_priority(1) + .into_any_element() + } +} + +impl gpui::Render for DatePicker { + fn render( + &mut self, + _window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let label = self + .value + .map(|d| d.format(DATE_FORMAT).to_string()) + .unwrap_or_else(|| self.placeholder.to_string()); + + let trigger = clickable_control_style( + gpui::div() + .child(label) + .child(gpui::div().text_color(theme.muted).child("▾")), + theme, + ); + + let trigger_wrap = gpui::div().flex_shrink_0().child(trigger).on_mouse_down( + gpui::MouseButton::Left, + cx.listener(Self::handle_trigger_mouse_down), + ); + + let mut container = gpui::div() + .id(self.id.clone()) + .flex() + .flex_col() + .child(trigger_wrap) + .child(self.render_calendar(cx)); + + if self.open { + container = container.on_mouse_down_out(cx.listener(Self::handle_mouse_down_out)); + } + + container + } +} + +impl DatePicker { + fn handle_trigger_mouse_down( + &mut self, + _event: &gpui::MouseDownEvent, + _window: &mut gpui::Window, + cx: &mut gpui::Context, + ) { + self.toggle_open(cx); + } +} diff --git a/src/components/input/mod.rs b/src/components/input/mod.rs index 88b4023..30da0be 100644 --- a/src/components/input/mod.rs +++ b/src/components/input/mod.rs @@ -276,19 +276,15 @@ impl Input { } fn refresh_suggestions(&mut self, cx: &mut gpui::Context) { - if let Some(suggest) = &self.suggest { - self.suggestions = suggest(&self.value); - self.active_suggestion = 0; - self.suggestions_open = !self.suggestions.is_empty() && !self.value.is_empty(); - cx.notify(); - } + self.open_suggestions(cx); } - fn open_suggestions(&mut self, cx: &mut gpui::Context) { + /// Recomputes and shows suggestions for the current value; public so a field can browse all options on focus. + pub fn open_suggestions(&mut self, cx: &mut gpui::Context) { if let Some(suggest) = &self.suggest { self.suggestions = suggest(&self.value); self.active_suggestion = 0; - self.suggestions_open = !self.suggestions.is_empty() && !self.value.is_empty(); + self.suggestions_open = !self.suggestions.is_empty(); cx.notify(); } } diff --git a/src/components/mod.rs b/src/components/mod.rs index 45da24f..5d4d5b8 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -2,6 +2,7 @@ pub mod action_button; pub mod button; pub mod chip; pub mod confirm_dialog; +pub mod date_picker; pub mod dialog; pub mod divider; pub mod field_row; diff --git a/src/view/task_detail_modal/bindings.rs b/src/view/task_detail_modal/bindings.rs index 96f24aa..8cdfc75 100644 --- a/src/view/task_detail_modal/bindings.rs +++ b/src/view/task_detail_modal/bindings.rs @@ -21,7 +21,6 @@ impl FocusMap { focus, ModalFocus::Description | ModalFocus::Project - | ModalFocus::Due | ModalFocus::TagsInput | ModalFocus::AnnotationsInput ) diff --git a/src/view/task_detail_modal/mod.rs b/src/view/task_detail_modal/mod.rs index 95f508a..5c2b1d7 100644 --- a/src/view/task_detail_modal/mod.rs +++ b/src/view/task_detail_modal/mod.rs @@ -4,6 +4,7 @@ use std::collections::HashSet; use std::sync::{Arc, Mutex}; use crate::components::button::{Dropdown, DropdownItem}; +use crate::components::date_picker::DatePicker; use crate::components::input::Input; use crate::components::toast::{ToastGlobal, ToastKind}; use crate::keymap::{Command, CommandDispatcher, ContextId}; @@ -45,7 +46,7 @@ struct ModalEntities { annotation_input: gpui::Entity, description_input: gpui::Entity, project_input: gpui::Entity, - due_input: gpui::Entity, + due_picker: gpui::Entity, tags_input: gpui::Entity, status_dropdown: gpui::Entity, priority_dropdown: gpui::Entity, @@ -102,15 +103,11 @@ impl TaskDetailModal { Input::new("task-edit-project", cx, "Project").with_suggest(Arc::new( move |query| { let query = query.trim(); - if query.is_empty() { - return Vec::new(); - } let Ok(list) = suggestions.lock() else { return Vec::new(); }; - let needle = query.to_lowercase(); let mut candidates = Vec::new(); let mut seen = HashSet::new(); @@ -146,6 +143,17 @@ impl TaskDetailModal { } } + // Empty query: show every known project, like a dropdown menu. + if query.is_empty() { + candidates.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + return candidates + .into_iter() + .take(20) + .map(crate::components::input::Suggestion::simple) + .collect(); + } + + let needle = query.to_lowercase(); let mut level_matches = Vec::new(); let mut prefix_matches = Vec::new(); let mut contains_matches = Vec::new(); @@ -182,7 +190,23 @@ impl TaskDetailModal { }) }; - let due_input = cx.new(|cx| Input::new("task-edit-due", cx, "Due (YYYY-MM-DD)")); + let due_picker = { + let modal_entity = modal_entity.clone(); + cx.new(|_cx| { + DatePicker::new("task-edit-due").on_select(Arc::new(move |date, cx| { + let modal_entity = modal_entity.clone(); + cx.defer(move |cx| { + let _ = modal_entity.update(cx, |modal, cx| { + modal.state.form.due = date + .map(|d| d.format(DATE_FORMAT).to_string()) + .unwrap_or_default(); + modal.update_field_error(FieldId::Due); + cx.notify(); + }); + }); + })) + }) + }; let tags_input = { let on_change_entity = modal_entity.clone(); @@ -313,7 +337,7 @@ impl TaskDetailModal { annotation_input, description_input, project_input, - due_input, + due_picker, tags_input, status_dropdown, priority_dropdown, @@ -638,8 +662,9 @@ impl TaskDetailModal { input.set_value_silent(form.project, cx); }); - self.entities.due_input.update(cx, |input, cx| { - input.set_value_silent(form.due, cx); + let due_date = NaiveDate::parse_from_str(form.due.trim(), DATE_FORMAT).ok(); + self.entities.due_picker.update(cx, |picker, cx| { + picker.set_value(due_date, cx); }); self.entities.tags_input.update(cx, |input, cx| { @@ -685,12 +710,6 @@ impl TaskDetailModal { cx.notify(); } - fn update_edit_due(&mut self, value: &str, cx: &mut gpui::Context) { - self.state.form.due = value.to_string(); - self.update_field_error(FieldId::Due); - cx.notify(); - } - fn update_edit_tags(&mut self, value: &str, cx: &mut gpui::Context) { self.state.form.tag_draft = value.to_string(); cx.notify(); @@ -721,7 +740,6 @@ impl TaskDetailModal { fn sync_form_from_inputs(&mut self, cx: &gpui::Context) { self.state.form.description = self.entities.description_input.read(cx).value().to_string(); self.state.form.project = self.entities.project_input.read(cx).value().to_string(); - self.state.form.due = self.entities.due_input.read(cx).value().to_string(); self.state.form.tag_draft = self.entities.tags_input.read(cx).value().to_string(); self.state.annotations.draft = self .entities @@ -903,8 +921,8 @@ impl TaskDetailModal { .project_input .update(cx, |input, cx| input.blur(window, cx)); self.entities - .due_input - .update(cx, |input, cx| input.blur(window, cx)); + .due_picker + .update(cx, |picker, cx| picker.close(cx)); self.entities .tags_input .update(cx, |input, cx| input.blur(window, cx)); @@ -989,11 +1007,6 @@ impl TaskDetailModal { .project_input .update(cx, |i, cx| i.focus(window, cx)); } - ModalFocus::Due => { - self.entities - .due_input - .update(cx, |i, cx| i.focus(window, cx)); - } ModalFocus::TagsInput => { self.entities .tags_input @@ -1004,13 +1017,16 @@ impl TaskDetailModal { .annotation_input .update(cx, |i, cx| i.focus(window, cx)); } - ModalFocus::None | ModalFocus::StatusDropdown | ModalFocus::PriorityDropdown => {} + ModalFocus::None + | ModalFocus::StatusDropdown + | ModalFocus::PriorityDropdown + | ModalFocus::Due => {} } return; } match focus { - ModalFocus::StatusDropdown | ModalFocus::PriorityDropdown => { + ModalFocus::StatusDropdown | ModalFocus::PriorityDropdown | ModalFocus::Due => { window.focus(&self.form_focus_handle); } ModalFocus::None => { @@ -1018,7 +1034,6 @@ impl TaskDetailModal { } ModalFocus::Description | ModalFocus::Project - | ModalFocus::Due | ModalFocus::TagsInput | ModalFocus::AnnotationsInput => {} } @@ -1040,12 +1055,14 @@ impl TaskDetailModal { self.entities .priority_dropdown .update(cx, |d, cx| d.close(cx)); + self.entities.due_picker.update(cx, |d, cx| d.close(cx)); } fn has_open_dropdown(&self, cx: &gpui::Context) -> bool { let status_open = self.entities.status_dropdown.read(cx).is_open(); let priority_open = self.entities.priority_dropdown.read(cx).is_open(); - status_open || priority_open + let due_open = self.entities.due_picker.read(cx).is_open(); + status_open || priority_open || due_open } pub fn toggle_focused_dropdown(&mut self, cx: &mut gpui::Context) { @@ -1090,11 +1107,22 @@ impl TaskDetailModal { } } } + ModalFocus::Due => { + self.entities.due_picker.update(cx, |d, cx| { + if d.is_open() { + d.close(cx); + } else { + d.open(cx); + } + }); + } _ => {} } cx.notify(); } + // ponytail: due picker is mouse-only (no arrow-key day navigation); + // add if keyboard-driven date entry is needed. pub fn select_next_dropdown_option(&mut self, cx: &mut gpui::Context) { let select_next = |d: &gpui::Entity, cx: &mut gpui::Context| { d.update(cx, |d, cx| { @@ -1802,7 +1830,7 @@ impl TaskDetailModal { if self.state.mode == ModalMode::Edit && matches!( self.state.modal_focus, - ModalFocus::StatusDropdown | ModalFocus::PriorityDropdown + ModalFocus::StatusDropdown | ModalFocus::PriorityDropdown | ModalFocus::Due ) { if self.state.edit_state == EditState::DropdownOpen { @@ -1911,13 +1939,12 @@ impl TaskDetailModal { } match self.state.modal_focus { - ModalFocus::StatusDropdown | ModalFocus::PriorityDropdown => { + ModalFocus::StatusDropdown | ModalFocus::PriorityDropdown | ModalFocus::Due => { self.state.edit_state = EditState::DropdownOpen; self.toggle_focused_dropdown(cx); } ModalFocus::Description | ModalFocus::Project - | ModalFocus::Due | ModalFocus::TagsInput | ModalFocus::AnnotationsInput => { self.state.edit_state = EditState::Editing; @@ -1930,14 +1957,10 @@ impl TaskDetailModal { .update(cx, |i, cx| i.focus(window, cx)); } ModalFocus::Project => { - self.entities - .project_input - .update(cx, |i, cx| i.focus(window, cx)); - } - ModalFocus::Due => { - self.entities - .due_input - .update(cx, |i, cx| i.focus(window, cx)); + self.entities.project_input.update(cx, |i, cx| { + i.focus(window, cx); + i.open_suggestions(cx); + }); } ModalFocus::TagsInput => { self.entities @@ -2126,7 +2149,7 @@ impl gpui::Render for TaskDetailModal { &self.entities.annotation_input, &self.entities.description_input, &self.entities.project_input, - &self.entities.due_input, + &self.entities.due_picker, &self.entities.tags_input, &self.entities.status_dropdown, &self.entities.priority_dropdown, diff --git a/src/view/task_detail_modal/render/mod.rs b/src/view/task_detail_modal/render/mod.rs index 1ac2e92..46c6c81 100644 --- a/src/view/task_detail_modal/render/mod.rs +++ b/src/view/task_detail_modal/render/mod.rs @@ -1,6 +1,7 @@ use gpui::prelude::*; use crate::components::button::Dropdown; +use crate::components::date_picker::DatePicker; use crate::components::input::Input; use crate::components::modal::ModalFrame; use crate::theme::ActiveTheme; @@ -15,7 +16,7 @@ pub(super) fn render_task_detail_modal( annotation_input: &gpui::Entity, description_input: &gpui::Entity, project_input: &gpui::Entity, - due_input: &gpui::Entity, + due_picker: &gpui::Entity, tags_input: &gpui::Entity, status_dropdown: &gpui::Entity, priority_dropdown: &gpui::Entity, @@ -57,7 +58,7 @@ pub(super) fn render_task_detail_modal( annotation_input, description_input, project_input, - due_input, + due_picker, tags_input, status_dropdown, priority_dropdown, diff --git a/src/view/task_detail_modal/render/panel.rs b/src/view/task_detail_modal/render/panel.rs index 2f83beb..2aeb659 100644 --- a/src/view/task_detail_modal/render/panel.rs +++ b/src/view/task_detail_modal/render/panel.rs @@ -6,6 +6,7 @@ use crate::components::action_button::ActionButton; use crate::components::button::Dropdown; use crate::components::chip::{Chip, ChipVariant}; use crate::components::confirm_dialog::ConfirmDialog; +use crate::components::date_picker::DatePicker; use crate::components::field_row::{FieldRow, KvRow}; use crate::components::input::Input; use crate::components::label::Label; @@ -116,7 +117,7 @@ pub(super) fn render_task_detail_panel( annotation_input: &gpui::Entity, description_input: &gpui::Entity, project_input: &gpui::Entity, - due_input: &gpui::Entity, + due_picker: &gpui::Entity, tags_input: &gpui::Entity, status_dropdown: &gpui::Entity, priority_dropdown: &gpui::Entity, @@ -246,7 +247,7 @@ where &priority_label, description_input, project_input, - due_input, + due_picker, status_dropdown, priority_dropdown, theme, @@ -468,7 +469,7 @@ fn render_overview_section( priority_label: &str, description_input: &gpui::Entity, project_input: &gpui::Entity, - due_input: &gpui::Entity, + due_picker: &gpui::Entity, status_dropdown: &gpui::Entity, priority_dropdown: &gpui::Entity, theme: &Theme, @@ -588,14 +589,33 @@ fn render_overview_section( let due_value = if is_editing { let focused = modal_focus == ModalFocus::Due; + let theme_clone = theme.clone(); + let picker_clone = due_picker.clone(); - crate::ui::focus_wrap(due_input.clone(), focused, theme) + gpui::div() + .relative() + .when(focused, |d| { + d.border_2() + .border_color(theme_clone.focus_ring) + .rounded_md() + .p_px() + }) .on_mouse_down( gpui::MouseButton::Left, - cx.listener(|modal, _, window, cx| { - modal.set_modal_focus_and_edit(ModalFocus::Due, Some(window), cx); + cx.listener(move |modal, _, window, cx| { + modal.set_modal_focus(ModalFocus::Due, Some(window), cx); + let picker = picker_clone.clone(); + + cx.defer(move |cx| { + picker.update(cx, |d, cx| { + if !d.is_open() { + d.open(cx); + } + }); + }); }), ) + .child(due_picker.clone()) .into_any_element() } else { value_label(due_text, theme.foreground)