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.
This commit is contained in:
1 parent
1633bc9293
commit
8cbd389d72
24 files changed
+1813
-31
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 | |
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -227,7 +227,6 @@ impl RenderOnce for AvailabilityPicker {
|
||||
});
|
||||
div()
|
||||
.debug_selector(|| "availability".into())
|
||||
.min_w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -139,7 +139,6 @@ impl RenderOnce for ReminderPicker {
|
||||
};
|
||||
div()
|
||||
.debug_selector(|| "reminders".into())
|
||||
.min_w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
|
||||
@@ -275,7 +275,6 @@ impl RenderOnce for FormField {
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1p5()
|
||||
.min_w_full()
|
||||
.child(self.label)
|
||||
.children(self.children)
|
||||
.children(note)
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user