From 8cbd389d720d209046280460409ffc0515e1197f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 05:12:20 -0400 Subject: [PATCH] =?UTF-8?q?Project=20=C2=B7=20Issues=20and=20boards:=20a?= =?UTF-8?q?=20board,=20issue=20cards,=20a=20task's=20panel,=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 4 +- TASKS.md | 5 +- examples/gallery/pages/project/boards.rs | 237 ++++++++++++ examples/gallery/pages/project/mod.rs | 44 ++- src/calendar/availability.rs | 1 - src/calendar/editor.rs | 13 +- src/calendar/recurrence.rs | 3 +- src/calendar/reminders.rs | 1 - src/forms/structure.rs | 1 - src/forms/tests/structure.rs | 28 ++ src/project/board.rs | 457 +++++++++++++++++++++++ src/project/card.rs | 152 ++++++++ src/project/labels.rs | 303 +++++++++++++++ src/project/mod.rs | 9 + src/project/moves.rs | 58 +++ src/project/panel.rs | 279 ++++++++++++++ src/project/tasks.rs | 4 +- src/project/tests/boards.rs | 169 +++++++++ src/project/{tests.rs => tests/mod.rs} | 2 + src/project/work.rs | 37 +- src/theme/mod.rs | 4 +- src/theme/palette.rs | 5 + src/theme/project.rs | 18 + tasks/ch31-43.md | 10 +- 24 files changed, 1813 insertions(+), 31 deletions(-) create mode 100644 examples/gallery/pages/project/boards.rs create mode 100644 src/project/board.rs create mode 100644 src/project/card.rs create mode 100644 src/project/labels.rs create mode 100644 src/project/moves.rs create mode 100644 src/project/panel.rs create mode 100644 src/project/tests/boards.rs rename src/project/{tests.rs => tests/mod.rs} (99%) create mode 100644 src/theme/project.rs diff --git a/AGENTS.md b/AGENTS.md index 66e2c80..e25fb74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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>`. 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 diff --git a/TASKS.md b/TASKS.md index 2e3ea35..1cef45e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | | diff --git a/examples/gallery/pages/project/boards.rs b/examples/gallery/pages/project/boards.rs new file mode 100644 index 0000000..4b9777c --- /dev/null +++ b/examples/gallery/pages/project/boards.rs @@ -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 = (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, + key: &SharedString, + name: &SharedString, +) { + labels + .filter(|label| label.key == *key) + .for_each(|label| label.name = name.clone()); +} + +fn recolor<'a>(labels: impl Iterator, key: &SharedString, hue: usize) { + labels + .filter(|label| label.key == *key) + .for_each(|label| label.hue = hue); +} diff --git a/examples/gallery/pages/project/mod.rs b/examples/gallery/pages/project/mod.rs index 3c81be4..f5311f2 100644 --- a/examples/gallery/pages/project/mod.rs +++ b/examples/gallery/pages/project/mod.rs @@ -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 { ] } +/// The studio's labels. +fn studio_labels() -> Vec