Project · Tasks: priorities, issues, statuses, assignees, task rows

A new chapter, src/project. PriorityIndicator shows how urgent a task
is in three bars, urgent in red, its words in a tip or beside it.
IssueIdBadge names an issue by its team's key and number, as ENG-123.
StatusSelect picks a status from the workflow, each with its mark, and
AssigneePicker picks a person or no one; DueDatePicker is the forms
DatePicker. TaskItem is a task in a row: its box, priority and issue,
its title, then its due day, red when past and still open, and who has
it. TaskList puts them in a SelectableList: arrows move, Enter opens,
and a box's press or keys stay at the box. SubtaskList is the
documents Checklist, which gains a field that adds a step on Enter.

Along the way: forms::Choice::avatar leads a person's row with their
initials, in lists and in a Select's trigger; ListItem::struck strikes
a closed task's title; typography::Ellipsis now shapes with the text
style's run, so a strike or underline on its box shows; two Lucide
icons, circle-dashed and circle-dot-dashed.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 04:48:54 -04:00
1 parent c99465153d
commit 1633bc9293
20 files changed
+1417 -30

No files matched your search

+36 -5
View File
@@ -1,12 +1,12 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, div, prelude::*,
App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div, prelude::*,
};
use crate::{
forms::{Checkbox, OnFlag},
forms::{Checkbox, Enter, Input, OnFlag, OnValue, TextInput},
motion::ProgressBar,
theme::{ActiveTheme, TextSize},
typography::tabular,
@@ -59,13 +59,14 @@ impl RenderOnce for TodoItem {
}
}
/// To-dos under a title, with how many are done as a count and a bar.
/// To-dos under a title, with how many are done as a count and a bar. With `on_add`, a field under them adds one.
#[derive(IntoElement)]
pub struct Checklist {
id: ElementId,
title: SharedString,
items: Vec<(SharedString, bool)>,
on_toggle: Option<OnItem>,
adding: Option<(SharedString, OnValue)>,
}
impl Checklist {
@@ -82,9 +83,20 @@ impl Checklist {
.map(|(label, done)| (label.into(), done))
.collect(),
on_toggle: None,
adding: None,
}
}
/// A field under the items: Enter hands on its words, trimmed, and clears it.
pub fn on_add(
mut self,
placeholder: impl Into<SharedString>,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.adding = Some((placeholder.into(), Rc::new(handler)));
self
}
/// Tells which item changed, by index, and whether it is now done.
pub fn on_toggle(
mut self,
@@ -96,7 +108,25 @@ impl Checklist {
}
impl RenderOnce for Checklist {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let add = self.adding.map(|(placeholder, on_add)| {
let field = window.use_keyed_state((self.id.clone(), "add"), cx, |window, cx| {
TextInput::new(window, cx).placeholder(placeholder)
});
let (id, typed) = (self.id.clone(), field.clone());
div()
.capture_action(move |_: &Enter, window, cx| {
let words = typed.read(cx).text().trim().to_string();
if words.is_empty() {
return;
}
cx.stop_propagation();
log::info!("checklist {id:?}: add {words}");
typed.update(cx, |input, cx| input.set_text(String::new(), cx));
on_add(&words.into(), window, cx);
})
.child(Input::new(&field))
});
let theme = cx.theme();
let done = self.items.iter().filter(|(_, done)| *done).count();
let total = self.items.len();
@@ -142,5 +172,6 @@ impl RenderOnce for Checklist {
}
}),
)
.children(add)
}
}
+15 -2
View File
@@ -8,9 +8,10 @@ use gpui::{
};
use crate::{
data_display::Avatar,
motion,
primitives::{Icon, IconName, raise},
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
theme::{ActiveTheme, AvatarSize, ControlSize, Elevation, IconSize, Radius, TextSize},
typography::Ellipsis,
};
@@ -20,6 +21,7 @@ pub struct Choice {
pub value: SharedString,
pub label: SharedString,
pub icon: Option<IconName>,
pub avatar: Option<SharedString>,
pub note: Option<SharedString>,
pub disabled: bool,
pub depth: usize,
@@ -31,6 +33,7 @@ impl Choice {
value: value.into(),
label: label.into(),
icon: None,
avatar: None,
note: None,
disabled: false,
depth: 0,
@@ -48,6 +51,12 @@ impl Choice {
self
}
/// Leads with the initials of `name` on its tone, as a person's row does.
pub fn avatar(mut self, name: impl Into<SharedString>) -> Self {
self.avatar = Some(name.into());
self
}
/// Quiet text at the row's end, such as a count or a shortcut.
pub fn note(mut self, note: impl Into<SharedString>) -> Self {
self.note = Some(note.into());
@@ -100,6 +109,7 @@ pub(crate) fn marked_row(
checked: Option<bool>,
cx: &App,
) -> Stateful<Div> {
let id = id.into();
let theme = cx.theme();
let colors = &theme.colors;
let fg = if choice.disabled {
@@ -108,7 +118,7 @@ pub(crate) fn marked_row(
colors.fg
};
div()
.id(id)
.id(id.clone())
.flex()
.items_center()
.gap_2()
@@ -140,6 +150,9 @@ pub(crate) fn marked_row(
.when_some(choice.icon, |row, icon| {
row.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
})
.when_some(choice.avatar.clone(), |row, name| {
row.child(Avatar::new((id, "avatar"), name).size(AvatarSize::Xs))
})
.children(mark)
.child(
div()
+9 -1
View File
@@ -12,8 +12,9 @@ use super::{
options::{OnValue, Pick, Popup, Run, reveal, step},
};
use crate::{
data_display::Avatar,
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius},
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius},
typography::Ellipsis,
};
@@ -330,6 +331,13 @@ impl RenderOnce for Select {
trigger.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
},
)
.when_some(
shown.as_ref().and_then(|choice| choice.avatar.clone()),
|trigger, name| {
let id = (self.id.clone(), "avatar");
trigger.child(Avatar::new(id, name).size(AvatarSize::Xs))
},
)
.child(field_text(
shown.map(|choice| choice.label),
self.placeholder,
+1
View File
@@ -26,6 +26,7 @@ pub mod motion;
pub mod navigation;
pub mod overlays;
pub mod primitives;
pub mod project;
pub mod shell;
pub mod tables;
pub mod terminal;
+9
View File
@@ -28,6 +28,7 @@ pub struct ListItem {
disabled: bool,
strong: bool,
quiet: bool,
struck: bool,
on_click: Option<OnClick>,
}
@@ -45,6 +46,7 @@ impl ListItem {
disabled: false,
strong: false,
quiet: false,
struck: false,
on_click: None,
}
}
@@ -61,6 +63,12 @@ impl ListItem {
self
}
/// Strikes the title through, as a finished task has it.
pub fn struck(mut self, struck: bool) -> Self {
self.struck = struck;
self
}
/// A second line, quieter than the title.
pub fn description(mut self, text: impl Into<SharedString>) -> Self {
self.description = Some(text.into());
@@ -162,6 +170,7 @@ impl RenderOnce for ListItem {
line(self.title)
.text_size(theme.text_size(TextSize::Base))
.when(self.strong, |title| title.font_weight(FontWeight::SEMIBOLD))
.when(self.struck, |title| title.line_through())
.text_color(if self.quiet {
colors.fg_muted
} else {
+2
View File
@@ -93,7 +93,9 @@ icons! {
ChevronsUpDown => "chevrons-up-down",
CircleAlert => "circle-alert",
CircleCheck => "circle-check",
CircleDashed => "circle-dashed",
CircleDot => "circle-dot",
CircleDotDashed => "circle-dot-dashed",
CircleHelp => "circle-help",
CirclePause => "circle-pause",
CircleStop => "circle-stop",
+234
View File
@@ -0,0 +1,234 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Window, div, prelude::*, relative,
};
use super::work::{IssueId, Person, Priority, Status};
use crate::{
forms::{Choice, Select},
primitives::{Icon, IconName, Tooltip},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
typography::tabular,
};
type OnStatus = Rc<dyn Fn(Status, &mut Window, &mut App)>;
type OnAssignee = Rc<dyn Fn(Option<&Person>, &mut Window, &mut App)>;
/// A priority at a glance: three bars, as many lit as it is high, or a red mark for urgent. Its words show in a tip, or beside it when labeled.
#[derive(IntoElement)]
pub struct PriorityIndicator {
id: ElementId,
priority: Priority,
labeled: bool,
}
impl PriorityIndicator {
pub fn new(id: impl Into<ElementId>, priority: Priority) -> Self {
Self {
id: id.into(),
priority,
labeled: false,
}
}
/// Its words beside it.
pub fn labeled(mut self) -> Self {
self.labeled = true;
self
}
}
impl RenderOnce for PriorityIndicator {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let mark = match self.priority {
Priority::Urgent => Icon::new(IconName::CircleAlert)
.size(IconSize::Sm)
.color(colors.danger)
.into_any_element(),
priority => div()
.size(theme.icon_size(IconSize::Sm))
.py_0p5()
.flex()
.items_end()
.justify_center()
.gap_0p5()
.children([0.5, 0.75, 1.0].into_iter().enumerate().map(|(ix, tall)| {
let lit = ix < priority.bars();
div().w_0p5().h(relative(tall)).rounded_full().bg(if lit {
colors.fg_muted
} else {
colors.border_strong
})
}))
.into_any_element(),
};
let words = self.priority.words();
div()
.id(self.id)
.flex_none()
.flex()
.items_center()
.gap_1p5()
.child(mark)
.map(|row| match self.labeled {
true => row.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(words),
),
false => row.tooltip(Tooltip::text(words)),
})
}
}
/// An issue's name, as ENG-123, quiet and in even figures.
#[derive(IntoElement)]
pub struct IssueIdBadge {
issue: IssueId,
}
impl IssueIdBadge {
pub fn new(issue: IssueId) -> Self {
Self { issue }
}
}
impl RenderOnce for IssueIdBadge {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
tabular(div())
.flex_none()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(self.issue.to_string())
}
}
/// Where a task stands, picked from its workflow, each status with its mark.
#[derive(IntoElement)]
pub struct StatusSelect {
id: ElementId,
status: Status,
size: ControlSize,
on_change: Option<OnStatus>,
}
impl StatusSelect {
pub fn new(id: impl Into<ElementId>, status: Status) -> Self {
Self {
id: id.into(),
status,
size: ControlSize::default(),
on_change: None,
}
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
pub fn on_change(mut self, handler: impl Fn(Status, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for StatusSelect {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let choices =
Status::ALL.map(|status| Choice::new(status.key(), status.words()).icon(status.icon()));
let (id, on_change) = (self.id.clone(), self.on_change);
Select::new(self.id, choices)
.selected(self.status.key())
.size(self.size)
.on_change(move |key: &SharedString, window, cx| {
let status = Status::of_key(key);
log::info!("status select {id:?}: {}", status.words());
if let Some(on_change) = &on_change {
on_change(status, window, cx);
}
})
}
}
/// Who has a task, picked from people by name, or no one.
#[derive(IntoElement)]
pub struct AssigneePicker {
id: ElementId,
people: Vec<Person>,
assignee: Option<SharedString>,
size: ControlSize,
on_change: Option<OnAssignee>,
}
impl AssigneePicker {
pub fn new(id: impl Into<ElementId>, people: impl IntoIterator<Item = Person>) -> Self {
let people: Vec<Person> = people.into_iter().collect();
for (ix, person) in people.iter().enumerate() {
let twice = people[..ix].iter().any(|other| other.key == person.key);
assert!(!twice, "person {} twice", person.key);
}
Self {
id: id.into(),
people,
assignee: None,
size: ControlSize::default(),
on_change: None,
}
}
/// Who has it now, by key.
pub fn assignee(mut self, key: impl Into<SharedString>) -> Self {
self.assignee = Some(key.into());
self
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
/// Gets the person picked, or none for no one.
pub fn on_change(
mut self,
handler: impl Fn(Option<&Person>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for AssigneePicker {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
if let Some(key) = &self.assignee {
let known = self.people.iter().any(|person| person.key == *key);
assert!(known, "assignee picker {:?}: no person {key}", self.id);
}
let nobody = Choice::new("", "No assignee").icon(IconName::User);
let choices: Vec<Choice> = std::iter::once(nobody)
.chain(self.people.iter().map(|person| {
Choice::new(person.key.clone(), person.name.clone()).avatar(person.name.clone())
}))
.collect();
let (id, people, on_change) = (self.id.clone(), self.people, self.on_change);
Select::new(self.id, choices)
.selected(self.assignee.unwrap_or_default())
.size(self.size)
.on_change(move |key: &SharedString, window, cx| {
let person = people.iter().find(|person| person.key == *key);
log::info!(
"assignee picker {id:?}: {}",
person.map_or("no one", |person| person.name.as_ref())
);
if let Some(on_change) = &on_change {
on_change(person, window, cx);
}
})
}
}
+10
View File
@@ -0,0 +1,10 @@
mod marks;
mod tasks;
mod work;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use marks::{AssigneePicker, IssueIdBadge, PriorityIndicator, StatusSelect};
pub use tasks::{TaskItem, TaskList};
pub use work::{IssueId, Person, Priority, Status, Task};
+253
View File
@@ -0,0 +1,253 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, Styled, Window, div,
};
use jiff::{Timestamp, civil::Date};
use super::{
marks::{IssueIdBadge, PriorityIndicator},
work::{Status, Task, due_words},
};
use crate::{
data_display::Avatar,
forms::{Checkbox, OnFlag, OnValue},
lists::{ListItem, SelectableList},
primitives::{Icon, IconName},
theme::{ActiveTheme, AvatarSize, IconSize, TextSize},
typography::{format, fresh},
};
type OnTaskToggle = Rc<dyn Fn(&SharedString, bool, &mut Window, &mut App)>;
/// Today by the clock, in the system's zone.
fn clock_today(user: &str) -> Date {
Timestamp::now().to_zoned(format::system_zone(user)).date()
}
/// A task in a row: its box, its priority and its issue's name, its title, then when it is due and who has it. Closed, the title goes quiet and struck through; past due and still open, the day reads in red.
#[derive(IntoElement)]
pub struct TaskItem {
id: ElementId,
task: Task,
today: Option<Date>,
on_toggle: Option<OnFlag>,
}
impl TaskItem {
pub fn new(id: impl Into<ElementId>, task: Task) -> Self {
Self {
id: id.into(),
task,
today: None,
on_toggle: None,
}
}
/// The day due dates count from; the clock's otherwise.
pub fn today(mut self, day: Date) -> Self {
self.today = Some(day);
self
}
/// A box that ticks the task done; it gets whether the box is now ticked. Without it, the status mark leads.
pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
fn item(self, today: Date, cx: &App) -> ListItem {
let theme = cx.theme();
let colors = &theme.colors;
let task = self.task;
let lead: AnyElement = match self.on_toggle {
Some(on_toggle) => {
let (key, named) = (task.key.clone(), task.key.clone());
div()
.debug_selector(move || format!("task-box {named}"))
.flex_none()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_key_down(|event, _, cx| {
let key = event.keystroke.key.as_str();
if matches!(key, "space" | "enter") && !event.keystroke.modifiers.modified()
{
cx.stop_propagation();
}
})
.child(
Checkbox::new((self.id.clone(), "done"), task.status == Status::Done)
.on_change(move |done, window, cx| {
log::info!("task {key}: {}", if done { "done" } else { "open" });
on_toggle(done, window, cx)
}),
)
.into_any_element()
}
None => Icon::new(task.status.icon())
.size(IconSize::Sm)
.color(colors.fg_muted)
.into_any_element(),
};
let due = task.due.map(|due| {
let late = task.overdue(today);
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(if late { colors.danger } else { colors.fg_muted })
.child(due_words(due, today))
});
let assignee = match task.assignee {
Some(person) => Avatar::new((self.id.clone(), "assignee"), person.name)
.size(AvatarSize::Xs)
.into_any_element(),
None => div()
.size(theme.avatar_size(AvatarSize::Xs))
.flex()
.items_center()
.justify_center()
.child(
Icon::new(IconName::User)
.size(IconSize::Xs)
.color(colors.fg_subtle),
)
.into_any_element(),
};
let lead = div()
.flex()
.items_center()
.gap_2()
.child(lead)
.child(PriorityIndicator::new(
(self.id.clone(), "priority"),
task.priority,
))
.children(task.issue.map(IssueIdBadge::new));
let trail = div()
.flex_none()
.flex()
.items_center()
.gap_2()
.children(due)
.child(assignee);
let closed = task.status.closed();
ListItem::new(self.id, task.title)
.leading(lead)
.struck(closed)
.quiet(closed)
.trailing(trail)
}
}
impl RenderOnce for TaskItem {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let today = match self.today {
Some(day) => day,
None => {
fresh((self.id.clone(), "clock"), window, cx);
clock_today("task item")
}
};
self.item(today, cx)
}
}
/// Tasks in rows: arrows move between them, Enter or a double press opens one, and each box ticks its task done.
#[derive(IntoElement)]
pub struct TaskList {
id: ElementId,
tasks: Vec<Task>,
today: Option<Date>,
on_open: Option<OnValue>,
on_toggle: Option<OnTaskToggle>,
}
impl TaskList {
pub fn new(id: impl Into<ElementId>, tasks: impl IntoIterator<Item = Task>) -> Self {
let tasks: Vec<Task> = tasks.into_iter().collect();
for (ix, task) in tasks.iter().enumerate() {
let twice = tasks[..ix].iter().any(|other| other.key == task.key);
assert!(!twice, "task {} twice", task.key);
}
Self {
id: id.into(),
tasks,
today: None,
on_open: None,
on_toggle: None,
}
}
/// The day due dates count from; the clock's otherwise.
pub fn today(mut self, day: Date) -> Self {
self.today = Some(day);
self
}
/// Gets the key of the task opened.
pub fn on_open(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
/// Gets a task's key and whether its box is now ticked.
pub fn on_toggle(
mut self,
handler: impl Fn(&SharedString, bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TaskList {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
if self.tasks.is_empty() {
return div()
.debug_selector(|| "task-none".into())
.min_w_full()
.px_3()
.py_4()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child("No tasks.")
.into_any_element();
}
let today = match self.today {
Some(day) => day,
None => {
fresh((self.id.clone(), "clock"), window, cx);
clock_today("task list")
}
};
let list = self.tasks.into_iter().fold(
SelectableList::new((self.id.clone(), "tasks")),
|list, task| {
let key = task.key.clone();
let item = TaskItem::new((self.id.clone(), format!("task-{key}")), task);
let item = match self.on_toggle.clone() {
Some(toggle) => {
let ticked = key.clone();
item.on_toggle(move |done, window, cx| toggle(&ticked, done, window, cx))
}
None => item,
};
list.row(key, item.item(today, cx))
},
);
let (id, on_open) = (self.id.clone(), self.on_open);
div()
.debug_selector(|| "task-list".into())
.min_w_full()
.child(list.on_activate(move |key, window, cx| {
log::info!("task list {id:?}: open {key}");
if let Some(on_open) = &on_open {
on_open(key, window, cx);
}
}))
.into_any_element()
}
}
+193
View File
@@ -0,0 +1,193 @@
use gpui::{
AnyElement, Context, Entity, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers,
ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, div, px,
};
use jiff::civil::date;
use super::{AssigneePicker, IssueId, Person, Priority, Status, StatusSelect, Task, TaskList};
use crate::{documents::Checklist, forms::bind_keys, primitives::FocusNext, theme::Theme};
/// A view that shows one project part and keeps what it heard.
struct Desk {
part: fn(Entity<Desk>) -> AnyElement,
heard: Vec<String>,
}
impl Render for Desk {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().w(px(640.0)).child((self.part)(cx.entity()))
}
}
fn settle(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
}
cx.run_until_parked();
}
fn desk(
part: fn(Entity<Desk>) -> AnyElement,
cx: &mut TestAppContext,
) -> (Entity<Desk>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
bind_keys(cx);
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
});
let (host, cx) = cx.add_window_view(|_, _| Desk {
part,
heard: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
(host, cx)
}
fn note(owner: &Entity<Desk>, what: String, cx: &mut gpui::App) {
owner.update(cx, |desk, cx| {
desk.heard.push(what);
cx.notify();
});
}
fn heard(host: &Entity<Desk>, cx: &mut VisualTestContext) -> Vec<String> {
host.read_with(cx, |desk, _| desk.heard.clone())
}
/// Moves focus to the `nth` Tab stop from none.
fn tab_to(nth: usize, cx: &mut VisualTestContext) {
cx.update(|window, _| {
window.blur();
for _ in 0..nth {
window.focus_next();
}
});
settle(cx);
}
fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
settle(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("a key"),
});
settle(cx);
}
fn tasks() -> Vec<Task> {
let ana = Person::new("ana", "Ana Lima");
vec![
Task::new("brief", "Write the brief")
.issue(IssueId::new("ENG", 12))
.priority(Priority::High)
.due(date(2026, 9, 25))
.assignee(ana),
Task::new("specs", "Check the specs").status(Status::Done),
]
}
fn list(owner: Entity<Desk>) -> AnyElement {
let (opened, ticked) = (owner.clone(), owner);
TaskList::new("tasks", tasks())
.today(date(2026, 9, 27))
.on_open(move |key, _, cx| note(&opened, format!("open {key}"), cx))
.on_toggle(move |key, done, _, cx| note(&ticked, format!("tick {key} {done}"), cx))
.into_any_element()
}
#[gpui::test]
fn a_box_ticks_its_task_and_leaves_the_row_alone(cx: &mut TestAppContext) {
let (host, cx) = desk(list, cx);
let tick = cx.debug_bounds("task-box specs").expect("the box draws");
cx.simulate_click(tick.center(), Modifiers::none());
settle(cx);
tab_to(2, cx);
press("enter", cx);
tab_to(1, cx);
press("enter", cx);
assert_eq!(
heard(&host, cx),
["tick specs false", "tick brief true", "open brief"],
"a press on a box leaves the cursor, and Enter on a box does not open its row"
);
}
#[gpui::test]
fn enter_opens_the_task_under_the_cursor(cx: &mut TestAppContext) {
let (host, cx) = desk(list, cx);
tab_to(1, cx);
press("enter", cx);
press("down", cx);
press("enter", cx);
assert_eq!(heard(&host, cx), ["open brief", "open specs"]);
}
fn status(owner: Entity<Desk>) -> AnyElement {
StatusSelect::new("status", Status::Todo)
.on_change(move |status, _, cx| note(&owner, status.words().into(), cx))
.into_any_element()
}
#[gpui::test]
fn a_status_picked_by_key_hands_on_its_status(cx: &mut TestAppContext) {
let (host, cx) = desk(status, cx);
tab_to(1, cx);
cx.simulate_keystrokes("down down enter");
settle(cx);
assert_eq!(heard(&host, cx), ["In progress"]);
}
fn assignee(owner: Entity<Desk>) -> AnyElement {
let people = [
Person::new("ana", "Ana Lima"),
Person::new("ben", "Ben Ito"),
];
AssigneePicker::new("assignee", people)
.assignee("ana")
.on_change(move |person, _, cx| {
let name = person.map_or("no one".into(), |person| person.name.to_string());
note(&owner, name, cx)
})
.into_any_element()
}
#[gpui::test]
fn the_picker_hands_on_a_person_or_no_one(cx: &mut TestAppContext) {
let (host, cx) = desk(assignee, cx);
tab_to(1, cx);
cx.simulate_keystrokes("down down enter");
settle(cx);
tab_to(1, cx);
cx.simulate_keystrokes("down up enter");
settle(cx);
assert_eq!(heard(&host, cx), ["Ben Ito", "no one"]);
}
fn checklist(owner: Entity<Desk>) -> AnyElement {
Checklist::new(
"subtasks",
"Subtasks",
[("Outline", true), ("Draft", false)],
)
.on_add("Add a subtask", move |words, _, cx| {
note(&owner, format!("add {words}"), cx)
})
.into_any_element()
}
#[gpui::test]
fn a_checklist_adds_what_is_typed_and_clears(cx: &mut TestAppContext) {
let (host, cx) = desk(checklist, cx);
tab_to(3, cx);
cx.simulate_input(" Review ");
press("enter", cx);
press("enter", cx);
assert_eq!(
heard(&host, cx),
["add Review"],
"the field clears, and empty adds nothing"
);
}
+276
View File
@@ -0,0 +1,276 @@
use std::fmt;
use gpui::SharedString;
use jiff::civil::Date;
use crate::primitives::IconName;
/// Where a task stands in its workflow.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
Backlog,
Todo,
InProgress,
InReview,
Done,
Canceled,
}
impl Status {
pub const ALL: [Status; 6] = [
Status::Backlog,
Status::Todo,
Status::InProgress,
Status::InReview,
Status::Done,
Status::Canceled,
];
pub fn words(self) -> &'static str {
match self {
Status::Backlog => "Backlog",
Status::Todo => "Todo",
Status::InProgress => "In progress",
Status::InReview => "In review",
Status::Done => "Done",
Status::Canceled => "Canceled",
}
}
pub fn icon(self) -> IconName {
match self {
Status::Backlog => IconName::CircleDashed,
Status::Todo => IconName::Circle,
Status::InProgress => IconName::CircleDot,
Status::InReview => IconName::CircleDotDashed,
Status::Done => IconName::CircleCheck,
Status::Canceled => IconName::CircleX,
}
}
/// Done or canceled: nothing more is owed.
pub fn closed(self) -> bool {
matches!(self, Status::Done | Status::Canceled)
}
pub(crate) fn key(self) -> &'static str {
match self {
Status::Backlog => "backlog",
Status::Todo => "todo",
Status::InProgress => "in-progress",
Status::InReview => "in-review",
Status::Done => "done",
Status::Canceled => "canceled",
}
}
pub(crate) fn of_key(key: &str) -> Status {
Status::ALL
.into_iter()
.find(|status| status.key() == key)
.unwrap_or_else(|| panic!("no status {key}"))
}
}
/// How urgent a task is, from none to urgent.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Priority {
None,
Low,
Medium,
High,
Urgent,
}
impl Priority {
pub fn words(self) -> &'static str {
match self {
Priority::None => "No priority",
Priority::Low => "Low",
Priority::Medium => "Medium",
Priority::High => "High",
Priority::Urgent => "Urgent",
}
}
/// Bars lit of three; urgent has a mark of its own.
pub(crate) fn bars(self) -> usize {
match self {
Priority::None => 0,
Priority::Low => 1,
Priority::Medium => 2,
Priority::High | Priority::Urgent => 3,
}
}
}
/// An issue's name within its team: the team's key and a number, as ENG-123.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IssueId {
team: SharedString,
number: u32,
}
impl IssueId {
/// The key is one to five capital letters, and numbers start at one.
pub fn new(team: impl Into<SharedString>, number: u32) -> Self {
let team = team.into();
let letters = (1..=5).contains(&team.len()) && team.chars().all(|c| c.is_ascii_uppercase());
assert!(letters, "issue team key {team} is not one to five capitals");
assert!(number > 0, "issue {team} numbers start at one");
Self { team, number }
}
}
impl fmt::Display for IssueId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}-{}", self.team, self.number)
}
}
/// Someone work can go to.
#[derive(Clone, Debug, PartialEq)]
pub struct Person {
pub key: SharedString,
pub name: SharedString,
}
impl Person {
pub fn new(key: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
let key = key.into();
assert!(!key.is_empty(), "a person needs a key");
Self {
key,
name: name.into(),
}
}
}
/// A piece of work: its key, its issue's name if it has one, what it is, where it stands, how urgent, when it is due, and who has it.
#[derive(Clone, Debug, PartialEq)]
pub struct Task {
pub key: SharedString,
pub issue: Option<IssueId>,
pub title: SharedString,
pub status: Status,
pub priority: Priority,
pub due: Option<Date>,
pub assignee: Option<Person>,
}
impl Task {
pub fn new(key: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
Self {
key: key.into(),
issue: None,
title: title.into(),
status: Status::Todo,
priority: Priority::None,
due: None,
assignee: None,
}
}
pub fn issue(mut self, id: IssueId) -> Self {
self.issue = Some(id);
self
}
pub fn status(mut self, status: Status) -> Self {
self.status = status;
self
}
pub fn priority(mut self, priority: Priority) -> Self {
self.priority = priority;
self
}
pub fn due(mut self, day: Date) -> Self {
self.due = Some(day);
self
}
pub fn assignee(mut self, person: Person) -> Self {
self.assignee = Some(person);
self
}
/// Due before `today` and still open.
pub fn overdue(&self, today: Date) -> bool {
!self.status.closed() && self.due.is_some_and(|due| due < today)
}
}
/// A due day in few words from `today`: Today, Tomorrow, Yesterday, a weekday within the week ahead, else the date, with its year when not this one.
pub(crate) fn due_words(due: Date, today: Date) -> String {
let days = due.since(today).expect("days between two dates").get_days();
match days {
0 => "Today".into(),
1 => "Tomorrow".into(),
-1 => "Yesterday".into(),
2..=6 => due.strftime("%a").to_string(),
_ if due.year() == today.year() => due.strftime("%b %-d").to_string(),
_ => due.strftime("%b %-d, %Y").to_string(),
}
}
#[cfg(test)]
mod tests {
use jiff::civil::date;
use super::{IssueId, Priority, Status, Task, due_words};
#[test]
fn due_days_read_near_then_far() {
let today = date(2026, 9, 27);
let words: Vec<String> = [27, 28, 26, 30]
.map(|day| due_words(date(2026, 9, day), today))
.into();
assert_eq!(words, ["Today", "Tomorrow", "Yesterday", "Wed"]);
assert_eq!(due_words(date(2026, 10, 4), today), "Oct 4");
assert_eq!(due_words(date(2026, 9, 20), today), "Sep 20");
assert_eq!(due_words(date(2027, 1, 8), today), "Jan 8, 2027");
}
#[test]
fn statuses_go_by_their_keys() {
for status in Status::ALL {
assert_eq!(Status::of_key(status.key()), status);
}
assert!(Status::Canceled.closed() && !Status::InReview.closed());
}
#[test]
fn priorities_rise_and_light_their_bars() {
assert!(Priority::Urgent > Priority::High && Priority::Low > Priority::None);
let bars = [
Priority::None,
Priority::Low,
Priority::Medium,
Priority::High,
]
.map(Priority::bars);
assert_eq!(bars, [0, 1, 2, 3]);
}
#[test]
fn only_an_open_task_past_its_day_is_overdue() {
let today = date(2026, 9, 27);
let late = Task::new("a", "A").due(date(2026, 9, 26));
assert!(late.overdue(today));
assert!(!late.clone().status(Status::Canceled).overdue(today));
assert!(!late.due(today).overdue(today));
}
#[test]
fn an_issue_reads_as_its_team_and_number() {
assert_eq!(IssueId::new("ENG", 123).to_string(), "ENG-123");
}
#[test]
#[should_panic(expected = "issue team key eng is not one to five capitals")]
fn a_team_key_is_in_capitals() {
IssueId::new("eng", 1);
}
}
+1 -8
View File
@@ -40,14 +40,7 @@ pub(crate) fn text_width(text: &str, size: Pixels, window: &Window) -> Pixels {
fn shape(text: &str, window: &mut Window) -> ShapedLine {
let style = window.text_style();
let run = TextRun {
len: text.len(),
font: style.font(),
color: style.color,
background_color: None,
underline: None,
strikethrough: None,
};
let run = style.to_run(text.len());
let size = style.font_size.to_pixels(window.rem_size());
window
.text_system()