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:
1 parent
9b506e5d54
commit
62382f7e68
24 files changed
+1813
-31
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user