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:
1 parent
c99465153d
commit
1633bc9293
20 files changed
+1417
-30
No files matched your search
@@ -168,6 +168,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- A default only the component can work out, such as a crop fitted to a picture it decodes, reaches the owner once through `window.defer` after render (`media::ImageUpload`).
|
||||
- A menu of times goes through `mail::times`: each row names its day and hour, a moment comes once (tomorrow is next Monday on a Sunday), and "Pick a time…" opens one dialog that waits for a time ahead of now (`ScheduleSend`, `SnoozePicker`).
|
||||
- A list whose cursor a field drives marks the cursor only while the field has focus; at rest its first row would look hovered (`mail::LabelPicker`). Its rows are option rows, and `forms::marked_row` leads one with a mark such as a label's hue.
|
||||
- A choice that names a person leads with their initials through `forms::Choice::avatar`, in the list and in a Select's trigger alike (`project::AssigneePicker`).
|
||||
- `typography::Ellipsis` shapes its line with the text style's own run, so a strike or an underline set on its box shows (`lists::ListItem::struck`, a closed task's title).
|
||||
|
||||
## Decisions
|
||||
|
||||
|
||||
@@ -110,8 +110,11 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
- [x] T30a Calendar · Months
|
||||
- [x] T30b Calendar · Time grid
|
||||
- [x] T30c Calendar · Agenda and people
|
||||
- [ ] T30d Calendar · Editing
|
||||
- [ ] T31 Project — `tasks/ch31-43.md`
|
||||
- [x] T30d Calendar · Editing
|
||||
- [ ] T31a Project · Tasks — `tasks/ch31-43.md`
|
||||
- [ ] T31b Project · Issues and boards
|
||||
- [ ] T31c Project · Plans
|
||||
- [ ] T31d Project · Time and databases
|
||||
- [ ] T32 Canvas & Design
|
||||
- [ ] T33 DB & Dev Tools
|
||||
- [ ] T34 Dashboard
|
||||
@@ -359,3 +362,4 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T30b | 2 | PASS | |
|
||||
| T30c | 1 | PASS | |
|
||||
| T30d | 1 | FAIL | a chosen day outlived the owner's days, and the next render panicked |
|
||||
| T30d | 2 | PASS | |
|
||||
@@ -0,0 +1,22 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-circle-dashed"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10.1 2.182a10 10 0 0 1 3.8 0" />
|
||||
<path d="M13.9 21.818a10 10 0 0 1-3.8 0" />
|
||||
<path d="M17.609 3.721a10 10 0 0 1 2.69 2.7" />
|
||||
<path d="M2.182 13.9a10 10 0 0 1 0-3.8" />
|
||||
<path d="M20.279 17.609a10 10 0 0 1-2.7 2.69" />
|
||||
<path d="M21.818 10.1a10 10 0 0 1 0 3.8" />
|
||||
<path d="M3.721 6.391a10 10 0 0 1 2.7-2.69" />
|
||||
<path d="M6.391 20.279a10 10 0 0 1-2.69-2.7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 674 B |
@@ -0,0 +1,23 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-circle-dot-dashed"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10.1 2.18a9.93 9.93 0 0 1 3.8 0" />
|
||||
<path d="M17.6 3.71a9.95 9.95 0 0 1 2.69 2.7" />
|
||||
<path d="M21.82 10.1a9.93 9.93 0 0 1 0 3.8" />
|
||||
<path d="M20.29 17.6a9.95 9.95 0 0 1-2.7 2.69" />
|
||||
<path d="M13.9 21.82a9.94 9.94 0 0 1-3.8 0" />
|
||||
<path d="M6.4 20.29a9.95 9.95 0 0 1-2.69-2.7" />
|
||||
<path d="M2.18 13.9a9.93 9.93 0 0 1 0-3.8" />
|
||||
<path d="M3.71 6.4a9.95 9.95 0 0 1 2.7-2.69" />
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 729 B |
@@ -24,6 +24,7 @@ mod motion;
|
||||
mod navigation;
|
||||
mod overlays;
|
||||
mod primitives;
|
||||
mod project;
|
||||
mod shell;
|
||||
mod tables;
|
||||
mod terminal;
|
||||
@@ -75,6 +76,7 @@ pub const ALL: &[Page] = &[
|
||||
messaging::PAGE,
|
||||
mail::PAGE,
|
||||
calendar::PAGE,
|
||||
project::PAGE,
|
||||
theme::PAGE,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
use ely_gpui_component::{
|
||||
project::{IssueId, Person, Priority, Status, Task},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
};
|
||||
use gpui::{
|
||||
AnyElement, App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div,
|
||||
};
|
||||
use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone};
|
||||
|
||||
mod tasks;
|
||||
|
||||
use super::Page;
|
||||
use crate::{script::Step, ui::keep};
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 31,
|
||||
slug: "project",
|
||||
title: "Project",
|
||||
summary: "Tasks and issues, who has them, where they stand, and when they are due.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
|
||||
const SCRIPT: &[Step] = &[
|
||||
Step::DownAt("project-fields", 150.0, 16.0),
|
||||
Step::UpAt("project-fields", 150.0, 16.0),
|
||||
Step::Wait(250),
|
||||
Step::Shot("status-open"),
|
||||
Step::Key("escape"),
|
||||
Step::DownAt("project-tasks", 26.0, 22.0),
|
||||
Step::UpAt("project-tasks", 26.0, 22.0),
|
||||
Step::Wait(200),
|
||||
Step::Shot("task-ticked"),
|
||||
];
|
||||
|
||||
/// What a demo says it did, under it.
|
||||
fn quiet(text: SharedString, cx: &App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(text)
|
||||
}
|
||||
|
||||
/// Today in the gallery's zone.
|
||||
fn today() -> Date {
|
||||
let zone = TimeZone::try_system().expect("the gallery reads the system time zone");
|
||||
Timestamp::now().to_zoned(zone).date()
|
||||
}
|
||||
|
||||
fn team() -> Vec<Person> {
|
||||
vec![
|
||||
Person::new("chloe", "Chloé Martin"),
|
||||
Person::new("ana", "Ana Lima"),
|
||||
Person::new("ben", "Ben Ito"),
|
||||
Person::new("dev", "Dev Rao"),
|
||||
]
|
||||
}
|
||||
|
||||
/// A studio's open work around today: late, due soon, in review, done.
|
||||
fn work(today: Date) -> Vec<Task> {
|
||||
let day = |offset: i64| today.checked_add(offset.days()).expect("a day nearby");
|
||||
let who = |key: &str| {
|
||||
team()
|
||||
.into_iter()
|
||||
.find(|person| person.key.as_ref() == key)
|
||||
.expect("one of the team")
|
||||
};
|
||||
vec![
|
||||
Task::new("brief", "Write the client brief")
|
||||
.issue(IssueId::new("ARC", 41))
|
||||
.status(Status::InProgress)
|
||||
.priority(Priority::Urgent)
|
||||
.due(day(-1))
|
||||
.assignee(who("ana")),
|
||||
Task::new("samples", "Order plaster samples")
|
||||
.issue(IssueId::new("ARC", 44))
|
||||
.priority(Priority::High)
|
||||
.due(day(0))
|
||||
.assignee(who("ben")),
|
||||
Task::new("model", "Photograph the site model")
|
||||
.issue(IssueId::new("ARC", 38))
|
||||
.status(Status::InReview)
|
||||
.priority(Priority::Medium)
|
||||
.due(day(3))
|
||||
.assignee(who("chloe")),
|
||||
Task::new("budget", "Revise the lighting budget")
|
||||
.issue(IssueId::new("ARC", 47))
|
||||
.priority(Priority::Low)
|
||||
.due(day(12)),
|
||||
Task::new("survey", "Book the land survey")
|
||||
.issue(IssueId::new("ARC", 29))
|
||||
.status(Status::Done)
|
||||
.priority(Priority::Medium)
|
||||
.due(day(-4))
|
||||
.assignee(who("dev")),
|
||||
Task::new("archive", "Archive last year's renders")
|
||||
.issue(IssueId::new("ARC", 17))
|
||||
.status(Status::Backlog),
|
||||
]
|
||||
}
|
||||
|
||||
/// The page's tasks, which ticks and picks change.
|
||||
fn board(window: &mut Window, cx: &mut App) -> Entity<Vec<Task>> {
|
||||
keep("project-work", || work(today()), window, cx)
|
||||
}
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div()
|
||||
.child(tasks::marks(cx))
|
||||
.child(tasks::fields(window, cx))
|
||||
.child(tasks::list(window, cx))
|
||||
.child(tasks::subtasks(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
use ely_gpui_component::{
|
||||
documents::Checklist,
|
||||
forms::DatePicker,
|
||||
project::{
|
||||
AssigneePicker, IssueId, IssueIdBadge, Priority, PriorityIndicator, Status, StatusSelect,
|
||||
TaskList,
|
||||
},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
};
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use super::{board, quiet, team};
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{change, keep, section, set},
|
||||
};
|
||||
|
||||
pub fn marks(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let priorities = [
|
||||
Priority::Urgent,
|
||||
Priority::High,
|
||||
Priority::Medium,
|
||||
Priority::Low,
|
||||
Priority::None,
|
||||
];
|
||||
section(
|
||||
"PriorityIndicator / IssueIdBadge",
|
||||
"How urgent a task is in three bars, urgent in red, its words in a tip or beside it. An issue goes by its team's key and its number.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"project-marks",
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_6()
|
||||
.children(priorities.map(|priority| {
|
||||
let id = SharedString::from(format!("project-priority-{priority:?}"));
|
||||
PriorityIndicator::new(id, priority).labeled()
|
||||
}))
|
||||
.child(IssueIdBadge::new(IssueId::new("ARC", 41)))
|
||||
.child(IssueIdBadge::new(IssueId::new("ENG", 1204))),
|
||||
))
|
||||
}
|
||||
|
||||
/// A label and its control, the label in a fixed column.
|
||||
fn property(label: &'static str, control: AnyElement, cx: &App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.w(px(88.))
|
||||
.flex_none()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(label),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().child(control))
|
||||
}
|
||||
|
||||
pub fn fields(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let tasks = board(window, cx);
|
||||
let task = tasks
|
||||
.read(cx)
|
||||
.iter()
|
||||
.find(|task| task.key.as_ref() == "brief")
|
||||
.cloned()
|
||||
.expect("the brief is listed");
|
||||
let edit = move |cx: &mut App, apply: &dyn Fn(&mut ely_gpui_component::project::Task)| {
|
||||
change(&tasks, cx, |tasks| {
|
||||
let brief = tasks
|
||||
.iter_mut()
|
||||
.find(|task| task.key.as_ref() == "brief")
|
||||
.expect("the brief is listed");
|
||||
apply(brief)
|
||||
})
|
||||
};
|
||||
let (moved, handed, dated) = (edit.clone(), edit.clone(), edit);
|
||||
let status = StatusSelect::new("project-status", task.status)
|
||||
.on_change(move |status, _, cx| moved(cx, &|task| task.status = status));
|
||||
let assignee = task.assignee.as_ref().map(|person| person.key.clone());
|
||||
let picker = AssigneePicker::new("project-assignee", team()).on_change(move |person, _, cx| {
|
||||
let person = person.cloned();
|
||||
handed(cx, &|task| task.assignee = person.clone())
|
||||
});
|
||||
let picker = match assignee {
|
||||
Some(key) => picker.assignee(key),
|
||||
None => picker,
|
||||
};
|
||||
let due = DatePicker::new("project-due", task.due)
|
||||
.on_change(move |day, _, cx| dated(cx, &|task| task.due = Some(day)));
|
||||
section(
|
||||
"StatusSelect / AssigneePicker / DueDatePicker",
|
||||
"A task's properties: where it stands in its workflow, who has it, and when it is due; DueDatePicker is the forms DatePicker. Each change shows in the list below.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"project-fields",
|
||||
div()
|
||||
.w(px(360.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(property("Status", status.into_any_element(), cx))
|
||||
.child(property("Assignee", picker.into_any_element(), cx))
|
||||
.child(property("Due", due.into_any_element(), cx)),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn list(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let tasks = board(window, cx);
|
||||
let listed = tasks.read(cx).clone();
|
||||
let said = keep("project-list-said", || None::<SharedString>, window, cx);
|
||||
let shown = said.read(cx).clone();
|
||||
let titles = listed.clone();
|
||||
section(
|
||||
"TaskItem / TaskList",
|
||||
"Tasks in rows: a box ticks one done, then its priority, its issue and its title; the due day and who has it sit at the end, and a day past due reads in red. Arrows move between rows; Enter or a double press opens one.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"project-tasks",
|
||||
div()
|
||||
.w(px(560.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
TaskList::new("project-tasks", listed)
|
||||
.on_toggle(move |key, done, _, cx| {
|
||||
change(&tasks, cx, |tasks| {
|
||||
let task = tasks
|
||||
.iter_mut()
|
||||
.find(|task| task.key == *key)
|
||||
.expect("a listed task");
|
||||
task.status = if done { Status::Done } else { Status::Todo };
|
||||
})
|
||||
})
|
||||
.on_open(move |key, _, cx| {
|
||||
let task = titles
|
||||
.iter()
|
||||
.find(|task| task.key == *key)
|
||||
.expect("a listed task");
|
||||
set(&said, Some(format!("Opened {}.", task.title).into()), cx)
|
||||
}),
|
||||
)
|
||||
.children(shown.map(|shown| quiet(shown, cx))),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn subtasks(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let items = keep(
|
||||
"project-subtasks",
|
||||
|| {
|
||||
vec![
|
||||
(SharedString::from("Measure the room"), true),
|
||||
(SharedString::from("Sketch two options"), true),
|
||||
(SharedString::from("Price the fixtures"), false),
|
||||
(SharedString::from("Send to Chloé"), false),
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let listed = items.read(cx).clone();
|
||||
let (toggled, added) = (items.clone(), items);
|
||||
section(
|
||||
"SubtaskList",
|
||||
"A task's steps with how many are done, as a count and a bar. It is the documents Checklist, whose field adds a step on Enter.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"project-subtasks",
|
||||
div().w(px(360.)).child(
|
||||
Checklist::new("project-subtasks", "Subtasks", listed)
|
||||
.on_toggle(move |ix, done, _, cx| change(&toggled, cx, |items| items[ix].1 = done))
|
||||
.on_add("Add a subtask", move |words, _, cx| {
|
||||
let words = words.clone();
|
||||
change(&added, cx, |items| items.push((words, false)))
|
||||
}),
|
||||
),
|
||||
))
|
||||
}
|
||||
+36
-5
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
+21
-12
@@ -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
|
||||
|
||||
Reference in new issue
Block a user