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 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 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 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
|
## Decisions
|
||||||
|
|
||||||
|
|||||||
@@ -110,8 +110,11 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
- [x] T30a Calendar · Months
|
- [x] T30a Calendar · Months
|
||||||
- [x] T30b Calendar · Time grid
|
- [x] T30b Calendar · Time grid
|
||||||
- [x] T30c Calendar · Agenda and people
|
- [x] T30c Calendar · Agenda and people
|
||||||
- [ ] T30d Calendar · Editing
|
- [x] T30d Calendar · Editing
|
||||||
- [ ] T31 Project — `tasks/ch31-43.md`
|
- [ ] T31a Project · Tasks — `tasks/ch31-43.md`
|
||||||
|
- [ ] T31b Project · Issues and boards
|
||||||
|
- [ ] T31c Project · Plans
|
||||||
|
- [ ] T31d Project · Time and databases
|
||||||
- [ ] T32 Canvas & Design
|
- [ ] T32 Canvas & Design
|
||||||
- [ ] T33 DB & Dev Tools
|
- [ ] T33 DB & Dev Tools
|
||||||
- [ ] T34 Dashboard
|
- [ ] T34 Dashboard
|
||||||
@@ -359,3 +362,4 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
| T30b | 2 | PASS | |
|
| T30b | 2 | PASS | |
|
||||||
| T30c | 1 | PASS | |
|
| T30c | 1 | PASS | |
|
||||||
| T30d | 1 | FAIL | a chosen day outlived the owner's days, and the next render panicked |
|
| 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 navigation;
|
||||||
mod overlays;
|
mod overlays;
|
||||||
mod primitives;
|
mod primitives;
|
||||||
|
mod project;
|
||||||
mod shell;
|
mod shell;
|
||||||
mod tables;
|
mod tables;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
@@ -75,6 +76,7 @@ pub const ALL: &[Page] = &[
|
|||||||
messaging::PAGE,
|
messaging::PAGE,
|
||||||
mail::PAGE,
|
mail::PAGE,
|
||||||
calendar::PAGE,
|
calendar::PAGE,
|
||||||
|
project::PAGE,
|
||||||
theme::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 std::rc::Rc;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||||
Window, div, prelude::*,
|
SharedString, Styled, Window, div, prelude::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
forms::{Checkbox, OnFlag},
|
forms::{Checkbox, Enter, Input, OnFlag, OnValue, TextInput},
|
||||||
motion::ProgressBar,
|
motion::ProgressBar,
|
||||||
theme::{ActiveTheme, TextSize},
|
theme::{ActiveTheme, TextSize},
|
||||||
typography::tabular,
|
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)]
|
#[derive(IntoElement)]
|
||||||
pub struct Checklist {
|
pub struct Checklist {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
title: SharedString,
|
title: SharedString,
|
||||||
items: Vec<(SharedString, bool)>,
|
items: Vec<(SharedString, bool)>,
|
||||||
on_toggle: Option<OnItem>,
|
on_toggle: Option<OnItem>,
|
||||||
|
adding: Option<(SharedString, OnValue)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Checklist {
|
impl Checklist {
|
||||||
@@ -82,9 +83,20 @@ impl Checklist {
|
|||||||
.map(|(label, done)| (label.into(), done))
|
.map(|(label, done)| (label.into(), done))
|
||||||
.collect(),
|
.collect(),
|
||||||
on_toggle: None,
|
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.
|
/// Tells which item changed, by index, and whether it is now done.
|
||||||
pub fn on_toggle(
|
pub fn on_toggle(
|
||||||
mut self,
|
mut self,
|
||||||
@@ -96,7 +108,25 @@ impl Checklist {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for 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 theme = cx.theme();
|
||||||
let done = self.items.iter().filter(|(_, done)| *done).count();
|
let done = self.items.iter().filter(|(_, done)| *done).count();
|
||||||
let total = self.items.len();
|
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::{
|
use crate::{
|
||||||
|
data_display::Avatar,
|
||||||
motion,
|
motion,
|
||||||
primitives::{Icon, IconName, raise},
|
primitives::{Icon, IconName, raise},
|
||||||
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
|
theme::{ActiveTheme, AvatarSize, ControlSize, Elevation, IconSize, Radius, TextSize},
|
||||||
typography::Ellipsis,
|
typography::Ellipsis,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ pub struct Choice {
|
|||||||
pub value: SharedString,
|
pub value: SharedString,
|
||||||
pub label: SharedString,
|
pub label: SharedString,
|
||||||
pub icon: Option<IconName>,
|
pub icon: Option<IconName>,
|
||||||
|
pub avatar: Option<SharedString>,
|
||||||
pub note: Option<SharedString>,
|
pub note: Option<SharedString>,
|
||||||
pub disabled: bool,
|
pub disabled: bool,
|
||||||
pub depth: usize,
|
pub depth: usize,
|
||||||
@@ -31,6 +33,7 @@ impl Choice {
|
|||||||
value: value.into(),
|
value: value.into(),
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
icon: None,
|
icon: None,
|
||||||
|
avatar: None,
|
||||||
note: None,
|
note: None,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
@@ -48,6 +51,12 @@ impl Choice {
|
|||||||
self
|
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.
|
/// Quiet text at the row's end, such as a count or a shortcut.
|
||||||
pub fn note(mut self, note: impl Into<SharedString>) -> Self {
|
pub fn note(mut self, note: impl Into<SharedString>) -> Self {
|
||||||
self.note = Some(note.into());
|
self.note = Some(note.into());
|
||||||
@@ -100,6 +109,7 @@ pub(crate) fn marked_row(
|
|||||||
checked: Option<bool>,
|
checked: Option<bool>,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> Stateful<Div> {
|
) -> Stateful<Div> {
|
||||||
|
let id = id.into();
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let colors = &theme.colors;
|
let colors = &theme.colors;
|
||||||
let fg = if choice.disabled {
|
let fg = if choice.disabled {
|
||||||
@@ -108,7 +118,7 @@ pub(crate) fn marked_row(
|
|||||||
colors.fg
|
colors.fg
|
||||||
};
|
};
|
||||||
div()
|
div()
|
||||||
.id(id)
|
.id(id.clone())
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -140,6 +150,9 @@ pub(crate) fn marked_row(
|
|||||||
.when_some(choice.icon, |row, icon| {
|
.when_some(choice.icon, |row, icon| {
|
||||||
row.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
|
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)
|
.children(mark)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
|
|||||||
+9
-1
@@ -12,8 +12,9 @@ use super::{
|
|||||||
options::{OnValue, Pick, Popup, Run, reveal, step},
|
options::{OnValue, Pick, Popup, Run, reveal, step},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
|
data_display::Avatar,
|
||||||
primitives::{Icon, IconName, tab_stop},
|
primitives::{Icon, IconName, tab_stop},
|
||||||
theme::{ActiveTheme, ControlSize, IconSize, Radius},
|
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius},
|
||||||
typography::Ellipsis,
|
typography::Ellipsis,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -330,6 +331,13 @@ impl RenderOnce for Select {
|
|||||||
trigger.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
|
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(
|
.child(field_text(
|
||||||
shown.map(|choice| choice.label),
|
shown.map(|choice| choice.label),
|
||||||
self.placeholder,
|
self.placeholder,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub mod motion;
|
|||||||
pub mod navigation;
|
pub mod navigation;
|
||||||
pub mod overlays;
|
pub mod overlays;
|
||||||
pub mod primitives;
|
pub mod primitives;
|
||||||
|
pub mod project;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod tables;
|
pub mod tables;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pub struct ListItem {
|
|||||||
disabled: bool,
|
disabled: bool,
|
||||||
strong: bool,
|
strong: bool,
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
|
struck: bool,
|
||||||
on_click: Option<OnClick>,
|
on_click: Option<OnClick>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ impl ListItem {
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
strong: false,
|
strong: false,
|
||||||
quiet: false,
|
quiet: false,
|
||||||
|
struck: false,
|
||||||
on_click: None,
|
on_click: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,6 +63,12 @@ impl ListItem {
|
|||||||
self
|
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.
|
/// A second line, quieter than the title.
|
||||||
pub fn description(mut self, text: impl Into<SharedString>) -> Self {
|
pub fn description(mut self, text: impl Into<SharedString>) -> Self {
|
||||||
self.description = Some(text.into());
|
self.description = Some(text.into());
|
||||||
@@ -162,6 +170,7 @@ impl RenderOnce for ListItem {
|
|||||||
line(self.title)
|
line(self.title)
|
||||||
.text_size(theme.text_size(TextSize::Base))
|
.text_size(theme.text_size(TextSize::Base))
|
||||||
.when(self.strong, |title| title.font_weight(FontWeight::SEMIBOLD))
|
.when(self.strong, |title| title.font_weight(FontWeight::SEMIBOLD))
|
||||||
|
.when(self.struck, |title| title.line_through())
|
||||||
.text_color(if self.quiet {
|
.text_color(if self.quiet {
|
||||||
colors.fg_muted
|
colors.fg_muted
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ icons! {
|
|||||||
ChevronsUpDown => "chevrons-up-down",
|
ChevronsUpDown => "chevrons-up-down",
|
||||||
CircleAlert => "circle-alert",
|
CircleAlert => "circle-alert",
|
||||||
CircleCheck => "circle-check",
|
CircleCheck => "circle-check",
|
||||||
|
CircleDashed => "circle-dashed",
|
||||||
CircleDot => "circle-dot",
|
CircleDot => "circle-dot",
|
||||||
|
CircleDotDashed => "circle-dot-dashed",
|
||||||
CircleHelp => "circle-help",
|
CircleHelp => "circle-help",
|
||||||
CirclePause => "circle-pause",
|
CirclePause => "circle-pause",
|
||||||
CircleStop => "circle-stop",
|
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 {
|
fn shape(text: &str, window: &mut Window) -> ShapedLine {
|
||||||
let style = window.text_style();
|
let style = window.text_style();
|
||||||
let run = TextRun {
|
let run = style.to_run(text.len());
|
||||||
len: text.len(),
|
|
||||||
font: style.font(),
|
|
||||||
color: style.color,
|
|
||||||
background_color: None,
|
|
||||||
underline: None,
|
|
||||||
strikethrough: None,
|
|
||||||
};
|
|
||||||
let size = style.font_size.to_pixels(window.rem_size());
|
let size = style.font_size.to_pixels(window.rem_size());
|
||||||
window
|
window
|
||||||
.text_system()
|
.text_system()
|
||||||
|
|||||||
+21
-12
@@ -1,25 +1,34 @@
|
|||||||
# Chapters 31–43
|
# 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
|
- [ ] KanbanBoard / KanbanColumn / KanbanCard
|
||||||
- [ ] TaskList
|
- [ ] Sprint Board → `project::KanbanBoard`
|
||||||
- [ ] TaskItem — check, priority, due date, assignee
|
|
||||||
- [ ] TaskDetailPanel
|
- [ ] TaskDetailPanel
|
||||||
- [ ] SubtaskList
|
|
||||||
|
## T31c Project · Plans
|
||||||
|
|
||||||
- [ ] GanttChart / TimelineView → `charts::GanttChart`
|
- [ ] GanttChart / TimelineView → `charts::GanttChart`
|
||||||
- [ ] Roadmap
|
- [ ] Roadmap
|
||||||
- [ ] Sprint Board → `project::KanbanBoard`
|
|
||||||
- [ ] Burndown Chart
|
- [ ] Burndown Chart
|
||||||
- [ ] PriorityIndicator
|
|
||||||
- [ ] StatusSelect
|
|
||||||
- [ ] AssigneePicker
|
|
||||||
- [ ] DueDatePicker → `forms::DatePicker`
|
|
||||||
- [ ] LabelManager
|
|
||||||
- [ ] IssueCard
|
|
||||||
- [ ] IssueIdBadge
|
|
||||||
- [ ] MilestoneProgress
|
- [ ] MilestoneProgress
|
||||||
- [ ] WorkloadView
|
- [ ] WorkloadView
|
||||||
|
|
||||||
|
## T31d Project · Time and databases
|
||||||
|
|
||||||
- [ ] TimeTracker
|
- [ ] TimeTracker
|
||||||
- [ ] Pomodoro Timer
|
- [ ] Pomodoro Timer
|
||||||
- [ ] DatabaseView (from T21c) — table, board, gallery, calendar, timeline
|
- [ ] DatabaseView (from T21c) — table, board, gallery, calendar, timeline
|
||||||
|
|||||||
Reference in new issue
Block a user