Project · Issues and boards: a board, issue cards, a task's panel, labels

KanbanBoard lays columns of cards side by side. A card drags to a
place in any column: it rides raised at the pointer, a slot opens where
it would land, the other cards glide aside on motion::Flip, and the
drop asks the owner through on_move(card, column, index); a drop in its
own place asks nothing. Option with an arrow moves the focused card,
whose focus the board keys by card so it follows it across columns. A
press, Enter or Space opens it. Sprint Board is this board.

IssueCard shows an issue's status, name, assignee, title, priority,
labels and due day. TaskDetailPanel lays a task out in full: its title
edits in place, its status, priority, assignee and due day change where
they stand, and its notes and steps follow; each edit hands the whole
task on, and a blank title is not taken. LabelManager tends a team's
labels: a hue picked from a popover, a name edited in place, a delete,
and a field that makes a new one; names stay unique in any case, and a
refused name says why.

The fill rule changes: min_w_full, taken up in T30d, resolves against
a block's outer width in taffy 0.9, so a field in a padded card ran
past its padding. A component that fills now takes no width on its
root, which blocks and flex columns stretch; FormField and the new
roots do. The chart's hue names move to theme::HUE_NAMES, which the
event editor and the label manager share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-27 05:12:20 -04:00
1 parent 9b506e5d54
commit 62382f7e68
24 files changed
+1813 -31

No files matched your search

