Files
Ely-GPUI-Components/src/calendar/reminders.rs
T
ZacharyZhang-NY 8cbd389d72 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.
2026-09-27 05:12:20 -04:00

171 lines
5.8 KiB
Rust

use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant, IconButton},
forms::{Choice, Select},
primitives::IconName,
theme::ControlSize,
};
type OnMinutes = Rc<dyn Fn(&[u32], &mut Window, &mut App)>;
/// The reminders on offer, in minutes before an event, with their words.
pub(crate) const OFFSETS: [(u32, &str); 9] = [
(0, "At the time"),
(5, "5 minutes before"),
(10, "10 minutes before"),
(15, "15 minutes before"),
(30, "30 minutes before"),
(60, "1 hour before"),
(120, "2 hours before"),
(1440, "1 day before"),
(10080, "1 week before"),
];
/// `minutes` sorted, each asserted to be on offer and to come once.
fn checked(minutes: &[u32]) -> Vec<u32> {
let mut sorted = minutes.to_vec();
sorted.sort_unstable();
for (ix, minute) in sorted.iter().enumerate() {
let offered = OFFSETS.iter().any(|(each, _)| each == minute);
assert!(offered, "no reminder {minute} minutes before is on offer");
assert!(
ix == 0 || sorted[ix - 1] != *minute,
"reminder {minute} twice"
);
}
sorted
}
/// Reminders before an event: each picked from a list, with a way off. Add brings the nearest one not yet set.
#[derive(IntoElement)]
pub struct ReminderPicker {
id: ElementId,
minutes: Vec<u32>,
on_change: Option<OnMinutes>,
}
impl ReminderPicker {
/// `minutes` before the event, each one of those on offer.
pub fn new(id: impl Into<ElementId>, minutes: impl IntoIterator<Item = u32>) -> Self {
let minutes: Vec<u32> = minutes.into_iter().collect();
Self {
id: id.into(),
minutes: checked(&minutes),
on_change: None,
}
}
/// Gets the minutes before after each change, soonest first.
pub fn on_change(mut self, handler: impl Fn(&[u32], &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ReminderPicker {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let set: OnMinutes = {
let (id, on_change) = (self.id.clone(), self.on_change);
Rc::new(move |next, window, cx| {
let next = checked(next);
log::info!("reminder picker {id:?}: {next:?}");
if let Some(on_change) = &on_change {
on_change(&next, window, cx);
}
})
};
let minutes = Rc::new(self.minutes);
let rows = minutes.iter().enumerate().map(|(ix, minute)| {
let choices = OFFSETS
.iter()
.filter(|(each, _)| each == minute || !minutes.contains(each))
.map(|(each, words)| Choice::new(each.to_string(), *words));
let (change, remove) = (set.clone(), set.clone());
let (changing, removing) = (minutes.clone(), minutes.clone());
div()
.flex()
.items_center()
.gap_2()
.child(
div().flex_1().min_w_0().child(
Select::new((self.id.clone(), format!("offset-{minute}")), choices)
.size(ControlSize::Sm)
.selected(minute.to_string())
.on_change(move |value: &SharedString, window, cx| {
let picked: u32 = value.parse().expect("an offset is a count");
let mut next = changing.to_vec();
next[ix] = picked;
change(&next, window, cx)
}),
),
)
.child(
IconButton::new((self.id.clone(), format!("remove-{minute}")), IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Remove reminder")
.on_click(move |_, window, cx| {
let next: Vec<u32> = removing
.iter()
.copied()
.filter(|each| each != &removing[ix])
.collect();
remove(&next, window, cx)
}),
)
});
let spare = OFFSETS
.iter()
.map(|(minute, _)| *minute)
.find(|minute| !minutes.contains(minute));
let add = {
let (set, minutes) = (set.clone(), minutes.clone());
Button::new((self.id.clone(), "add"), "Add reminder")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.icon(IconName::Plus)
.disabled(spare.is_none())
.on_click(move |_, window, cx| {
let mut next = minutes.to_vec();
next.push(spare.expect("Add waits for a reminder not yet set"));
set(&next, window, cx)
})
};
div()
.debug_selector(|| "reminders".into())
.flex()
.flex_col()
.gap_2()
.children(rows.collect::<Vec<_>>())
.child(div().flex().child(add))
}
}
#[cfg(test)]
mod tests {
use super::checked;
#[test]
fn reminders_sort_soonest_first() {
assert_eq!(checked(&[1440, 10, 0]), [0, 10, 1440]);
}
#[test]
#[should_panic(expected = "no reminder 7 minutes before is on offer")]
fn a_reminder_is_one_on_offer() {
checked(&[7]);
}
#[test]
#[should_panic(expected = "reminder 10 twice")]
fn a_reminder_comes_once() {
checked(&[10, 10]);
}
}