feat: Refine filters and dropdown UI for task table

Update task table filters and dropdowns, including due-date options, layout tweaks, and style polish.
Keep panel style refinement intact so layout stays stable.
This commit is contained in:
Ignacio Perez
2025-12-27 16:00:38 -03:00
parent 45d1036fa7
commit 29a23017f2
8 changed files with 566 additions and 56 deletions
+2 -2
View File
@@ -20,9 +20,9 @@ impl gpui::Render for App {
fn render( fn render(
&mut self, &mut self,
_window: &mut gpui::Window, _window: &mut gpui::Window,
_cx: &mut gpui::Context<Self>, cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement { ) -> impl gpui::IntoElement {
let theme = _cx.theme(); let theme = cx.theme();
let content = div() let content = div()
.flex() .flex()
+100 -20
View File
@@ -3,17 +3,31 @@ use std::sync::Arc;
use gpui::prelude::*; use gpui::prelude::*;
use crate::components::button::Button; use crate::components::button::Button;
use crate::components::label::Label;
use crate::theme::ActiveTheme; use crate::theme::ActiveTheme;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct DropdownItem { pub struct DropdownItem {
pub label: gpui::SharedString, pub label: gpui::SharedString,
pub value: gpui::SharedString,
} }
impl DropdownItem { impl DropdownItem {
pub fn new(label: impl Into<gpui::SharedString>) -> Self { pub fn new(label: impl Into<gpui::SharedString>) -> Self {
let label = label.into();
Self {
value: label.clone(),
label,
}
}
pub fn with_value(
label: impl Into<gpui::SharedString>,
value: impl Into<gpui::SharedString>,
) -> Self {
Self { Self {
label: label.into(), label: label.into(),
value: value.into(),
} }
} }
} }
@@ -27,6 +41,7 @@ pub struct Dropdown {
disabled: bool, disabled: bool,
loading: bool, loading: bool,
placeholder: gpui::SharedString, placeholder: gpui::SharedString,
label_prefix: Option<gpui::SharedString>,
on_select: Option<Arc<dyn Fn(usize, &DropdownItem, &mut gpui::Context<Self>) + Send + Sync>>, on_select: Option<Arc<dyn Fn(usize, &DropdownItem, &mut gpui::Context<Self>) + Send + Sync>>,
} }
@@ -41,6 +56,7 @@ impl Dropdown {
disabled: false, disabled: false,
loading: false, loading: false,
placeholder: "Seleccionar".into(), placeholder: "Seleccionar".into(),
label_prefix: None,
on_select: None, on_select: None,
} }
} }
@@ -68,6 +84,11 @@ impl Dropdown {
self self
} }
pub fn label_prefix(mut self, prefix: impl Into<gpui::SharedString>) -> Self {
self.label_prefix = Some(prefix.into());
self
}
pub fn selected_index(mut self, index: usize) -> Self { pub fn selected_index(mut self, index: usize) -> Self {
self.selected_index = Some(index); self.selected_index = Some(index);
self self
@@ -108,6 +129,23 @@ impl Dropdown {
.and_then(|index| self.items.get(index).map(|item| item.label.clone())) .and_then(|index| self.items.get(index).map(|item| item.label.clone()))
} }
pub fn set_selected_index(&mut self, index: usize, cx: &mut gpui::Context<Self>) {
if index < self.items.len() {
self.selected_index = Some(index);
cx.notify();
}
}
pub fn set_items(&mut self, items: Vec<DropdownItem>, cx: &mut gpui::Context<Self>) {
self.items = items;
if let Some(selected) = self.selected_index {
if selected >= self.items.len() {
self.selected_index = None;
}
}
cx.notify();
}
fn toggle_open(&mut self, cx: &mut gpui::Context<Self>) { fn toggle_open(&mut self, cx: &mut gpui::Context<Self>) {
if self.disabled || self.loading || self.items.is_empty() { if self.disabled || self.loading || self.items.is_empty() {
return; return;
@@ -168,15 +206,21 @@ impl Dropdown {
let is_selected = self.selected_index == Some(index); let is_selected = self.selected_index == Some(index);
let mut row = gpui::div() let mut row = gpui::div()
.id(index) .id(index)
.w_full()
.px_2() .px_2()
.py_1() .py_1()
.text_sm()
.whitespace_nowrap()
.bg(theme.background)
.text_color(if is_selected { .text_color(if is_selected {
theme.selection_foreground theme.selection_foreground
} else { } else {
theme.foreground theme.foreground
}) })
.when(is_selected, |el| el.bg(theme.selection)) .when(is_selected, |el| el.bg(theme.selection))
.hover(|s: gpui::StyleRefinement| s.bg(theme.selection)) .when(!is_disabled, |el| {
el.hover(|s: gpui::StyleRefinement| s.bg(theme.selection))
})
.child(item.label.clone()); .child(item.label.clone());
if is_disabled { if is_disabled {
@@ -198,13 +242,17 @@ impl Dropdown {
.absolute() .absolute()
.top_full() .top_full()
.left_0() .left_0()
.right_0() .min_w(gpui::rems(12.0))
.min_w_full()
.mt_1() .mt_1()
.occlude()
.p_1()
.border_1() .border_1()
.border_color(theme.border) .border_color(theme.border)
.bg(theme.panel) .bg(theme.background)
.rounded_md() .rounded_md()
.overflow_hidden() .overflow_hidden()
.shadow_lg()
.children(items) .children(items)
.into_any_element() .into_any_element()
} }
@@ -216,28 +264,60 @@ impl gpui::Render for Dropdown {
_window: &mut gpui::Window, _window: &mut gpui::Window,
cx: &mut gpui::Context<Self>, cx: &mut gpui::Context<Self>,
) -> impl IntoElement { ) -> impl IntoElement {
let theme = cx.theme();
let is_disabled = self.disabled || self.loading; let is_disabled = self.disabled || self.loading;
let label = self let disabled = is_disabled || self.items.is_empty();
let mut label = self
.selected_label() .selected_label()
.unwrap_or_else(|| self.placeholder.clone()); .unwrap_or_else(|| self.placeholder.clone());
if let Some(prefix) = &self.label_prefix {
let mut trigger = if let Some(button) = self.button.clone() { label = format!("{}: {}", prefix, label).into();
let mut button = button;
button.on_click = None;
button.label = Some(label.clone());
button.disabled(is_disabled).loading(self.loading)
} else {
Button::label(self.id.clone(), label)
.disabled(is_disabled)
.loading(self.loading)
};
if is_disabled || self.items.is_empty() {
trigger = trigger.disabled(true);
} }
let mut trigger_wrap = gpui::div().child(trigger); let arrow = "";
if !is_disabled && !self.items.is_empty() {
let trigger: gpui::AnyElement = if let Some(button) = self.button.clone() {
let mut button = button;
button.on_click = None;
button.label = Some(format!("{} {}", label, arrow).into());
button
.disabled(disabled)
.loading(self.loading)
.bg(theme.background)
.border_color(theme.border)
.text_color(theme.foreground)
.into_any_element()
} else {
let mut trigger = gpui::div()
.flex()
.items_center()
.gap_2()
.px_3()
.py_2()
.rounded_md()
.border_1()
.border_color(theme.border)
.bg(theme.background)
.text_sm()
.text_color(theme.foreground)
.child(Label::new(label.clone()))
.child(Label::new(arrow).text_color(theme.muted));
if disabled {
trigger = trigger.text_color(theme.muted).cursor_not_allowed();
} else {
trigger = trigger
.cursor_pointer()
.hover(|s: gpui::StyleRefinement| s.bg(theme.selection));
}
trigger.into_any_element()
};
let mut trigger_wrap = gpui::div()
.min_w(gpui::rems(12.0))
.child(trigger);
if !disabled {
trigger_wrap = trigger_wrap.on_mouse_down( trigger_wrap = trigger_wrap.on_mouse_down(
gpui::MouseButton::Left, gpui::MouseButton::Left,
cx.listener(Self::handle_trigger_mouse_down), cx.listener(Self::handle_trigger_mouse_down),
+2 -1
View File
@@ -7,6 +7,7 @@ use gpui::{
use std::sync::Arc; use std::sync::Arc;
use crate::components::icon::{Icon, IconSize}; use crate::components::icon::{Icon, IconSize};
use crate::components::label::Label;
use crate::theme::ActiveTheme; use crate::theme::ActiveTheme;
fn darken(color: gpui::Rgba, amount: f32) -> gpui::Rgba { fn darken(color: gpui::Rgba, amount: f32) -> gpui::Rgba {
@@ -264,7 +265,7 @@ impl RenderOnce for Button {
} }
if let Some(label) = self.label { if let Some(label) = self.label {
base = base.child(label); base = base.child(Label::new(label).text_color(fg));
} }
if self.loading { if self.loading {
+7 -2
View File
@@ -78,7 +78,8 @@ impl gpui::RenderOnce for Panel {
let children: Vec<gpui::AnyElement> = self.content.drain(..).collect(); let children: Vec<gpui::AnyElement> = self.content.drain(..).collect();
gpui::div() let mut base = gpui::div()
.relative()
.size_full() .size_full()
.bg(theme.panel) .bg(theme.panel)
.border(gpui::px(self.border)) .border(gpui::px(self.border))
@@ -87,6 +88,10 @@ impl gpui::RenderOnce for Panel {
.p(gpui::px(self.padding)) .p(gpui::px(self.padding))
.overflow_hidden() .overflow_hidden()
.children(header) .children(header)
.children(children) .children(children);
base.style().refine(&self.style);
base
} }
} }
+73 -10
View File
@@ -1,5 +1,7 @@
use std::collections::HashSet; use std::collections::HashSet;
use chrono::NaiveDate;
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct FilterState { pub struct FilterState {
pub selected_project: Option<String>, pub selected_project: Option<String>,
@@ -45,6 +47,17 @@ impl StatusFilter {
Self::Deleted, Self::Deleted,
] ]
} }
pub fn from_index(index: usize) -> Self {
Self::all_variants().get(index).copied().unwrap_or_default()
}
pub fn to_index(&self) -> usize {
Self::all_variants()
.iter()
.position(|v| v == self)
.unwrap_or(0)
}
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -76,6 +89,17 @@ impl PriorityFilter {
pub fn all_variants() -> &'static [Self] { pub fn all_variants() -> &'static [Self] {
&[Self::All, Self::High, Self::Medium, Self::Low, Self::None] &[Self::All, Self::High, Self::Medium, Self::Low, Self::None]
} }
pub fn from_index(index: usize) -> Self {
Self::all_variants().get(index).copied().unwrap_or_default()
}
pub fn to_index(&self) -> usize {
Self::all_variants()
.iter()
.position(|v| v == self)
.unwrap_or(0)
}
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -85,6 +109,7 @@ pub enum DueFilter {
Today, Today,
ThisWeek, ThisWeek,
NoDate, NoDate,
OnDate(NaiveDate),
} }
impl Default for DueFilter { impl Default for DueFilter {
@@ -94,16 +119,6 @@ impl Default for DueFilter {
} }
impl DueFilter { impl DueFilter {
pub fn as_str(&self) -> &'static str {
match self {
Self::All => "All",
Self::Overdue => "Overdue",
Self::Today => "Today",
Self::ThisWeek => "This Week",
Self::NoDate => "No Date",
}
}
pub fn all_variants() -> &'static [Self] { pub fn all_variants() -> &'static [Self] {
&[ &[
Self::All, Self::All,
@@ -113,6 +128,54 @@ impl DueFilter {
Self::NoDate, Self::NoDate,
] ]
} }
pub fn from_index(index: usize) -> Self {
Self::all_variants().get(index).copied().unwrap_or_default()
}
pub fn to_index(&self) -> usize {
Self::all_variants()
.iter()
.position(|v| v == self)
.unwrap_or(0)
}
pub fn label(&self) -> String {
match self {
Self::All => "All".to_string(),
Self::Overdue => "Overdue".to_string(),
Self::Today => "Today".to_string(),
Self::ThisWeek => "This Week".to_string(),
Self::NoDate => "No Date".to_string(),
Self::OnDate(date) => date.format("%d-%m-%Y").to_string(),
}
}
pub fn value_key(&self) -> String {
match self {
Self::All => "all".to_string(),
Self::Overdue => "overdue".to_string(),
Self::Today => "today".to_string(),
Self::ThisWeek => "this_week".to_string(),
Self::NoDate => "none".to_string(),
Self::OnDate(date) => format!("date:{}", date.format("%Y-%m-%d")),
}
}
pub fn from_value(value: &str) -> Option<Self> {
match value {
"all" => Some(Self::All),
"overdue" => Some(Self::Overdue),
"today" => Some(Self::Today),
"this_week" => Some(Self::ThisWeek),
"none" => Some(Self::NoDate),
_ => value.strip_prefix("date:").and_then(|date| {
NaiveDate::parse_from_str(date, "%Y-%m-%d")
.ok()
.map(Self::OnDate)
}),
}
}
} }
impl FilterState { impl FilterState {
+8 -1
View File
@@ -1,6 +1,6 @@
use std::collections::HashSet; use std::collections::HashSet;
use chrono::{DateTime, Utc}; use chrono::{DateTime, NaiveDate, Utc};
use super::model::{Task, TaskPriority, TaskStatus}; use super::model::{Task, TaskPriority, TaskStatus};
use crate::models::{DueFilter, FilterState, PriorityFilter, StatusFilter}; use crate::models::{DueFilter, FilterState, PriorityFilter, StatusFilter};
@@ -20,6 +20,7 @@ pub enum DueDateFilter {
NoDate, NoDate,
Before(DateTime<Utc>), Before(DateTime<Utc>),
After(DateTime<Utc>), After(DateTime<Utc>),
OnDate(NaiveDate),
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -112,6 +113,7 @@ impl From<&FilterState> for TaskFilter {
DueFilter::Today => Some(DueDateFilter::Today), DueFilter::Today => Some(DueDateFilter::Today),
DueFilter::ThisWeek => Some(DueDateFilter::ThisWeek), DueFilter::ThisWeek => Some(DueDateFilter::ThisWeek),
DueFilter::NoDate => Some(DueDateFilter::NoDate), DueFilter::NoDate => Some(DueDateFilter::NoDate),
DueFilter::OnDate(date) => Some(DueDateFilter::OnDate(date)),
}; };
if !state.search_text.is_empty() { if !state.search_text.is_empty() {
@@ -228,6 +230,11 @@ impl TaskFilter {
return false; return false;
} }
} }
DueDateFilter::OnDate(date) => {
if !task.due.map(|d| d.date_naive() == *date).unwrap_or(false) {
return false;
}
}
} }
} }
+61 -2
View File
@@ -71,6 +71,22 @@ impl Sidebar {
cx.notify(); cx.notify();
} }
fn handle_clear_tags(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.filter_state.update(cx, |filter, cx| {
filter.active_tags.clear();
cx.notify();
});
cx.notify();
}
fn handle_clear_project(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.filter_state.update(cx, |filter, cx| {
filter.selected_project = None;
cx.notify();
});
cx.notify();
}
fn render_projects(&self, cx: &mut Context<Self>) -> Vec<Div> { fn render_projects(&self, cx: &mut Context<Self>) -> Vec<Div> {
let theme = cx.theme(); let theme = cx.theme();
let filter = self.filter_state.read(cx); let filter = self.filter_state.read(cx);
@@ -259,6 +275,9 @@ impl Render for Sidebar {
let theme = cx.theme().clone(); let theme = cx.theme().clone();
let projects = self.render_projects(cx); let projects = self.render_projects(cx);
let tags = self.render_tags(cx); let tags = self.render_tags(cx);
let filter = self.filter_state.read(cx);
let has_project = filter.selected_project.is_some();
let has_tags = !filter.active_tags.is_empty();
Panel::new("Sidebar").border(1.0).padding(0.0).child( Panel::new("Sidebar").border(1.0).padding(0.0).child(
div() div()
@@ -276,6 +295,9 @@ impl Render for Sidebar {
.child( .child(
div() div()
.flex_shrink_0() .flex_shrink_0()
.flex()
.items_center()
.justify_between()
.px_3() .px_3()
.py_2() .py_2()
.border_b_1() .border_b_1()
@@ -285,7 +307,24 @@ impl Render for Sidebar {
.text_sm() .text_sm()
.font_weight(gpui::FontWeight::BOLD) .font_weight(gpui::FontWeight::BOLD)
.text_color(theme.foreground), .text_color(theme.foreground),
), )
.when(has_project, |this| {
this.child(
div()
.id("clear-project")
.text_xs()
.text_color(theme.muted)
.cursor_pointer()
.hover(|s| s.text_color(theme.accent))
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|view, _, window, cx| {
view.handle_clear_project(window, cx);
}),
)
.child("Clear"),
)
}),
) )
.child( .child(
div() div()
@@ -308,6 +347,9 @@ impl Render for Sidebar {
.child( .child(
div() div()
.flex_shrink_0() .flex_shrink_0()
.flex()
.items_center()
.justify_between()
.px_3() .px_3()
.py_2() .py_2()
.border_b_1() .border_b_1()
@@ -317,7 +359,24 @@ impl Render for Sidebar {
.text_sm() .text_sm()
.font_weight(gpui::FontWeight::BOLD) .font_weight(gpui::FontWeight::BOLD)
.text_color(theme.foreground), .text_color(theme.foreground),
), )
.when(has_tags, |this| {
this.child(
div()
.id("clear-tags")
.text_xs()
.text_color(theme.muted)
.cursor_pointer()
.hover(|s| s.text_color(theme.accent))
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|view, _, window, cx| {
view.handle_clear_tags(window, cx);
}),
)
.child("Clear"),
)
}),
) )
.child( .child(
div() div()
+313 -18
View File
@@ -1,10 +1,16 @@
use std::cmp::Ordering; use std::cmp::Ordering;
use std::collections::BTreeSet;
use std::sync::Arc;
use gpui::prelude::*; use gpui::prelude::*;
use crate::{ use crate::{
components, components::{
models::FilterState, self,
button::{Dropdown, DropdownItem},
input::Input,
},
models::{DueFilter, FilterState, PriorityFilter, StatusFilter},
task::{self, TaskFilter, TaskService}, task::{self, TaskFilter, TaskService},
theme::{self, ActiveTheme}, theme::{self, ActiveTheme},
}; };
@@ -216,14 +222,93 @@ pub struct TaskTable {
selected_page_idx: Option<usize>, selected_page_idx: Option<usize>,
selected_global_idx: Option<usize>, selected_global_idx: Option<usize>,
need_reload: bool, need_reload: bool,
filter_bar_height: gpui::Pixels,
search_input: gpui::Entity<Input>,
status_dropdown: gpui::Entity<Dropdown>,
priority_dropdown: gpui::Entity<Dropdown>,
due_dropdown: gpui::Entity<Dropdown>,
} }
impl TaskTable { impl TaskTable {
pub fn new( pub fn new(
id: impl Into<gpui::ElementId>, id: impl Into<gpui::ElementId>,
filter_state: gpui::Entity<FilterState>, filter_state: gpui::Entity<FilterState>,
_cx: &mut gpui::Context<Self>, cx: &mut gpui::Context<Self>,
) -> Self { ) -> Self {
let search_input = {
let filter_state = filter_state.clone();
cx.new(|cx| {
Input::new("filter-search", cx, "Search...").with_on_change(Arc::new(
move |value: &str, cx: &mut gpui::Context<Input>| {
cx.update_entity(&filter_state, |filter, cx| {
filter.search_text = value.to_string();
cx.notify();
});
},
))
})
};
let status_items = StatusFilter::all_variants()
.iter()
.map(|status| DropdownItem::new(status.as_str()))
.collect::<Vec<_>>();
let status_dropdown = {
let filter_state = filter_state.clone();
cx.new(|_cx| {
Dropdown::new("filter-status")
.items(status_items)
.label_prefix("Status")
.selected_index(StatusFilter::default().to_index())
.on_select(Arc::new(move |index, _item, cx| {
let selected = StatusFilter::from_index(index);
cx.update_entity(&filter_state, |filter, cx| {
filter.status_filter = selected;
cx.notify();
});
}))
})
};
let priority_items = PriorityFilter::all_variants()
.iter()
.map(|priority| DropdownItem::new(priority.as_str()))
.collect::<Vec<_>>();
let priority_dropdown = {
let filter_state = filter_state.clone();
cx.new(|_cx| {
Dropdown::new("filter-priority")
.items(priority_items)
.label_prefix("Priority")
.selected_index(PriorityFilter::default().to_index())
.on_select(Arc::new(move |index, _item, cx| {
let selected = PriorityFilter::from_index(index);
cx.update_entity(&filter_state, |filter, cx| {
filter.priority_filter = selected;
cx.notify();
});
}))
})
};
let due_dropdown = {
let filter_state = filter_state.clone();
cx.new(|_cx| {
Dropdown::new("filter-due")
.items(vec![DropdownItem::with_value("All", "all")])
.label_prefix("Due")
.selected_index(0)
.on_select(Arc::new(move |_index, item, cx| {
let selected =
DueFilter::from_value(item.value.as_ref()).unwrap_or(DueFilter::All);
cx.update_entity(&filter_state, |filter, cx| {
filter.due_filter = selected;
cx.notify();
});
}))
})
};
Self { Self {
id: id.into(), id: id.into(),
filter_state, filter_state,
@@ -234,6 +319,11 @@ impl TaskTable {
selected_page_idx: None, selected_page_idx: None,
selected_global_idx: None, selected_global_idx: None,
need_reload: true, need_reload: true,
filter_bar_height: gpui::px(52.0),
search_input,
status_dropdown,
priority_dropdown,
due_dropdown,
} }
} }
@@ -297,14 +387,17 @@ impl TaskTable {
pub fn reload_tasks(&mut self, task_service: &mut TaskService, cx: &mut gpui::Context<Self>) { pub fn reload_tasks(&mut self, task_service: &mut TaskService, cx: &mut gpui::Context<Self>) {
let filter_state = self.filter_state.read(cx).clone(); let filter_state = self.filter_state.read(cx).clone();
let task_filter = TaskFilter::from(&filter_state); let all_tasks = task_service.get_all_tasks().unwrap_or_else(|e| {
log::error!("[TaskTable] Failed to load tasks: {}", e);
vec![]
});
let filtered_tasks = task_service let task_filter = TaskFilter::from(&filter_state);
.get_filtered_tasks(&task_filter) let mut due_filter = task_filter.clone();
.unwrap_or_else(|e| { due_filter.due_filter = None;
log::error!("[TaskTable] Failed to load filtered tasks: {}", e);
vec![] let filtered_tasks = task_filter.apply(&all_tasks);
}); let due_tasks = due_filter.apply(&all_tasks);
self.cached_tasks = filtered_tasks; self.cached_tasks = filtered_tasks;
self.apply_sort(); self.apply_sort();
@@ -314,6 +407,7 @@ impl TaskTable {
self.selected_page_idx = None; self.selected_page_idx = None;
self.recalculate_rows(); self.recalculate_rows();
self.sync_filter_dropdowns(&due_tasks, &filter_state, cx);
self.need_reload = false; self.need_reload = false;
@@ -324,6 +418,121 @@ impl TaskTable {
self.cached_rows = self.cached_tasks.iter().map(TaskRow::from).collect(); self.cached_rows = self.cached_tasks.iter().map(TaskRow::from).collect();
} }
fn sync_filter_dropdowns(
&mut self,
due_tasks: &[task::Task],
filter_state: &FilterState,
cx: &mut gpui::Context<Self>,
) {
let status_index = filter_state.status_filter.to_index();
self.status_dropdown.update(cx, |dropdown, cx| {
dropdown.set_selected_index(status_index, cx);
});
let priority_index = filter_state.priority_filter.to_index();
self.priority_dropdown.update(cx, |dropdown, cx| {
dropdown.set_selected_index(priority_index, cx);
});
let mut due_items = Self::build_due_items(due_tasks);
let selected_key = filter_state.due_filter.value_key();
let mut selected_index = due_items
.iter()
.position(|item| item.value.as_ref() == selected_key);
if selected_index.is_none() && filter_state.due_filter != DueFilter::All {
if let Some(item) = Self::due_item_from_filter(&filter_state.due_filter) {
due_items.push(item);
selected_index = Some(due_items.len() - 1);
}
}
self.due_dropdown.update(cx, |dropdown, cx| {
dropdown.set_items(due_items, cx);
if let Some(index) = selected_index {
dropdown.set_selected_index(index, cx);
}
});
}
fn build_due_items(tasks: &[task::Task]) -> Vec<DropdownItem> {
let now = chrono::Utc::now();
let today = now.date_naive();
let week_end = now + chrono::Duration::days(7);
let mut dates = BTreeSet::new();
let mut has_no_date = false;
let mut has_overdue = false;
let mut has_today = false;
let mut has_this_week = false;
for task in tasks {
match task.due {
None => {
has_no_date = true;
}
Some(due) => {
let date = due.date_naive();
dates.insert(date);
if due < now {
has_overdue = true;
}
if date == today {
has_today = true;
}
if due >= now && due <= week_end {
has_this_week = true;
}
}
}
}
let mut items = vec![DropdownItem::with_value("All", "all")];
if has_no_date {
items.push(DropdownItem::with_value("No Date", "none"));
}
if has_overdue {
items.push(DropdownItem::with_value("Overdue", "overdue"));
}
if has_today {
items.push(DropdownItem::with_value("Today", "today"));
}
if has_this_week {
items.push(DropdownItem::with_value("This Week", "this_week"));
}
for date in dates {
if date == today {
continue;
}
let label = Self::format_due_label(date);
let value = format!("date:{}", date.format("%Y-%m-%d"));
items.push(DropdownItem::with_value(label, value));
}
items
}
fn due_item_from_filter(filter: &DueFilter) -> Option<DropdownItem> {
match filter {
DueFilter::All => None,
DueFilter::OnDate(date) => Some(DropdownItem::with_value(
Self::format_due_label(*date),
filter.value_key(),
)),
_ => Some(DropdownItem::with_value(filter.label(), filter.value_key())),
}
}
fn format_due_label(date: chrono::NaiveDate) -> String {
let today = chrono::Utc::now().date_naive();
if date == today {
"Today".to_string()
} else {
date.format("%d-%m-%Y").to_string()
}
}
fn priority_color(&self, row: &TaskRow, cx: &gpui::Context<Self>) -> theme::Color { fn priority_color(&self, row: &TaskRow, cx: &gpui::Context<Self>) -> theme::Color {
let theme = cx.theme(); let theme = cx.theme();
@@ -376,6 +585,74 @@ impl TaskTable {
cx.notify(); cx.notify();
} }
fn handle_clear_filters(&mut self, cx: &mut gpui::Context<Self>) {
self.search_input.update(cx, |input, cx| {
input.clear(cx);
});
self.filter_state.update(cx, |filter, cx| {
filter.clear();
cx.notify();
});
}
fn render_filter_bar(&self, cx: &gpui::Context<Self>) -> impl gpui::IntoElement {
let theme = cx.theme();
let filter = self.filter_state.read(cx);
let has_filters = filter.has_active_filters();
let view = cx.entity().clone();
let bar = gpui::div()
.id("filter-bar")
.flex()
.flex_wrap()
.gap_2()
.items_center()
.px_4()
.py_2()
.bg(theme.panel)
.child(
gpui::div()
.w(gpui::rems(14.0))
.child(self.search_input.clone()),
)
.child(self.status_dropdown.clone())
.child(self.priority_dropdown.clone())
.child(self.due_dropdown.clone())
.when(has_filters, |this| {
this.child(
gpui::div()
.id("clear-all-filters")
.px_2()
.py_1()
.rounded_md()
.text_sm()
.text_color(theme.error)
.cursor_pointer()
.hover(|s| s.bg(theme.selection))
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|table, _, _, cx| {
table.handle_clear_filters(cx);
}),
)
.child("✕ Clear"),
)
});
gpui::div().child(bar).on_children_prepainted(move |bounds, _, cx| {
let Some(bounds) = bounds.first() else {
return;
};
let height = bounds.size.height;
cx.update_entity(&view, |table, cx| {
if table.filter_bar_height != height {
table.filter_bar_height = height;
cx.notify();
}
});
})
}
fn render_header_column( fn render_header_column(
&self, &self,
column: SortColumn, column: SortColumn,
@@ -424,9 +701,10 @@ impl TaskTable {
.items_center() .items_center()
.gap_2() .gap_2()
.px_4() .px_4()
.py_1() .py_2()
.border_b_1() .border_t_1()
.border_color(theme.border) .border_color(theme.border)
.border_b_1()
.bg(theme.panel) .bg(theme.panel)
.text_sm() .text_sm()
.font_weight(gpui::FontWeight::MEDIUM) .font_weight(gpui::FontWeight::MEDIUM)
@@ -632,12 +910,12 @@ impl TaskTable {
impl gpui::Render for TaskTable { impl gpui::Render for TaskTable {
fn render( fn render(
&mut self, &mut self,
window: &mut gpui::Window, _window: &mut gpui::Window,
cx: &mut gpui::Context<Self>, cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement { ) -> impl gpui::IntoElement {
let theme = cx.theme(); let theme = cx.theme();
let panel = components::panel::Panel::new(self.id.clone()); let panel = components::panel::Panel::new(self.id.clone()).flex().flex_col();
if self.need_reload { if self.need_reload {
return panel return panel
@@ -656,13 +934,19 @@ impl gpui::Render for TaskTable {
.map(|(index, row)| self.render_row(index, row, cx)) .map(|(index, row)| self.render_row(index, row, cx))
.collect(); .collect();
panel let filter_bar = self.render_filter_bar(cx);
let header = self.render_header(cx);
let footer = self.render_footer(cx);
let body = gpui::div()
.flex() .flex()
.flex_col() .flex_col()
.size_full() .flex_1()
.min_h_0()
.overflow_hidden() .overflow_hidden()
.bg(theme.background) .bg(theme.background)
.child(self.render_header(cx)) .child(gpui::div().h(self.filter_bar_height))
.child(header)
.child( .child(
gpui::div() gpui::div()
.id("task-table-content") .id("task-table-content")
@@ -671,6 +955,17 @@ impl gpui::Render for TaskTable {
.overflow_y_scroll() .overflow_y_scroll()
.child(gpui::div().flex().flex_col().children(rows)), .child(gpui::div().flex().flex_col().children(rows)),
) )
.child(self.render_footer(cx)) .child(footer);
panel
.child(body)
.child(
gpui::div()
.absolute()
.top_0()
.left_0()
.right_0()
.child(filter_bar),
)
} }
} }