Add date picker and task improvements

This commit is contained in:
2026-09-10 14:08:54 +02:00
parent cd00f19c4c
commit c520847146
10 changed files with 499 additions and 57 deletions
Generated
+1 -1
View File
@@ -6497,7 +6497,7 @@ dependencies = [
[[package]]
name = "task-warrior-gpui"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"chrono",
"dirs 6.0.0",
+59
View File
@@ -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;
};
}
+17
View File
@@ -0,0 +1,17 @@
{ pkgs ? import <nixpkgs> {} }:
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
];
}
+326
View File
@@ -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<NaiveDate>,
view_month: NaiveDate,
open: bool,
placeholder: gpui::SharedString,
on_select: Option<Arc<dyn Fn(Option<NaiveDate>, &mut gpui::Context<Self>) + Send + Sync>>,
}
impl DatePicker {
pub fn new(id: impl Into<gpui::ElementId>) -> 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<dyn Fn(Option<NaiveDate>, &mut gpui::Context<Self>) + 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>) {
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>) {
self.open = false;
cx.notify();
}
fn toggle_open(&mut self, cx: &mut gpui::Context<Self>) {
if self.open {
self.close(cx);
} else {
self.open(cx);
}
}
pub fn set_value(&mut self, value: Option<NaiveDate>, cx: &mut gpui::Context<Self>) {
self.value = value;
cx.notify();
}
fn select(&mut self, date: Option<NaiveDate>, cx: &mut gpui::Context<Self>) {
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<Self>) {
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<Self>,
) {
if self.open {
self.open = false;
cx.notify();
}
}
fn render_calendar(&self, cx: &gpui::Context<Self>) -> 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<Self>,
) -> 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>,
) {
self.toggle_open(cx);
}
}
+4 -8
View File
@@ -276,19 +276,15 @@ impl Input {
}
fn refresh_suggestions(&mut self, cx: &mut gpui::Context<Self>) {
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<Self>) {
/// 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<Self>) {
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();
}
}
+1
View File
@@ -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;
-1
View File
@@ -21,7 +21,6 @@ impl FocusMap {
focus,
ModalFocus::Description
| ModalFocus::Project
| ModalFocus::Due
| ModalFocus::TagsInput
| ModalFocus::AnnotationsInput
)
+62 -39
View File
@@ -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<Input>,
description_input: gpui::Entity<Input>,
project_input: gpui::Entity<Input>,
due_input: gpui::Entity<Input>,
due_picker: gpui::Entity<DatePicker>,
tags_input: gpui::Entity<Input>,
status_dropdown: gpui::Entity<Dropdown>,
priority_dropdown: gpui::Entity<Dropdown>,
@@ -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>) {
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>) {
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>) {
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<Self>) -> 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<Self>) {
@@ -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<Self>) {
let select_next = |d: &gpui::Entity<Dropdown>, cx: &mut gpui::Context<Self>| {
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,
+3 -2
View File
@@ -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<Input>,
description_input: &gpui::Entity<Input>,
project_input: &gpui::Entity<Input>,
due_input: &gpui::Entity<Input>,
due_picker: &gpui::Entity<DatePicker>,
tags_input: &gpui::Entity<Input>,
status_dropdown: &gpui::Entity<Dropdown>,
priority_dropdown: &gpui::Entity<Dropdown>,
@@ -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,
+26 -6
View File
@@ -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<OnCloseClick>(
annotation_input: &gpui::Entity<Input>,
description_input: &gpui::Entity<Input>,
project_input: &gpui::Entity<Input>,
due_input: &gpui::Entity<Input>,
due_picker: &gpui::Entity<DatePicker>,
tags_input: &gpui::Entity<Input>,
status_dropdown: &gpui::Entity<Dropdown>,
priority_dropdown: &gpui::Entity<Dropdown>,
@@ -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<Input>,
project_input: &gpui::Entity<Input>,
due_input: &gpui::Entity<Input>,
due_picker: &gpui::Entity<DatePicker>,
status_dropdown: &gpui::Entity<Dropdown>,
priority_dropdown: &gpui::Entity<Dropdown>,
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)