+3 -1
View File
@@ -141,7 +141,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A row that holds text beside fixed parts lets the text give way: the text takes `flex_1().min_w_0()` and an `Ellipsis`; counts, icons, dates and chevrons take `flex_none`. A label that must stay whole wraps in that block instead, as `forms::Checkbox`'s does.
- A mark over a text field reads the field at paint time, from a canvas laid over it (`collab::RemoteCursor`): by then `TextInput::bounds_for` holds this frame's layout, and a still page never renders again to catch up.
- A callback field holds a named `type` alias; clippy's type_complexity rejects `Option<Rc<dyn Fn(..)>>`. A chapter shares one alias per shape.
- A component that fills its container says `min_w_full` on its root. As a window's root, or in a flex row, an auto width would shrink to its text's narrowest width. A percent width does not stretch in a flex box, and taffy 0.9 lays a flex box out once at unknown width while a block parent measures its content, then keeps that layout when the width comes out the same: its `w_full` children stay at content width (`forms::FormField`). Roots made before T30d still say `w_full`; T44 moves them.
- A component that fills its container takes no width on its root: a block or a flex column stretches it. Percents fill badly in taffy 0.9. `w_full` in a flex column that a block measures by content keeps its content's width: taffy lays the column out once at unknown width and keeps that layout when the width comes out the same. `min_w_full` resolves against a block's outer width, padding and all (`forms::FormField`). In a flex row the host gives it `flex_1`; as a window's root it sits in a `size_full` box. Roots made before T30d still say `w_full`; T44 takes it off.
- A field's `Highlight` sets color, wash, weight, slant and strike per span; measuring and painting share one set of runs, so wrapped heights match.
- A block editor keeps one undo for the document: text and structure together, as snapshots through `forms::History`; it takes Undo and Redo before its fields do.
- gpui calls focus listeners only while the window is active. A test that needs focus events activates its window with `window.activate_window()`.
@@ -170,6 +170,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- 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).
- Focus that must follow an item to another parent is taken by the owner and keyed by the item: a handle gpui makes for an element dies with its path (`project::KanbanBoard`, whose cards keep focus across columns).
- The chart's hues go by `theme::HUE_NAMES`, for an event's color and a label's alike.
## Decisions
+3 -2
View File
@@ -111,7 +111,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T30b Calendar · Time grid
- [x] T30c Calendar · Agenda and people
- [x] T30d Calendar · Editing
- [ ] T31a Project · Tasks — `tasks/ch31-43.md`
- [x] T31a Project · Tasks — `tasks/ch31-43.md`
- [ ] T31b Project · Issues and boards
- [ ] T31c Project · Plans
- [ ] T31d Project · Time and databases
@@ -127,7 +127,7 @@ Per-component lines live in `tasks/`. Tags there:
- [ ] T41 Maps
- [ ] T42 Misc
- [ ] T43 Library Tooling
- [ ] T44 Capture: every component, light and dark, plus motion clips; roots still on `w_full` move to `min_w_full`, checked at 280px
- [ ] T44 Capture: every component, light and dark, plus motion clips; roots still on `w_full` drop it, checked at 280px and in padded cards
- [ ] T45 Website: `frontend/`, Vite 8, pnpm, light and dark, motion
- [ ] T46 E2E: Playwright against the built site
- [ ] T47 Ship: GitHub repo (public), CI, Pages deploy
@@ -363,3 +363,4 @@ Per-component lines live in `tasks/`. Tags there:
| T30c | 1 | PASS | |
| T30d | 1 | FAIL | a chosen day outlived the owner's days, and the next render panicked |
| T30d | 2 | PASS | |
| T31a | 1 | PASS | |
+237
View File
@@ -0,0 +1,237 @@
use ely_gpui_component::{
mail::Label,
project::{
IssueCard, KanbanBoard, KanbanCard, KanbanColumn, LabelManager, Status, Task,
TaskDetailPanel,
},
theme::{ActiveTheme, Radius},
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use super::{board, quiet, studio_labels, team};
use crate::{
probe::probe,
ui::{change, keep, section, set},
};
/// The board's columns: a status each gathers, and the status a card dropped there takes.
const LANES: [(&str, &str, Status, &[Status]); 3] = [
(
"todo",
"Todo",
Status::Todo,
&[Status::Backlog, Status::Todo],
),
(
"doing",
"In progress",
Status::InProgress,
&[Status::InProgress, Status::InReview],
),
(
"done",
"Done",
Status::Done,
&[Status::Done, Status::Canceled],
),
];
pub fn kanban(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let tasks = board(window, cx);
let listed = tasks.read(cx).clone();
let opened = keep(
"project-opened",
|| Some(SharedString::from("brief")),
window,
cx,
);
let columns = LANES.map(|(key, title, _, statuses)| {
let cards = listed
.iter()
.filter(|task| statuses.contains(&task.status))
.map(|task| {
let face = IssueCard::new(
SharedString::from(format!("project-issue-{}", task.key)),
task.clone(),
);
KanbanCard::new(task.key.clone(), face)
});
KanbanColumn::new(key, title).cards(cards)
});
let moved = tasks.clone();
let board = columns
.into_iter()
.fold(KanbanBoard::new("project-board"), |board, column| {
board.column(column)
})
.on_move(move |key, column, index, _, cx| {
let (_, _, status, statuses) = LANES
.into_iter()
.find(|(lane, ..)| *lane == column.as_ref())
.expect("a lane of the board");
change(&moved, cx, |tasks| {
let at = tasks
.iter()
.position(|task| task.key == *key)
.expect("a task on the board");
let mut task = tasks.remove(at);
if !statuses.contains(&task.status) {
task.status = status;
}
let lane: Vec<usize> = (0..tasks.len())
.filter(|ix| statuses.contains(&tasks[*ix].status))
.collect();
let place = lane
.get(index)
.copied()
.unwrap_or_else(|| lane.last().map_or(tasks.len(), |last| last + 1));
tasks.insert(place, task);
})
})
.on_open(move |key, _, cx| set(&opened, Some(key.clone()), cx));
section(
"KanbanBoard / KanbanColumn / KanbanCard · IssueCard · Sprint Board",
"Columns of issue cards for a sprint. A card drags to a place in any column while the others glide aside, Option with an arrow moves the focused one, and a press or Enter opens it in the panel below. Sprint Board is this board.",
cx,
)
.child(probe("project-board", div().child(board)))
}
pub fn panel(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let tasks = board(window, cx);
let opened = keep(
"project-opened",
|| Some(SharedString::from("brief")),
window,
cx,
);
let steps = keep(
"project-steps",
|| {
vec![
(SharedString::from("Gather the client's notes"), true),
(SharedString::from("Draft the scope"), false),
(SharedString::from("Share for comments"), false),
]
},
window,
cx,
);
let shown = opened
.read(cx)
.clone()
.and_then(|key| tasks.read(cx).iter().find(|task| task.key == key).cloned());
let theme = cx.theme();
let body = match shown {
Some(task) => {
let (edited, closed, ticked, added) =
(tasks.clone(), opened.clone(), steps.clone(), steps.clone());
TaskDetailPanel::new("project-panel", task, team())
.notes("The client wants the brief before the site visit: scope, budget range and the three questions for the survey.")
.steps(steps.read(cx).clone())
.on_change(move |task: &Task, _, cx| {
let task = task.clone();
change(&edited, cx, |tasks| {
let at = tasks.iter().position(|each| each.key == task.key).expect("a listed task");
tasks[at] = task;
})
})
.on_close(move |_, cx| set(&closed, None, cx))
.on_step(move |ix, done, _, cx| change(&ticked, cx, |steps| steps[ix].1 = done))
.on_add_step(move |words, _, cx| {
let words = words.clone();
change(&added, cx, |steps| steps.push((words, false)))
})
.into_any_element()
}
None => quiet("Open a card on the board to see it here.".into(), cx).into_any_element(),
};
section(
"TaskDetailPanel",
"A task in full beside the board: its title edits in place, its properties change where they stand, and its notes and steps follow. Each edit shows on the board and in the list.",
cx,
)
.child(probe(
"project-panel",
div()
.w(px(400.))
.p_5()
.border_1()
.border_color(theme.colors.border)
.rounded(theme.radius(Radius::Lg))
.child(body),
))
}
pub fn labels(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let labels = keep("project-labels", studio_labels, window, cx);
let tasks = board(window, cx);
let listed = labels.read(cx).clone();
let (renamed, recolored, deleted, made) =
(labels.clone(), labels.clone(), labels.clone(), labels);
let (renamed_tasks, recolored_tasks, deleted_tasks) = (tasks.clone(), tasks.clone(), tasks);
let manager = LabelManager::new("project-labels", listed)
.on_rename(move |key, name, _, cx| {
let name = name.clone();
change(&renamed, cx, |labels| rename(labels.iter_mut(), key, &name));
change(&renamed_tasks, cx, |tasks| {
rename(
tasks.iter_mut().flat_map(|task| task.labels.iter_mut()),
key,
&name,
)
});
})
.on_recolor(move |key, hue, _, cx| {
change(&recolored, cx, |labels| {
recolor(labels.iter_mut(), key, hue)
});
change(&recolored_tasks, cx, |tasks| {
recolor(
tasks.iter_mut().flat_map(|task| task.labels.iter_mut()),
key,
hue,
)
});
})
.on_delete(move |key, _, cx| {
change(&deleted, cx, |labels| {
labels.retain(|label| label.key != *key)
});
change(&deleted_tasks, cx, |tasks| {
tasks
.iter_mut()
.for_each(|task| task.labels.retain(|label| label.key != *key))
});
})
.on_create(move |name, _, cx| {
let name = name.clone();
change(&made, cx, |labels| {
let key = SharedString::from(format!("label-{}", labels.len() + 1));
let hue = labels.len() % 8;
labels.push(Label::new(key, name, hue));
})
});
section(
"LabelManager",
"A team's labels: a press on a hue picks another, a name edits in place, and the field at the end makes a new one. Names stay unique whatever their case, and each change shows on the cards.",
cx,
)
.child(probe("project-label-manager", div().w(px(360.)).child(manager)))
}
fn rename<'a>(
labels: impl Iterator<Item = &'a mut Label>,
key: &SharedString,
name: &SharedString,
) {
labels
.filter(|label| label.key == *key)
.for_each(|label| label.name = name.clone());
}
fn recolor<'a>(labels: impl Iterator<Item = &'a mut Label>, key: &SharedString, hue: usize) {
labels
.filter(|label| label.key == *key)
.for_each(|label| label.hue = hue);
}
+40 -4
View File
@@ -1,4 +1,5 @@
use ely_gpui_component::{
mail::Label,
project::{IssueId, Person, Priority, Status, Task},
theme::{ActiveTheme, TextSize},
};
@@ -7,6 +8,7 @@ use gpui::{
};
use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone};
mod boards;
mod tasks;
use super::Page;
@@ -31,6 +33,14 @@ const SCRIPT: &[Step] = &[
Step::UpAt("project-tasks", 26.0, 22.0),
Step::Wait(200),
Step::Shot("task-ticked"),
Step::DownAt("project-board", 80.0, 80.0),
Step::DragTo("project-board", 200.0, 90.0),
Step::DragTo("project-board", 360.0, 110.0),
Step::Wait(250),
Step::Shot("board-drag"),
Step::UpAt("project-board", 360.0, 110.0),
Step::Wait(300),
Step::Shot("board-dropped"),
];
/// What a demo says it did, under it.
@@ -57,6 +67,24 @@ fn team() -> Vec<Person> {
]
}
/// The studio's labels.
fn studio_labels() -> Vec<Label> {
vec![
Label::new("client", "Client", 0),
Label::new("site", "Site", 5),
Label::new("render", "Render", 4),
Label::new("budget", "Budget", 2),
]
}
/// One of the studio's labels, by key.
fn tag(key: &str) -> Label {
studio_labels()
.into_iter()
.find(|label| label.key.as_ref() == key)
.expect("one of the studio's labels")
}
/// 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");
@@ -72,18 +100,22 @@ fn work(today: Date) -> Vec<Task> {
.status(Status::InProgress)
.priority(Priority::Urgent)
.due(day(-1))
.assignee(who("ana")),
.assignee(who("ana"))
.label(tag("client")),
Task::new("samples", "Order plaster samples")
.issue(IssueId::new("ARC", 44))
.priority(Priority::High)
.due(day(0))
.assignee(who("ben")),
.assignee(who("ben"))
.label(tag("budget")),
Task::new("model", "Photograph the site model")
.issue(IssueId::new("ARC", 38))
.status(Status::InReview)
.priority(Priority::Medium)
.due(day(3))
.assignee(who("chloe")),
.assignee(who("chloe"))
.label(tag("site"))
.label(tag("render")),
Task::new("budget", "Revise the lighting budget")
.issue(IssueId::new("ARC", 47))
.priority(Priority::Low)
@@ -93,7 +125,8 @@ fn work(today: Date) -> Vec<Task> {
.status(Status::Done)
.priority(Priority::Medium)
.due(day(-4))
.assignee(who("dev")),
.assignee(who("dev"))
.label(tag("site")),
Task::new("archive", "Archive last year's renders")
.issue(IssueId::new("ARC", 17))
.status(Status::Backlog),
@@ -111,5 +144,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(tasks::fields(window, cx))
.child(tasks::list(window, cx))
.child(tasks::subtasks(window, cx))
.child(boards::kanban(window, cx))
.child(boards::panel(window, cx))
.child(boards::labels(window, cx))
.into_any_element()
}
-1
View File
@@ -227,7 +227,6 @@ impl RenderOnce for AvailabilityPicker {
});
div()
.debug_selector(|| "availability".into())
.min_w_full()
.flex()
.flex_col()
.gap_3()
+5 -8
View File
@@ -18,17 +18,12 @@ use crate::{
ColorPalette, DatePicker, DateTimePicker, FormError, FormField, Input, InputEvent, Run,
Switch, TextInput,
},
theme::ActiveTheme,
theme::{ActiveTheme, HUE_NAMES},
typography::format,
};
type OnDraft = Rc<dyn Fn(&EventDraft, &mut Window, &mut App)>;
/// The chart's hues by name, for the color an event wears.
const HUES: [&str; 8] = [
"Blue", "Teal", "Ochre", "Rose", "Violet", "Green", "Rust", "Cyan",
];
/// An event's parts as an editor hands them back.
#[derive(Clone, Debug, PartialEq)]
pub struct EventDraft {
@@ -362,7 +357,6 @@ impl RenderOnce for EventEditor {
};
div()
.debug_selector(|| "event-editor".into())
.min_w_full()
.flex()
.flex_col()
.gap_4()
@@ -402,7 +396,10 @@ impl RenderOnce for EventEditor {
FormField::new((self.id.clone(), "color-field"), "Color").child(
ColorPalette::new(
(self.id.clone(), "color"),
HUES.iter().zip(chart).map(|(name, color)| (*name, color)),
HUE_NAMES
.iter()
.zip(chart)
.map(|(name, color)| (*name, color)),
)
.selected(chart[draft.hue])
.on_change({
+1 -2
View File
@@ -116,7 +116,7 @@ impl RenderOnce for RecurrenceEditor {
.selected(frequency_key(rule.as_ref().map(|rule| rule.frequency)))
.on_change(pick);
let Some(rule) = rule else {
return div().min_w_full().child(frequency);
return div().child(frequency);
};
let unit = match (rule.frequency, rule.interval) {
(Frequency::Daily, 1) => "day",
@@ -259,7 +259,6 @@ impl RenderOnce for RecurrenceEditor {
};
div()
.debug_selector(|| "recurrence".into())
.min_w_full()
.flex()
.flex_col()
.gap_3()
-1
View File
@@ -139,7 +139,6 @@ impl RenderOnce for ReminderPicker {
};
div()
.debug_selector(|| "reminders".into())
.min_w_full()
.flex()
.flex_col()
.gap_2()
-1
View File
@@ -275,7 +275,6 @@ impl RenderOnce for FormField {
.flex()
.flex_col()
.gap_1p5()
.min_w_full()
.child(self.label)
.children(self.children)
.children(note)
+28
View File
@@ -167,3 +167,31 @@ fn a_field_fills_a_column_its_block_measures_by_content(cx: &mut TestAppContext)
"the field spans the card inside its padding"
);
}
/// A field in a card of a set width, inside its padding.
struct Padded;
impl Render for Padded {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let control = div().debug_selector(|| "padded-control".into()).h_2();
div()
.w(px(400.))
.p_5()
.child(FormField::new("padded", "Name").child(control))
}
}
#[gpui::test]
fn a_field_keeps_inside_its_cards_padding(cx: &mut TestAppContext) {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Padded);
cx.run_until_parked();
let control = cx
.debug_bounds("padded-control")
.expect("the control draws");
assert_eq!(
control.size.width,
px(360.),
"a percent min would take the card's outer width"
);
}
+457
View File
@@ -0,0 +1,457 @@
use std::{collections::HashMap, rc::Rc};
use gpui::{
AnyElement, App, Bounds, ElementId, EmptyView, EntityId, FocusHandle, InteractiveElement,
IntoElement, ParentElement, Pixels, Point, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Window, anchored, canvas, div, prelude::*,
};
use super::moves::{landing, stepped};
use crate::{
layout::on_axis,
motion::Flip,
primitives::{FocusRing, raise, tab_stop},
theme::{ActiveTheme, Elevation, Radius, TextSize},
typography::tabular,
};
type OnMove = Rc<dyn Fn(&SharedString, &SharedString, usize, &mut Window, &mut App)>;
type OnCard = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A card on a board: its key and what it shows.
pub struct KanbanCard {
key: SharedString,
content: AnyElement,
}
impl KanbanCard {
pub fn new(key: impl Into<SharedString>, content: impl IntoElement) -> Self {
Self {
key: key.into(),
content: content.into_any_element(),
}
}
}
/// A board's column: its key, its title, and its cards in order.
pub struct KanbanColumn {
key: SharedString,
title: SharedString,
cards: Vec<KanbanCard>,
}
impl KanbanColumn {
pub fn new(key: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
Self {
key: key.into(),
title: title.into(),
cards: Vec::new(),
}
}
pub fn card(mut self, card: KanbanCard) -> Self {
self.cards.push(card);
self
}
pub fn cards(mut self, cards: impl IntoIterator<Item = KanbanCard>) -> Self {
self.cards.extend(cards);
self
}
}
/// A card on its way, by key, and whose board it belongs to.
struct CardDrag {
owner: EntityId,
key: SharedString,
}
/// The card held: its key, where the pointer took it, the pointer now, and where it would land.
#[derive(Clone)]
struct Hold {
key: SharedString,
grab: Point<Pixels>,
pointer: Point<Pixels>,
column: SharedString,
index: usize,
}
/// Last frame's measures: each card's row height and box, each column's body.
#[derive(Default)]
struct Measures {
rows: HashMap<SharedString, Pixels>,
cards: HashMap<SharedString, Bounds<Pixels>>,
bodies: HashMap<SharedString, Bounds<Pixels>>,
}
/// Measures the box it lies over into `measures` when it changes.
fn measure(
measures: &gpui::Entity<Measures>,
save: impl Fn(&mut Measures, Bounds<Pixels>) -> bool + 'static,
) -> impl IntoElement {
let measures = measures.clone();
canvas(
move |bounds, window, cx| {
let changed = measures.update(cx, |measures, _| save(measures, bounds));
if changed {
measures.update(cx, |_, cx| cx.notify());
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full()
}
/// Columns of cards: a card drags to a place in any column, the others gliding aside, and Option with an arrow moves the focused one. Each move asks the owner through `on_move(card, column, index)`, the index counted without the card.
#[derive(IntoElement)]
pub struct KanbanBoard {
id: ElementId,
columns: Vec<KanbanColumn>,
on_move: Option<OnMove>,
on_open: Option<OnCard>,
}
impl KanbanBoard {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
columns: Vec::new(),
on_move: None,
on_open: None,
}
}
pub fn column(mut self, column: KanbanColumn) -> Self {
self.columns.push(column);
self
}
/// Gets a card's key, the column it goes to, and its place there.
pub fn on_move(
mut self,
handler: impl Fn(&SharedString, &SharedString, usize, &mut Window, &mut App) + 'static,
) -> Self {
self.on_move = Some(Rc::new(handler));
self
}
/// A press, Enter or Space on a card; gets its key.
pub fn on_open(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
impl RenderOnce for KanbanBoard {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id.clone();
let mut places: HashMap<SharedString, (usize, usize)> = HashMap::new();
for (column_ix, column) in self.columns.iter().enumerate() {
let twice = self.columns[..column_ix]
.iter()
.any(|other| other.key == column.key);
assert!(!twice, "board {id:?}: column {} twice", column.key);
for (ix, card) in column.cards.iter().enumerate() {
let known = places.insert(card.key.clone(), (column_ix, ix));
assert!(known.is_none(), "board {id:?}: card {} twice", card.key);
}
}
let hold = window.use_keyed_state((id.clone(), "hold"), cx, |_, _| None::<Hold>);
let measures =
window.use_keyed_state((id.clone(), "measures"), cx, |_, _| Measures::default());
let owner = hold.entity_id();
let stale = hold
.read(cx)
.as_ref()
.is_some_and(|held| !cx.has_active_drag() || !places.contains_key(&held.key));
if stale {
log::debug!("board {id:?}: the held card let go");
hold.update(cx, |hold, _| *hold = None);
}
let held = hold.read(cx).clone();
let focus: HashMap<SharedString, FocusHandle> = places
.keys()
.map(|key| {
let stop = tab_stop((id.clone(), format!("card-{key}")).into(), true, window, cx);
(key.clone(), stop)
})
.collect();
let keys: Vec<SharedString> = self
.columns
.iter()
.map(|column| column.key.clone())
.collect();
let orders: HashMap<SharedString, Vec<SharedString>> = self
.columns
.iter()
.map(|column| {
let cards = column.cards.iter().map(|card| card.key.clone()).collect();
(column.key.clone(), cards)
})
.collect();
let origins: HashMap<SharedString, (SharedString, usize)> = places
.iter()
.map(|(key, (column, ix))| (key.clone(), (keys[*column].clone(), *ix)))
.collect();
let lens: Rc<Vec<usize>> = Rc::new(
self.columns
.iter()
.map(|column| column.cards.len())
.collect(),
);
let theme = cx.theme();
let colors = &theme.colors;
let width = theme.project().column;
let mut lifted: Option<AnyElement> = None;
let mut columns = Vec::new();
for (column_ix, column) in self.columns.into_iter().enumerate() {
let mut flip = Flip::new((id.clone(), format!("column-{}", column.key)));
let count = column.cards.len();
let mut shown = 0;
let slot = held
.as_ref()
.filter(|held| held.column == column.key)
.map(|held| held.index);
let placeholder = |measures: &gpui::Entity<Measures>, key: &SharedString, cx: &App| {
let card = measures.read(cx).cards[key];
div().pb_2().child(
div()
.h(card.size.height)
.rounded(cx.theme().radius(Radius::Md))
.bg(cx.theme().colors.hover),
)
};
for (ix, card) in column.cards.into_iter().enumerate() {
let key = card.key.clone();
if held.as_ref().is_some_and(|held| held.key == key) {
lifted = Some(card.content);
continue;
}
if slot == Some(shown) {
let held_key = &held.as_ref().expect("a slot holds a card").key;
flip = flip.row("+slot", placeholder(&measures, held_key, cx));
}
shown += 1;
let handle = focus[&key].clone();
let (drag, moves, opens) =
(hold.clone(), self.on_move.clone(), self.on_open.clone());
let (column_key, row_key, card_key, open_key) =
(column.key.clone(), key.clone(), key.clone(), key.clone());
let (lens, keys, from) = (lens.clone(), keys.clone(), (column_ix, ix));
let (row_measures, card_measures, grab_measures) =
(measures.clone(), measures.clone(), measures.clone());
let face = div()
.id((id.clone(), format!("card-{key}")))
.debug_selector(move || format!("card {card_key}"))
.relative()
.track_focus(&handle)
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(gpui::transparent_black())
.focus_ring(cx)
.cursor_grab()
.on_click(move |_, window, cx| {
log::info!("board: open {open_key}");
if let Some(opens) = &opens {
opens(&open_key, window, cx);
}
})
.on_key_down(move |event, window, cx| {
let modifiers = event.keystroke.modifiers;
if !modifiers.alt || modifiers.platform || modifiers.control {
return;
}
let Some((column, index)) =
stepped(&lens, from.0, from.1, event.keystroke.key.as_str())
else {
return;
};
cx.stop_propagation();
log::info!("board: {row_key} to {} at {index}", keys[column]);
if let Some(moves) = &moves {
moves(&row_key, &keys[column], index, window, cx);
}
})
.on_drag(
CardDrag {
owner,
key: key.clone(),
},
move |drag_card, offset, _, cx| {
let origin = grab_measures.read(cx).cards[&drag_card.key].origin;
log::info!("board: {} lifted", drag_card.key);
drag.update(cx, |hold, cx| {
*hold = Some(Hold {
key: drag_card.key.clone(),
grab: offset,
pointer: origin + offset,
column: column_key.clone(),
index: from.1,
});
cx.notify();
});
cx.new(|_| EmptyView)
},
)
.child(card.content)
.child(measure(&card_measures, {
let key = key.clone();
move |measures, bounds| {
measures.cards.insert(key.clone(), bounds) != Some(bounds)
}
}));
let row = div()
.relative()
.pb_2()
.child(face)
.child(measure(&row_measures, {
let key = key.clone();
move |measures, bounds| {
measures.rows.insert(key.clone(), bounds.size.height)
!= Some(bounds.size.height)
}
}));
flip = flip.row(key, row);
}
if slot.is_some_and(|slot| slot >= shown) {
let held_key = &held.as_ref().expect("a slot holds a card").key;
flip = flip.row("+slot", placeholder(&measures, held_key, cx));
}
let body_key = column.key.clone();
columns.push(
div()
.debug_selector({
let key = column.key.clone();
move || format!("column {key}")
})
.w(width)
.flex_none()
.flex()
.flex_col()
.gap_2()
.p_2()
.rounded(theme.radius(Radius::Lg))
.bg(colors.sunken)
.child(
div()
.flex()
.items_center()
.justify_between()
.gap_2()
.px_1()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(gpui::FontWeight::MEDIUM)
.child(column.title),
)
.child(
tabular(div())
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(count.to_string()),
),
)
.child(div().relative().min_h_16().child(flip).child(measure(
&measures,
move |measures, bounds| {
measures.bodies.insert(body_key.clone(), bounds) != Some(bounds)
},
))),
);
}
let raised = held.as_ref().zip(lifted).map(|(held, content)| {
raise(
(id.clone(), "held"),
anchored().position(held.pointer - held.grab).child(
div()
.w(width)
.rounded(theme.radius(Radius::Md))
.shadow(theme.elevation(Elevation::Floating))
.child(content),
),
)
});
let (moves, drops, move_measures) = (hold.clone(), hold, measures);
let on_move = self.on_move;
on_axis(
div()
.id(id.clone())
.debug_selector(|| "kanban-board".into())
.overflow_x_scroll(),
)
.flex()
.items_start()
.gap_3()
.on_drag_move::<CardDrag>(move |event, _, cx| {
if event.drag(cx).owner != owner {
return;
}
let pointer = event.event.position;
let measures = move_measures.read(cx);
let Some(held) = moves.read(cx).clone() else {
return;
};
let column = measures
.bodies
.iter()
.min_by(|(_, a), (_, b)| {
let distance = |bounds: &Bounds<Pixels>| (bounds.center().x - pointer.x).abs();
distance(a)
.partial_cmp(&distance(b))
.expect("distances are numbers")
})
.map(|(key, body)| (key.clone(), *body));
let Some((column, body)) = column else { return };
let card = measures.cards[&held.key];
let heights: Vec<Pixels> = orders[&column]
.iter()
.filter(|key| **key != held.key)
.map(|key| measures.rows[key])
.collect();
let middle = pointer.y - held.grab.y + card.size.height / 2.0;
let index = landing(&heights, body.origin.y, middle);
moves.update(cx, |hold, cx| {
if let Some(hold) = hold {
hold.pointer = pointer;
hold.column = column;
hold.index = index;
}
cx.notify();
});
})
.on_drop(move |drag: &CardDrag, window, cx| {
if drag.owner != owner {
return;
}
let Some(held) = drops.update(cx, |hold, cx| {
cx.notify();
hold.take()
}) else {
return;
};
if origins[&held.key] == (held.column.clone(), held.index) {
log::info!("board: {} dropped where it was", held.key);
return;
}
log::info!(
"board: {} dropped in {} at {}",
held.key,
held.column,
held.index
);
if let Some(on_move) = &on_move {
on_move(&held.key, &held.column, held.index, window, cx);
}
})
.children(columns)
.children(raised)
}
}
+152
View File
@@ -0,0 +1,152 @@
use std::rc::Rc;
use gpui::{
App, Div, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use jiff::civil::Date;
use super::{
marks::{IssueIdBadge, PriorityIndicator},
tasks::clock_today,
work::{Task, due_words},
};
use crate::{
data_display::{Avatar, color_mark},
forms::Run,
mail::Label,
primitives::{FocusRing, Icon},
theme::{ActiveTheme, AvatarSize, IconSize, Radius, TextSize},
typography::fresh,
};
/// A label worn on a task: its hue and its name, quiet.
pub(super) fn label_chip(label: &Label, cx: &App) -> Div {
let theme = cx.theme();
let hue = theme
.colors
.hue(label.hue, format_args!("label {}", label.name));
div()
.flex_none()
.flex()
.items_center()
.gap_1()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.child(color_mark(hue, cx))
.child(label.name.clone())
}
/// An issue on a card: its status, name and who has it, its title, then its priority, labels and due day, red when past and still open. With `on_open`, a press, Enter or Space opens it.
#[derive(IntoElement)]
pub struct IssueCard {
id: ElementId,
task: Task,
today: Option<Date>,
on_open: Option<Run>,
}
impl IssueCard {
pub fn new(id: impl Into<ElementId>, task: Task) -> Self {
Self {
id: id.into(),
task,
today: None,
on_open: None,
}
}
/// The day due dates count from; the clock's otherwise.
pub fn today(mut self, day: Date) -> Self {
self.today = Some(day);
self
}
pub fn on_open(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
impl RenderOnce for IssueCard {
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("issue card")
}
};
let theme = cx.theme();
let colors = &theme.colors;
let task = self.task;
let key = task.key.clone();
let late = task.overdue(today);
let due = task.due.map(|due| {
div()
.flex_none()
.text_size(theme.text_size(TextSize::Xs))
.text_color(if late { colors.danger } else { colors.fg_muted })
.child(due_words(due, today))
});
let labels = task.labels.iter().map(|label| label_chip(label, cx));
let top = div()
.flex()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
Icon::new(task.status.icon())
.size(IconSize::Sm)
.color(colors.fg_muted),
)
.children(task.issue.clone().map(IssueIdBadge::new)),
)
.children(task.assignee.as_ref().map(|person| {
Avatar::new((self.id.clone(), "assignee"), person.name.clone()).size(AvatarSize::Xs)
}));
let foot = div()
.flex()
.flex_wrap()
.items_center()
.gap_x_3()
.gap_y_1()
.child(PriorityIndicator::new(
(self.id.clone(), "priority"),
task.priority,
))
.children(labels)
.children(due);
div()
.id(self.id)
.debug_selector(move || format!("issue {key}"))
.flex()
.flex_col()
.gap_2()
.p_3()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.child(top)
.child(
div()
.text_size(theme.text_size(TextSize::Base))
.text_color(colors.fg)
.child(task.title),
)
.child(foot)
.when_some(self.on_open, |card, open| {
card.tab_index(0)
.focus_ring(cx)
.cursor_pointer()
.hover(|style| style.border_color(colors.border_strong))
.on_click(move |_, window, cx| open(window, cx))
})
}
}
+303
View File
@@ -0,0 +1,303 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Window, div,
};
use crate::{
buttons::{ButtonVariant, IconButton},
data_display::color_mark,
forms::{ColorPalette, Enter, FormError, InlineEdit, Input, OnValue, TextInput},
mail::Label,
overlays::Popover,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, HUE_NAMES, IconSize, Radius},
};
type OnRename = Rc<dyn Fn(&SharedString, &SharedString, &mut Window, &mut App)>;
type OnRecolor = Rc<dyn Fn(&SharedString, usize, &mut Window, &mut App)>;
/// Why `name` cannot name a label, if it cannot: blank, or another label's already, whatever the case.
pub(crate) fn refusal(
name: &str,
labels: &[Label],
except: Option<&SharedString>,
) -> Option<&'static str> {
let name = name.trim().to_lowercase();
if name.is_empty() {
return Some("A label needs a name.");
}
let taken = labels
.iter()
.any(|label| Some(&label.key) != except && label.name.to_lowercase() == name);
taken.then_some("That name is taken.")
}
/// A team's labels to tend: each wears its hue, which a press on it changes, and its name, which edits in place; a label can go, and a field at the end makes a new one. Names stay unique, whatever their case.
#[derive(IntoElement)]
pub struct LabelManager {
id: ElementId,
labels: Vec<Label>,
on_rename: Option<OnRename>,
on_recolor: Option<OnRecolor>,
on_delete: Option<OnValue>,
on_create: Option<OnValue>,
}
impl LabelManager {
pub fn new(id: impl Into<ElementId>, labels: impl IntoIterator<Item = Label>) -> Self {
let labels: Vec<Label> = labels.into_iter().collect();
for (ix, label) in labels.iter().enumerate() {
let before = &labels[..ix];
assert!(
!before.iter().any(|other| other.key == label.key),
"label {} twice",
label.key
);
assert!(
refusal(&label.name, before, None).is_none(),
"label name {} blank or twice",
label.name
);
}
Self {
id: id.into(),
labels,
on_rename: None,
on_recolor: None,
on_delete: None,
on_create: None,
}
}
/// Gets a label's key and its new name, trimmed.
pub fn on_rename(
mut self,
handler: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_rename = Some(Rc::new(handler));
self
}
/// Gets a label's key and its new hue among the chart colors.
pub fn on_recolor(
mut self,
handler: impl Fn(&SharedString, usize, &mut Window, &mut App) + 'static,
) -> Self {
self.on_recolor = Some(Rc::new(handler));
self
}
/// Gets the key of the label to delete.
pub fn on_delete(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_delete = Some(Rc::new(handler));
self
}
/// Gets a new label's name, trimmed; the owner picks its key and hue.
pub fn on_create(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_create = Some(Rc::new(handler));
self
}
}
impl RenderOnce for LabelManager {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id.clone();
let refused = window.use_keyed_state((id.clone(), "refused"), cx, |_, _| {
None::<(SharedString, &'static str)>
});
let field = window.use_keyed_state((id.clone(), "new"), cx, |window, cx| {
TextInput::new(window, cx).placeholder("New label")
});
let refuse = {
let refused = refused.clone();
move |at: Option<(SharedString, &'static str)>, cx: &mut App| {
refused.update(cx, |refused, cx| {
*refused = at;
cx.notify();
})
}
};
let shown = refused.read(cx).clone();
let labels = Rc::new(self.labels);
let theme = cx.theme();
let chart = theme.colors.chart;
let error = |at: &str| {
shown
.as_ref()
.filter(|(key, _)| key.as_ref() == at)
.map(|(key, why)| {
let named = key.clone();
div()
.debug_selector(move || format!("refused {named}"))
.child(FormError::new((id.clone(), format!("refused-{key}")), *why))
})
};
let rows = labels.iter().map(|label| {
let key = label.key.clone();
let hue = theme
.colors
.hue(label.hue, format_args!("label {}", label.name));
let swatch_id = (id.clone(), format!("hue-{key}"));
let palette = {
let (id, key, on_recolor) = (id.clone(), key.clone(), self.on_recolor.clone());
move |_: &mut Window, _: &mut App| {
let colors = HUE_NAMES
.iter()
.zip(chart)
.map(|(name, color)| (*name, color));
ColorPalette::new((id.clone(), format!("palette-{key}")), colors)
.columns(4)
.selected(hue)
.on_change(move |color, window, cx| {
let hue = chart
.iter()
.position(|each| *each == color)
.expect("a hue of the chart");
log::info!("label manager: {key} to hue {hue}");
if let Some(on_recolor) = &on_recolor {
on_recolor(&key, hue, window, cx);
}
})
}
};
let (mark, radius) = (color_mark(hue, cx), theme.radius(Radius::Sm));
let (ring, lit) = (theme.colors.focus, theme.colors.hover);
let swatch = Popover::with_opener(
(id.clone(), format!("recolor-{key}")),
move |toggle| {
div()
.id(swatch_id)
.flex_none()
.p_1()
.rounded(radius)
.border_1()
.border_color(gpui::transparent_black())
.tab_index(0)
.focus(move |style| style.border_color(ring))
.cursor_pointer()
.hover(move |style| style.bg(lit))
.on_click(move |_, window, cx| toggle(window, cx))
.child(mark)
},
palette,
);
let name = {
let (key, labels, refuse) = (key.clone(), labels.clone(), refuse.clone());
let on_rename = self.on_rename.clone();
InlineEdit::new((id.clone(), format!("name-{key}")), label.name.clone()).on_commit(
move |name, window, cx| match refusal(name, &labels, Some(&key)) {
Some(why) => {
log::info!("label manager: {key} not renamed: {why}");
refuse(Some((key.clone(), why)), cx);
}
None => {
refuse(None, cx);
let name = SharedString::from(name.trim().to_string());
log::info!("label manager: {key} renamed {name}");
if let Some(on_rename) = &on_rename {
on_rename(&key, &name, window, cx);
}
}
},
)
};
let delete = {
let (key, on_delete) = (key.clone(), self.on_delete.clone());
IconButton::new((id.clone(), format!("delete-{key}")), IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Delete label")
.on_click(move |_, window, cx| {
log::info!("label manager: delete {key}");
if let Some(on_delete) = &on_delete {
on_delete(&key, window, cx);
}
})
};
div()
.debug_selector(move || format!("label-row {key}"))
.flex()
.flex_col()
.gap_1()
.child(
div()
.flex()
.items_center()
.gap_2()
.child(swatch)
.child(div().flex_1().min_w_0().child(name))
.child(delete),
)
.children(error(label.key.as_ref()))
});
let rows: Vec<_> = rows.collect();
let create = {
let (labels, field, on_create) = (labels.clone(), field.clone(), self.on_create);
move |_: &Enter, window: &mut Window, cx: &mut App| {
let words = field.read(cx).text().to_string();
cx.stop_propagation();
if let Some(why) = refusal(&words, &labels, None) {
log::info!("label manager: not made: {why}");
refuse(Some(("+new".into(), why)), cx);
return;
}
refuse(None, cx);
field.update(cx, |input, cx| input.set_text(String::new(), cx));
let name = SharedString::from(words.trim().to_string());
log::info!("label manager: make {name}");
if let Some(on_create) = &on_create {
on_create(&name, window, cx);
}
}
};
let plus = Icon::new(IconName::Plus)
.size(IconSize::Sm)
.color(theme.colors.fg_subtle);
div()
.debug_selector(|| "label-manager".into())
.flex()
.flex_col()
.gap_2()
.children(rows)
.child(
div()
.flex()
.flex_col()
.gap_1()
.capture_action(create)
.child(Input::new(&field).prefix(plus))
.children(error("+new")),
)
}
}
#[cfg(test)]
mod tests {
use super::refusal;
use crate::mail::Label;
#[test]
fn a_name_is_refused_when_blank_or_taken_in_any_case() {
let labels = [Label::new("bug", "Bug", 3), Label::new("site", "Site", 1)];
assert_eq!(refusal(" ", &labels, None), Some("A label needs a name."));
assert_eq!(
refusal(" SITE ", &labels, None),
Some("That name is taken.")
);
assert_eq!(
refusal("bug", &labels, Some(&"bug".into())),
None,
"its own name"
);
assert_eq!(refusal("Client", &labels, None), None);
}
}
+9
View File
@@ -1,10 +1,19 @@
mod board;
mod card;
mod labels;
mod marks;
mod moves;
mod panel;
mod tasks;
mod work;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use board::{KanbanBoard, KanbanCard, KanbanColumn};
pub use card::IssueCard;
pub use labels::LabelManager;
pub use marks::{AssigneePicker, IssueIdBadge, PriorityIndicator, StatusSelect};
pub use panel::TaskDetailPanel;
pub use tasks::{TaskItem, TaskList};
pub use work::{IssueId, Person, Priority, Status, Task};
+58
View File
@@ -0,0 +1,58 @@
use gpui::Pixels;
/// Where a card whose middle sits at `middle` lands among rows of `heights` from a column's `top`: past every row whose middle is above it.
pub(crate) fn landing(heights: &[Pixels], top: Pixels, middle: Pixels) -> usize {
let mut y = top;
let mut index = 0;
for height in heights {
if y + *height / 2.0 < middle {
index += 1;
}
y += *height;
}
index
}
/// Where Option and an arrow take the card at `index` of `column`, among columns of `lens` cards: up and down within it, left and right to the same place or the end.
pub(crate) fn stepped(
lens: &[usize],
column: usize,
index: usize,
key: &str,
) -> Option<(usize, usize)> {
match key {
"up" => (index > 0).then(|| (column, index - 1)),
"down" => (index + 1 < lens[column]).then(|| (column, index + 1)),
"left" => (column > 0).then(|| (column - 1, index.min(lens[column - 1]))),
"right" => (column + 1 < lens.len()).then(|| (column + 1, index.min(lens[column + 1]))),
_ => None,
}
}
#[cfg(test)]
mod tests {
use gpui::px;
use super::{landing, stepped};
#[test]
fn a_card_lands_past_the_rows_whose_middles_it_passed() {
let rows = [px(40.0), px(60.0), px(40.0)];
assert_eq!(landing(&rows, px(100.0), px(110.0)), 0);
assert_eq!(landing(&rows, px(100.0), px(125.0)), 1);
assert_eq!(landing(&rows, px(100.0), px(175.0)), 2);
assert_eq!(landing(&rows, px(100.0), px(400.0)), 3);
}
#[test]
fn option_arrows_step_within_and_across_columns() {
let lens = [3, 0, 2];
assert_eq!(stepped(&lens, 0, 0, "up"), None);
assert_eq!(stepped(&lens, 0, 1, "down"), Some((0, 2)));
assert_eq!(stepped(&lens, 0, 2, "down"), None);
assert_eq!(stepped(&lens, 0, 2, "right"), Some((1, 0)));
assert_eq!(stepped(&lens, 2, 1, "left"), Some((1, 0)));
assert_eq!(stepped(&lens, 2, 1, "right"), None);
assert_eq!(stepped(&lens, 1, 0, "home"), None);
}
}
+279
View File
@@ -0,0 +1,279 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div, prelude::*,
};
use super::{
card::label_chip,
marks::{AssigneePicker, IssueIdBadge, StatusSelect},
work::{Person, Priority, Task},
};
use crate::{
buttons::{ButtonVariant, IconButton},
documents::Checklist,
forms::{Choice, DatePicker, InlineEdit, OnValue, Run, Select},
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
};
type OnTask = Rc<dyn Fn(&Task, &mut Window, &mut App)>;
type OnStep = Rc<dyn Fn(usize, bool, &mut Window, &mut App)>;
type Edit = Rc<dyn Fn(&dyn Fn(&mut Task), &mut Window, &mut App)>;
/// A task laid out in full: its status and issue with a way to close, its title to edit in place, its status, priority, assignee, due day and labels, its notes, and its steps. Each edit hands the whole task on.
#[derive(IntoElement)]
pub struct TaskDetailPanel {
id: ElementId,
task: Task,
people: Vec<Person>,
notes: Option<SharedString>,
steps: Vec<(SharedString, bool)>,
on_change: Option<OnTask>,
on_close: Option<Run>,
on_step: Option<OnStep>,
on_add_step: Option<OnValue>,
}
impl TaskDetailPanel {
/// `people` are those it can go to.
pub fn new(
id: impl Into<ElementId>,
task: Task,
people: impl IntoIterator<Item = Person>,
) -> Self {
Self {
id: id.into(),
task,
people: people.into_iter().collect(),
notes: None,
steps: Vec::new(),
on_change: None,
on_close: None,
on_step: None,
on_add_step: None,
}
}
pub fn notes(mut self, notes: impl Into<SharedString>) -> Self {
self.notes = Some(notes.into());
self
}
/// Its steps, each done or not.
pub fn steps(
mut self,
steps: impl IntoIterator<Item = (impl Into<SharedString>, bool)>,
) -> Self {
self.steps = steps
.into_iter()
.map(|(step, done)| (step.into(), done))
.collect();
self
}
/// Gets the task after each edit.
pub fn on_change(mut self, handler: impl Fn(&Task, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_close = Some(Rc::new(handler));
self
}
/// Gets a step's place and whether it is now done.
pub fn on_step(
mut self,
handler: impl Fn(usize, bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_step = Some(Rc::new(handler));
self
}
/// A field under the steps; gets a new step's words.
pub fn on_add_step(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_add_step = Some(Rc::new(handler));
self
}
}
/// A property's name over a fixed column and its control, which drops below when narrow.
fn property(name: &'static str, control: AnyElement, cx: &App) -> impl IntoElement {
let theme = cx.theme();
div()
.flex()
.flex_wrap()
.items_center()
.gap_x_3()
.gap_y_1()
.child(
div()
.w(theme.label_width())
.flex_none()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(name),
)
.child(div().flex_1().min_w(theme.label_width()).child(control))
}
impl RenderOnce for TaskDetailPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id.clone();
let task = self.task;
let edit: Edit = {
let (task, on_change, id) = (task.clone(), self.on_change, id.clone());
Rc::new(move |apply, window, cx| {
let mut next = task.clone();
apply(&mut next);
log::info!("task panel {id:?}: {} changed", next.key);
if let Some(on_change) = &on_change {
on_change(&next, window, cx);
}
})
};
let theme = cx.theme();
let colors = &theme.colors;
let head = div()
.flex()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
Icon::new(task.status.icon())
.size(IconSize::Sm)
.color(colors.fg_muted),
)
.children(task.issue.clone().map(IssueIdBadge::new)),
)
.children(self.on_close.map(|close| {
IconButton::new((id.clone(), "close"), IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Close")
.on_click(move |_, window, cx| close(window, cx))
}));
let title = {
let edit = edit.clone();
InlineEdit::new((id.clone(), "title"), task.title.clone()).on_commit(
move |title, window, cx| {
let title = SharedString::from(title.trim().to_string());
if title.is_empty() {
log::info!("task panel: a blank title is not taken");
return;
}
edit(&move |task| task.title = title.clone(), window, cx)
},
)
};
let status = {
let edit = edit.clone();
StatusSelect::new((id.clone(), "status"), task.status)
.size(ControlSize::Sm)
.on_change(move |status, window, cx| {
edit(&move |task| task.status = status, window, cx)
})
};
let priority = {
let edit = edit.clone();
let choices =
Priority::ALL.map(|priority| Choice::new(priority.key(), priority.words()));
Select::new((id.clone(), "priority"), choices)
.selected(task.priority.key())
.size(ControlSize::Sm)
.on_change(move |key: &SharedString, window, cx| {
let priority = Priority::of_key(key);
edit(&move |task| task.priority = priority, window, cx)
})
};
let assignee = {
let edit = edit.clone();
let picker = AssigneePicker::new((id.clone(), "assignee"), self.people)
.size(ControlSize::Sm)
.on_change(move |person, window, cx| {
let person = person.cloned();
edit(&move |task| task.assignee = person.clone(), window, cx)
});
match &task.assignee {
Some(person) => picker.assignee(person.key.clone()),
None => picker,
}
};
let due = {
let edit = edit.clone();
DatePicker::new((id.clone(), "due"), task.due)
.size(ControlSize::Sm)
.on_change(move |day, window, cx| {
edit(&move |task| task.due = Some(day), window, cx)
})
};
let labels = match task.labels.is_empty() {
true => div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_subtle)
.child("None"),
false => div()
.flex()
.flex_wrap()
.gap_x_3()
.gap_y_1()
.children(task.labels.iter().map(|label| label_chip(label, cx))),
};
let steps = (!self.steps.is_empty() || self.on_add_step.is_some()).then(|| {
let steps = Checklist::new((id.clone(), "steps"), "Subtasks", self.steps);
let steps = match self.on_step {
Some(on_step) => {
steps.on_toggle(move |ix, done, window, cx| on_step(ix, done, window, cx))
}
None => steps,
};
match self.on_add_step {
Some(on_add) => steps.on_add("Add a subtask", move |words, window, cx| {
on_add(words, window, cx)
}),
None => steps,
}
});
div()
.debug_selector(|| "task-panel".into())
.flex()
.flex_col()
.gap_5()
.child(head)
.child(
div()
.text_size(theme.text_size(TextSize::Lg))
.font_weight(FontWeight::SEMIBOLD)
.child(title),
)
.child(
div()
.flex()
.flex_col()
.gap_2()
.child(property("Status", status.into_any_element(), cx))
.child(property("Priority", priority.into_any_element(), cx))
.child(property("Assignee", assignee.into_any_element(), cx))
.child(property("Due", due.into_any_element(), cx))
.child(property("Labels", labels.into_any_element(), cx)),
)
.children(self.notes.map(|notes| {
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg)
.child(notes)
}))
.children(steps)
}
}
+1 -3
View File
@@ -22,7 +22,7 @@ use crate::{
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 {
pub(super) fn clock_today(user: &str) -> Date {
Timestamp::now().to_zoned(format::system_zone(user)).date()
}
@@ -208,7 +208,6 @@ impl RenderOnce for TaskList {
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))
@@ -241,7 +240,6 @@ impl RenderOnce for TaskList {
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 {
+169
View File
@@ -0,0 +1,169 @@
use gpui::{
AnyElement, Entity, IntoElement, Modifiers, MouseButton, SharedString, TestAppContext, point,
px,
};
use jiff::civil::date;
use super::{Desk, desk, heard, note, press, settle, tab_to};
use crate::{
mail::Label,
project::{
IssueCard, KanbanBoard, KanbanCard, KanbanColumn, LabelManager, Person, Status, Task,
TaskDetailPanel,
},
};
fn card(key: &str) -> KanbanCard {
let task = Task::new(SharedString::from(key.to_string()), format!("Task {key}"));
let face =
IssueCard::new(SharedString::from(format!("issue-{key}")), task).today(date(2026, 9, 27));
KanbanCard::new(SharedString::from(key.to_string()), face)
}
fn board(owner: Entity<Desk>) -> AnyElement {
let (moved, opened) = (owner.clone(), owner);
KanbanBoard::new("board")
.column(
KanbanColumn::new("todo", "Todo")
.card(card("a"))
.card(card("b")),
)
.column(KanbanColumn::new("doing", "In progress").card(card("c")))
.on_move(move |key, column, index, _, cx| {
note(&moved, format!("move {key} {column} {index}"), cx)
})
.on_open(move |key, _, cx| note(&opened, format!("open {key}"), cx))
.into_any_element()
}
/// Presses at `from`, drags in steps to `to`, and lets go there.
fn drag(
from: gpui::Point<gpui::Pixels>,
to: gpui::Point<gpui::Pixels>,
cx: &mut gpui::VisualTestContext,
) {
cx.simulate_mouse_move(from, None, Modifiers::none());
cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none());
for step in 1..=6 {
let share = step as f32 / 6.0;
let at = point(
from.x + (to.x - from.x) * share,
from.y + (to.y - from.y) * share,
);
cx.simulate_mouse_move(at, Some(MouseButton::Left), Modifiers::none());
settle(cx);
}
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
settle(cx);
}
#[gpui::test]
fn a_card_dragged_to_another_column_lands_where_it_is_let_go(cx: &mut TestAppContext) {
let (host, cx) = desk(board, cx);
let from = cx.debug_bounds("card a").expect("card a draws").center();
let lane = cx.debug_bounds("column doing").expect("the column draws");
drag(from, point(lane.center().x, lane.bottom() - px(8.0)), cx);
assert_eq!(
heard(&host, cx),
["move a doing 1"],
"below the column's one card"
);
}
#[gpui::test]
fn a_card_let_go_in_its_own_place_does_not_move(cx: &mut TestAppContext) {
let (host, cx) = desk(board, cx);
let from = cx.debug_bounds("card a").expect("card a draws").center();
drag(from, point(from.x, from.y + px(6.0)), cx);
assert!(heard(&host, cx).is_empty(), "{:?}", heard(&host, cx));
}
#[gpui::test]
fn option_arrows_move_the_focused_card_and_enter_opens_it(cx: &mut TestAppContext) {
let (host, cx) = desk(board, cx);
tab_to(1, cx);
press("down", cx);
press("alt-down", cx);
press("alt-right", cx);
press("enter", cx);
assert_eq!(
heard(&host, cx),
["move a todo 1", "move a doing 0", "open a"]
);
}
fn labels(owner: Entity<Desk>) -> AnyElement {
let (renamed, made) = (owner.clone(), owner);
LabelManager::new(
"labels",
[Label::new("bug", "Bug", 3), Label::new("site", "Site", 1)],
)
.on_rename(move |key, name, _, cx| note(&renamed, format!("rename {key} {name}"), cx))
.on_create(move |name, _, cx| note(&made, format!("make {name}"), cx))
.into_any_element()
}
#[gpui::test]
fn a_label_keeps_a_name_of_its_own(cx: &mut TestAppContext) {
let (host, cx) = desk(labels, cx);
tab_to(2, cx);
press("enter", cx);
cx.simulate_input("site");
press("enter", cx);
assert!(
cx.debug_bounds("refused bug").is_some(),
"a taken name is refused"
);
tab_to(2, cx);
press("enter", cx);
cx.simulate_input("Defect");
press("enter", cx);
tab_to(7, cx);
cx.simulate_input("bug");
press("enter", cx);
assert!(
cx.debug_bounds("refused +new").is_some(),
"a new label cannot take one"
);
cx.simulate_input(" report");
press("enter", cx);
assert_eq!(heard(&host, cx), ["rename bug Defect", "make bug report"]);
}
fn panel(owner: Entity<Desk>) -> AnyElement {
let task = Task::new("brief", "Write the brief").status(Status::Todo);
TaskDetailPanel::new("panel", task, [Person::new("ana", "Ana Lima")])
.on_change(move |task, _, cx| {
let said = format!("{} {:?} {:?}", task.title, task.status, task.priority);
note(&owner, said, cx)
})
.into_any_element()
}
#[gpui::test]
fn the_panel_hands_on_the_task_after_each_edit(cx: &mut TestAppContext) {
let (host, cx) = desk(panel, cx);
tab_to(1, cx);
press("enter", cx);
cx.simulate_input(" ");
press("enter", cx);
tab_to(1, cx);
press("enter", cx);
cx.simulate_input("Client brief");
press("enter", cx);
tab_to(2, cx);
cx.simulate_keystrokes("down down enter");
settle(cx);
tab_to(3, cx);
cx.simulate_keystrokes("down up enter");
settle(cx);
assert_eq!(
heard(&host, cx),
[
"Client brief Todo None",
"Write the brief InProgress None",
"Write the brief Todo Low"
],
"a blank title is not taken"
);
}
@@ -7,6 +7,8 @@ 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};
mod boards;
/// A view that shows one project part and keeps what it heard.
struct Desk {
part: fn(Entity<Desk>) -> AnyElement,
+35 -2
View File
@@ -3,7 +3,7 @@ use std::fmt;
use gpui::SharedString;
use jiff::civil::Date;
use crate::primitives::IconName;
use crate::{mail::Label, primitives::IconName};
/// Where a task stands in its workflow.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -83,6 +83,15 @@ pub enum Priority {
}
impl Priority {
/// Most urgent first.
pub const ALL: [Priority; 5] = [
Priority::Urgent,
Priority::High,
Priority::Medium,
Priority::Low,
Priority::None,
];
pub fn words(self) -> &'static str {
match self {
Priority::None => "No priority",
@@ -93,6 +102,23 @@ impl Priority {
}
}
pub(crate) fn key(self) -> &'static str {
match self {
Priority::None => "none",
Priority::Low => "low",
Priority::Medium => "medium",
Priority::High => "high",
Priority::Urgent => "urgent",
}
}
pub(crate) fn of_key(key: &str) -> Priority {
Priority::ALL
.into_iter()
.find(|priority| priority.key() == key)
.unwrap_or_else(|| panic!("no priority {key}"))
}
/// Bars lit of three; urgent has a mark of its own.
pub(crate) fn bars(self) -> usize {
match self {
@@ -146,7 +172,7 @@ impl Person {
}
}
/// 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.
/// 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, who has it, and its labels.
#[derive(Clone, Debug, PartialEq)]
pub struct Task {
pub key: SharedString,
@@ -156,6 +182,7 @@ pub struct Task {
pub priority: Priority,
pub due: Option<Date>,
pub assignee: Option<Person>,
pub labels: Vec<Label>,
}
impl Task {
@@ -168,6 +195,7 @@ impl Task {
priority: Priority::None,
due: None,
assignee: None,
labels: Vec::new(),
}
}
@@ -196,6 +224,11 @@ impl Task {
self
}
pub fn label(mut self, label: Label) -> Self {
self.labels.push(label);
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)
+3 -1
View File
@@ -5,6 +5,7 @@ mod generative;
mod media;
mod messaging;
mod palette;
mod project;
mod tokens;
use std::time::{Duration, Instant};
@@ -18,7 +19,8 @@ pub use files::FileSizes;
pub use generative::GenerativeSizes;
pub use media::MediaSizes;
pub use messaging::MessagingSizes;
pub use palette::{Mix, Palette, Syntax};
pub use palette::{HUE_NAMES, Mix, Palette, Syntax};
pub use project::ProjectSizes;
pub use tokens::{
AvatarSize, ContainerSize, ControlSize, Density, Elevation, IconSize, Radius, TextSize,
};
+5
View File
@@ -117,6 +117,11 @@ fn c8<const N: usize>(hex: [u32; N]) -> [Hsla; N] {
hex.map(c)
}
/// The chart's hues by name, for what wears one: an event, a label.
pub const HUE_NAMES: [&str; 8] = [
"Blue", "Teal", "Ochre", "Rose", "Violet", "Green", "Rust", "Cyan",
];
impl Palette {
/// The chart color at `hue`; a hue past the chart fails, naming `owner`.
pub fn hue(&self, hue: usize, owner: impl std::fmt::Display) -> Hsla {
+18
View File
@@ -0,0 +1,18 @@
use gpui::Rems;
use super::{Theme, tokens::px_to_rems};
/// Project's measures.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ProjectSizes {
/// A board's column, wide.
pub column: Rems,
}
impl Theme {
pub fn project(&self) -> ProjectSizes {
ProjectSizes {
column: px_to_rems(272.0),
}
}
}
+5 -5
View File
@@ -13,11 +13,11 @@
## T31b Project · Issues and boards
- [ ] IssueCard
- [ ] LabelManager
- [ ] KanbanBoard / KanbanColumn / KanbanCard
- [ ] Sprint Board → `project::KanbanBoard`
- [ ] TaskDetailPanel
- [x] IssueCard
- [x] LabelManager
- [x] KanbanBoard / KanbanColumn / KanbanCard
- [x] Sprint Board → `project::KanbanBoard`
- [x] TaskDetailPanel
## T31c Project · Plans