{
+ 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()
diff --git a/src/forms/select.rs b/src/forms/select.rs
index a99d2f8..3884000 100644
--- a/src/forms/select.rs
+++ b/src/forms/select.rs
@@ -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,
diff --git a/src/lib.rs b/src/lib.rs
index a0cebc3..aa376d3 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -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;
diff --git a/src/lists/item.rs b/src/lists/item.rs
index 77f010b..f6bb9f6 100644
--- a/src/lists/item.rs
+++ b/src/lists/item.rs
@@ -28,6 +28,7 @@ pub struct ListItem {
disabled: bool,
strong: bool,
quiet: bool,
+ struck: bool,
on_click: Option
,
}
@@ -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) -> 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 {
diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs
index a1958a3..5cc2830 100644
--- a/src/primitives/icon.rs
+++ b/src/primitives/icon.rs
@@ -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",
diff --git a/src/project/marks.rs b/src/project/marks.rs
new file mode 100644
index 0000000..d257f3e
--- /dev/null
+++ b/src/project/marks.rs
@@ -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;
+type OnAssignee = Rc, &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, 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,
+}
+
+impl StatusSelect {
+ pub fn new(id: impl Into, 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,
+ assignee: Option,
+ size: ControlSize,
+ on_change: Option,
+}
+
+impl AssigneePicker {
+ pub fn new(id: impl Into, people: impl IntoIterator- ) -> Self {
+ let people: Vec = 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) -> 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 = 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);
+ }
+ })
+ }
+}
diff --git a/src/project/mod.rs b/src/project/mod.rs
new file mode 100644
index 0000000..768907e
--- /dev/null
+++ b/src/project/mod.rs
@@ -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};
diff --git a/src/project/tasks.rs b/src/project/tasks.rs
new file mode 100644
index 0000000..ed7bf05
--- /dev/null
+++ b/src/project/tasks.rs
@@ -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;
+
+/// 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,
+ on_toggle: Option,
+}
+
+impl TaskItem {
+ pub fn new(id: impl Into, 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,
+ today: Option,
+ on_open: Option,
+ on_toggle: Option,
+}
+
+impl TaskList {
+ pub fn new(id: impl Into, tasks: impl IntoIterator
- ) -> Self {
+ let tasks: Vec = 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()
+ }
+}
diff --git a/src/project/tests.rs b/src/project/tests.rs
new file mode 100644
index 0000000..6b3d246
--- /dev/null
+++ b/src/project/tests.rs
@@ -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) -> AnyElement,
+ heard: Vec,
+}
+
+impl Render for Desk {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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) -> AnyElement,
+ cx: &mut TestAppContext,
+) -> (Entity, &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, what: String, cx: &mut gpui::App) {
+ owner.update(cx, |desk, cx| {
+ desk.heard.push(what);
+ cx.notify();
+ });
+}
+
+fn heard(host: &Entity, cx: &mut VisualTestContext) -> Vec {
+ 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 {
+ 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) -> 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) -> 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) -> 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) -> 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"
+ );
+}
diff --git a/src/project/work.rs b/src/project/work.rs
new file mode 100644
index 0000000..a448494
--- /dev/null
+++ b/src/project/work.rs
@@ -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, 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, name: impl Into) -> 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,
+ pub title: SharedString,
+ pub status: Status,
+ pub priority: Priority,
+ pub due: Option,
+ pub assignee: Option,
+}
+
+impl Task {
+ pub fn new(key: impl Into, title: impl Into) -> 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 = [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);
+ }
+}
diff --git a/src/typography/fit.rs b/src/typography/fit.rs
index eb2da59..f4612b9 100644
--- a/src/typography/fit.rs
+++ b/src/typography/fit.rs
@@ -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()
diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md
index 019c605..f03750e 100644
--- a/tasks/ch31-43.md
+++ b/tasks/ch31-43.md
@@ -1,25 +1,34 @@
# Chapters 31–43
-## T31 Project — `src/project`
+## T31a Project · Tasks — `src/project`
+- [x] PriorityIndicator
+- [x] IssueIdBadge
+- [x] StatusSelect
+- [x] AssigneePicker
+- [x] DueDatePicker → `forms::DatePicker`
+- [x] TaskItem — check, priority, due date, assignee
+- [x] TaskList
+- [x] SubtaskList → `documents::Checklist`, which gains a field to add one
+
+## T31b Project · Issues and boards
+
+- [ ] IssueCard
+- [ ] LabelManager
- [ ] KanbanBoard / KanbanColumn / KanbanCard
-- [ ] TaskList
-- [ ] TaskItem — check, priority, due date, assignee
+- [ ] Sprint Board → `project::KanbanBoard`
- [ ] TaskDetailPanel
-- [ ] SubtaskList
+
+## T31c Project · Plans
+
- [ ] GanttChart / TimelineView → `charts::GanttChart`
- [ ] Roadmap
-- [ ] Sprint Board → `project::KanbanBoard`
- [ ] Burndown Chart
-- [ ] PriorityIndicator
-- [ ] StatusSelect
-- [ ] AssigneePicker
-- [ ] DueDatePicker → `forms::DatePicker`
-- [ ] LabelManager
-- [ ] IssueCard
-- [ ] IssueIdBadge
- [ ] MilestoneProgress
- [ ] WorkloadView
+
+## T31d Project · Time and databases
+
- [ ] TimeTracker
- [ ] Pomodoro Timer
- [ ] DatabaseView (from T21c) — table, board, gallery, calendar, timeline