Mail · Sorting: a label picker and a snooze menu
LabelPicker puts labels on a message: a field finds them by name, arrows move and a press or Enter turns one on or off, each row with its check and its hue. A name no label has can be made into one, and the field starts over. Its rows are the option rows every list shares; forms::marked_row leads one with a mark. The cursor shows only while the field has focus. SnoozePicker offers later today, tomorrow, this weekend and next week, each with its day and hour, or a time picked in a dialog. It shares mail::times with ScheduleSend: the names, the dialog, which now waits for a time ahead of now, and a rule that a moment comes once, so on a Sunday tomorrow morning no longer repeats as Monday morning.
This commit is contained in:
1 parent
14b5faa80c
commit
ccbc0970e5
16 files changed
+1037
-144
No files matched your search
@@ -162,6 +162,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- The focus color belongs to the focus ring. A chosen or shown item takes accent, as `forms::picks` and `lists::ListItem` do.
|
||||
- A box that shows a host's picture takes the picture's shape from the host (`ratio`), so nothing is cropped and a point given as a share lands on the picture. A focusable one keeps its focus border on an outer box, so the picture keeps that shape exactly (`media::ImageCropper`).
|
||||
- A default only the component can work out, such as a crop fitted to a picture it decodes, reaches the owner once through `window.defer` after render (`media::ImageUpload`).
|
||||
- A menu of times goes through `mail::times`: each row names its day and hour, a moment comes once (tomorrow is next Monday on a Sunday), and "Pick a time…" opens one dialog that waits for a time ahead of now (`ScheduleSend`, `SnoozePicker`).
|
||||
- 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.
|
||||
|
||||
## Decisions
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
- [x] T28e Messaging · Stickers and GIFs
|
||||
- [x] T29a Mail · Boxes and lists
|
||||
- [x] T29b Mail · Reading
|
||||
- [ ] T29c Mail · Writing
|
||||
- [x] T29c Mail · Writing
|
||||
- [ ] T29d Mail · Sorting
|
||||
- [ ] T30 Calendar
|
||||
- [ ] T31 Project — `tasks/ch31-43.md`
|
||||
@@ -346,3 +346,5 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T29a | 2 | PASS | |
|
||||
| T29b | 1 | FAIL | a folded message lost focus as it opened; a long name beside a long date ran past a 280px fold |
|
||||
| T29b | 2 | PASS | |
|
||||
| T29c | 1 | FAIL | a long chip ran past its tag field at 280px |
|
||||
| T29c | 2 | PASS | |
|
||||
@@ -3,12 +3,14 @@ use ely_gpui_component::{
|
||||
feedback::EmptyState,
|
||||
mail::{Mail, MailList, Mailbox, MailboxList},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, Radius},
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
typography::format,
|
||||
};
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
use jiff::{SignedDuration, Timestamp};
|
||||
use jiff::{SignedDuration, Timestamp, tz::TimeZone};
|
||||
|
||||
mod read;
|
||||
mod sort;
|
||||
mod write;
|
||||
|
||||
use super::Page;
|
||||
@@ -52,8 +54,40 @@ const SCRIPT: &[Step] = &[
|
||||
Step::Key("enter"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("recipient-added"),
|
||||
Step::DownAt("mail-labels", 150.0, 25.0),
|
||||
Step::UpAt("mail-labels", 150.0, 25.0),
|
||||
Step::Type("Suppliers"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("label-new"),
|
||||
Step::Key("enter"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("label-made"),
|
||||
Step::DownAt("mail-snooze", 50.0, 16.0),
|
||||
Step::UpAt("mail-snooze", 50.0, 16.0),
|
||||
Step::Wait(300),
|
||||
Step::Shot("snooze-open"),
|
||||
Step::Key("up"),
|
||||
Step::Key("enter"),
|
||||
Step::Wait(300),
|
||||
Step::Shot("snooze-dialog"),
|
||||
Step::Key("escape"),
|
||||
];
|
||||
|
||||
/// What a demo says it did, under it.
|
||||
fn quiet(text: SharedString, cx: &App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(text)
|
||||
}
|
||||
|
||||
/// A moment as the demos say it: its weekday and hour here.
|
||||
fn day_and_hour(at: Timestamp) -> String {
|
||||
let zone = TimeZone::try_system().expect("the gallery reads the system time zone");
|
||||
format::datetime(at, &zone, "%a %H:%M").expect("a fixed pattern formats")
|
||||
}
|
||||
|
||||
fn boxes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let open = keep("mail-open-box", || SharedString::from("inbox"), window, cx);
|
||||
let shown = open.read(cx).clone();
|
||||
@@ -223,6 +257,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
.child(read::thread(cx))
|
||||
.child(write::composer(window, cx))
|
||||
.child(write::signature(window, cx))
|
||||
.child(sort::labels(window, cx))
|
||||
.child(sort::snooze(window, cx))
|
||||
.child(write::schedule(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use ely_gpui_component::{
|
||||
mail::{Label, LabelPicker, SnoozePicker},
|
||||
theme::{ActiveTheme, Radius},
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use super::{day_and_hour, quiet};
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{change, keep, section, set},
|
||||
};
|
||||
|
||||
/// The demo's labels and the keys on.
|
||||
#[derive(Clone)]
|
||||
struct Sorting {
|
||||
labels: Vec<Label>,
|
||||
on: Vec<SharedString>,
|
||||
}
|
||||
|
||||
pub fn labels(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let sorting = keep(
|
||||
"mail-labels",
|
||||
|| Sorting {
|
||||
labels: vec![
|
||||
Label::new("clients", "Clients", 0),
|
||||
Label::new("studio", "Studio", 5),
|
||||
Label::new("press", "Press", 3),
|
||||
Label::new("finance", "Finance", 1),
|
||||
Label::new("site", "Site visits", 2),
|
||||
Label::new("travel", "Travel", 7),
|
||||
],
|
||||
on: vec!["clients".into(), "press".into()],
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let now = sorting.read(cx).clone();
|
||||
let names: Vec<&str> = now
|
||||
.labels
|
||||
.iter()
|
||||
.filter(|label| now.on.contains(&label.key))
|
||||
.map(|label| label.name.as_ref())
|
||||
.collect();
|
||||
let said = match names.len() {
|
||||
0 => SharedString::from("No labels on."),
|
||||
_ => format!("On: {}.", names.join(", ")).into(),
|
||||
};
|
||||
let (changing, making) = (sorting.clone(), sorting);
|
||||
let theme = cx.theme();
|
||||
section(
|
||||
"LabelPicker",
|
||||
"The labels a message wears: the field finds them by name, arrows move, and a press or Enter turns one on or off. A name no label has can be made into one, on at once.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"mail-labels",
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.w(px(300.))
|
||||
.p_2()
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.child(
|
||||
LabelPicker::new("mail-labels", now.labels.clone())
|
||||
.selected(now.on.clone())
|
||||
.on_change(move |keys, _, cx| {
|
||||
change(&changing, cx, |sorting| sorting.on = keys.to_vec())
|
||||
})
|
||||
.on_create(move |name, _, cx| {
|
||||
let hues = cx.theme().colors.chart.len();
|
||||
change(&making, cx, |sorting| {
|
||||
let made = sorting.labels.len();
|
||||
let key = SharedString::from(format!("made-{made}"));
|
||||
let free = (0..hues).find(|hue| {
|
||||
sorting.labels.iter().all(|label| label.hue != *hue)
|
||||
});
|
||||
let hue = free.unwrap_or(made % hues);
|
||||
sorting.labels.push(Label::new(key.clone(), name.clone(), hue));
|
||||
sorting.on.push(key);
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(quiet(said, cx)),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn snooze(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let said = keep("mail-snoozed", || None::<SharedString>, window, cx);
|
||||
let shown = said.read(cx).clone();
|
||||
section(
|
||||
"SnoozePicker",
|
||||
"Put a message off until later today, tomorrow, this weekend or next week, each with its day and hour, or until a time of your own.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"mail-snooze",
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_4()
|
||||
.child(SnoozePicker::new("mail-snooze").on_snooze(move |at, _, cx| {
|
||||
set(&said, Some(format!("Back {}.", day_and_hour(at)).into()), cx)
|
||||
}))
|
||||
.children(shown.map(|shown| quiet(shown, cx))),
|
||||
))
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
use ely_gpui_component::{
|
||||
chat::Attachment,
|
||||
mail::{Contact, MailComposer, ScheduleSend, SignatureEditor},
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
theme::{ActiveTheme, Radius},
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use super::{day_and_hour, quiet};
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{change, keep, section, set},
|
||||
@@ -39,14 +40,6 @@ struct Writing {
|
||||
said: Option<SharedString>,
|
||||
}
|
||||
|
||||
fn quiet(text: SharedString, cx: &App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(text)
|
||||
}
|
||||
|
||||
pub fn composer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let writing = keep(
|
||||
"mail-writing",
|
||||
@@ -83,7 +76,7 @@ pub fn composer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
change(&sent, cx, |writing| writing.said = Some(said.into()))
|
||||
})
|
||||
.on_schedule(move |draft, at, _, cx| {
|
||||
let said = format!("{} will go at {at}.", draft.subject);
|
||||
let said = format!("{} goes {}.", draft.subject, day_and_hour(at));
|
||||
change(&later, cx, |writing| writing.said = Some(said.into()))
|
||||
})
|
||||
.on_discard(move |_, cx| {
|
||||
@@ -164,7 +157,9 @@ pub fn schedule(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
.child(
|
||||
ScheduleSend::new("mail-schedule")
|
||||
.on_send(move |_, cx| set(&sent, Some("Sent now.".into()), cx))
|
||||
.on_schedule(move |at, _, cx| set(&later, Some(format!("Goes at {at}.").into()), cx)),
|
||||
.on_schedule(move |at, _, cx| {
|
||||
set(&later, Some(format!("Goes {}.", day_and_hour(at)).into()), cx)
|
||||
}),
|
||||
)
|
||||
.children(shown.map(|shown| quiet(shown, cx))),
|
||||
))
|
||||
|
||||
+2
-2
@@ -85,8 +85,8 @@ pub use multi::MultiSelect;
|
||||
pub use number::{NumberInput, ScrubInput};
|
||||
pub use options::Choice;
|
||||
pub(crate) use options::{
|
||||
OnFlag, OnNumber, OnValue, OnValues, Pick, Run, float, float_height, reveal, revealer, step,
|
||||
surface,
|
||||
OnFlag, OnNumber, OnValue, OnValues, Pick, Run, float, float_height, marked_row, reveal,
|
||||
revealer, step, surface,
|
||||
};
|
||||
pub use pairs::{FieldArray, KeyValueInput, ListInput};
|
||||
pub use path::PathInput;
|
||||
|
||||
@@ -87,6 +87,18 @@ pub(crate) fn option_row(
|
||||
highlighted: bool,
|
||||
checked: Option<bool>,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
marked_row(id, choice, None, highlighted, checked, cx)
|
||||
}
|
||||
|
||||
/// An option row with `mark`, such as a label's color, before the label.
|
||||
pub(crate) fn marked_row(
|
||||
id: impl Into<ElementId>,
|
||||
choice: &Choice,
|
||||
mark: Option<AnyElement>,
|
||||
highlighted: bool,
|
||||
checked: Option<bool>,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
@@ -128,6 +140,7 @@ pub(crate) fn option_row(
|
||||
.when_some(choice.icon, |row, icon| {
|
||||
row.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
|
||||
})
|
||||
.children(mark)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
|
||||
+2
-13
@@ -5,6 +5,7 @@ use gpui::{
|
||||
Styled, Window, div,
|
||||
};
|
||||
|
||||
use super::labels::hue_dot;
|
||||
use crate::{
|
||||
data_display::CountBadge,
|
||||
forms::OnValue,
|
||||
@@ -66,19 +67,7 @@ impl Mailbox {
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_muted)
|
||||
.into_any_element(),
|
||||
Mark::Hue(hue) => {
|
||||
let tint = *colors
|
||||
.chart
|
||||
.get(hue)
|
||||
.unwrap_or_else(|| panic!("label {}: no chart hue {hue}", self.name));
|
||||
div()
|
||||
.size(theme.icon_size(IconSize::Sm))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(div().size(theme.status_dot()).rounded_full().bg(tint))
|
||||
.into_any_element()
|
||||
}
|
||||
Mark::Hue(hue) => hue_dot(&self.name, hue, cx).into_any_element(),
|
||||
};
|
||||
let row = ListItem::new((id.clone(), format!("box-{}", self.key)), self.name.clone())
|
||||
.leading(mark)
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, Point, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement,
|
||||
Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
forms::{
|
||||
Choice, Down, Enter, OnValue, OnValues, Pick, SearchInput, TextInput, Up, marked_row,
|
||||
reveal, revealer,
|
||||
},
|
||||
layout::on_axis,
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, IconSize, TextSize},
|
||||
};
|
||||
|
||||
/// A label: its key, its name, and its hue among the theme's chart colors.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Label {
|
||||
pub key: SharedString,
|
||||
pub name: SharedString,
|
||||
pub hue: usize,
|
||||
}
|
||||
|
||||
impl Label {
|
||||
pub fn new(key: impl Into<SharedString>, name: impl Into<SharedString>, hue: usize) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
name: name.into(),
|
||||
hue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Label `name`'s dot in the chart color at `hue`, centered in an icon's box.
|
||||
pub(super) fn hue_dot(name: &str, hue: usize, cx: &App) -> Div {
|
||||
let theme = cx.theme();
|
||||
let tint = *theme
|
||||
.colors
|
||||
.chart
|
||||
.get(hue)
|
||||
.unwrap_or_else(|| panic!("label {name}: no chart hue {hue}"));
|
||||
div()
|
||||
.flex_none()
|
||||
.size(theme.icon_size(IconSize::Sm))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(div().size(theme.status_dot()).rounded_full().bg(tint))
|
||||
}
|
||||
|
||||
/// The search field, the row under the keyboard, the query it was set for, and the list's scroll.
|
||||
struct Finding {
|
||||
search: Entity<TextInput>,
|
||||
at: usize,
|
||||
query: String,
|
||||
revealed: Option<usize>,
|
||||
scroll: ScrollHandle,
|
||||
}
|
||||
|
||||
/// Moves the row under the keyboard by `by` among `count`, wrapping.
|
||||
fn step(finding: &Entity<Finding>, count: usize, by: isize, cx: &mut App) {
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
finding.update(cx, |finding, cx| {
|
||||
let at = finding.at.min(count - 1) as isize;
|
||||
finding.at = (at + by).rem_euclid(count as isize) as usize;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
/// Labels to put on a message: the field finds them by name, arrows move between them, and a press or Enter turns one on or off. With `on_create`, a name no label has can be made into one.
|
||||
#[derive(IntoElement)]
|
||||
pub struct LabelPicker {
|
||||
id: ElementId,
|
||||
labels: Vec<Label>,
|
||||
selected: Vec<SharedString>,
|
||||
on_change: Option<OnValues>,
|
||||
on_create: Option<OnValue>,
|
||||
}
|
||||
|
||||
impl LabelPicker {
|
||||
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];
|
||||
let key = before.iter().any(|other| other.key == label.key);
|
||||
assert!(!key, "label {} twice", label.key);
|
||||
let name = label.name.to_lowercase();
|
||||
let named = before.iter().any(|other| other.name.to_lowercase() == name);
|
||||
assert!(!named, "label name {} twice", label.name);
|
||||
}
|
||||
Self {
|
||||
id: id.into(),
|
||||
labels,
|
||||
selected: Vec::new(),
|
||||
on_change: None,
|
||||
on_create: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys of the labels on.
|
||||
pub fn selected(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
|
||||
self.selected = keys.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the keys on, in the labels' order, each time one turns on or off.
|
||||
pub fn on_change(
|
||||
mut self,
|
||||
handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Offers to make a label of a name no label has, and gets the name. The owner adds the label, on if it should be.
|
||||
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 LabelPicker {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
for key in &self.selected {
|
||||
let known = self.labels.iter().any(|label| label.key == *key);
|
||||
assert!(known, "label picker {:?}: no label {key}", self.id);
|
||||
}
|
||||
let finding = window.use_keyed_state(
|
||||
(self.id.clone(), "finding"),
|
||||
cx,
|
||||
|window, cx: &mut Context<Finding>| Finding {
|
||||
search: cx.new(|cx| TextInput::new(window, cx).placeholder("Find or make a label")),
|
||||
at: 0,
|
||||
query: String::new(),
|
||||
revealed: None,
|
||||
scroll: ScrollHandle::new(),
|
||||
},
|
||||
);
|
||||
let search = finding.read(cx).search.clone();
|
||||
let query = search.read(cx).text().trim().to_string();
|
||||
if finding.read(cx).query != query {
|
||||
finding.update(cx, |finding, _| {
|
||||
finding.query = query.clone();
|
||||
finding.at = 0;
|
||||
finding.scroll.set_offset(Point::default());
|
||||
});
|
||||
}
|
||||
let lower = query.to_lowercase();
|
||||
let fits: Vec<&Label> = self
|
||||
.labels
|
||||
.iter()
|
||||
.filter(|label| label.name.to_lowercase().contains(&lower))
|
||||
.collect();
|
||||
let fresh = self.on_create.is_some()
|
||||
&& !query.is_empty()
|
||||
&& !self
|
||||
.labels
|
||||
.iter()
|
||||
.any(|label| label.name.to_lowercase() == lower);
|
||||
let count = fits.len() + usize::from(fresh);
|
||||
let at = finding.read(cx).at.min(count.saturating_sub(1));
|
||||
let keyed = search.read(cx).focus().is_focused(window);
|
||||
let shown = reveal(&finding, |finding| &mut finding.revealed, count > 0, at, cx);
|
||||
let scroll = finding.read(cx).scroll.clone();
|
||||
let selected = Rc::new(self.selected);
|
||||
let pick: Pick = {
|
||||
let keys: Vec<SharedString> = fits.iter().map(|label| label.key.clone()).collect();
|
||||
let all: Vec<SharedString> =
|
||||
self.labels.iter().map(|label| label.key.clone()).collect();
|
||||
let (id, selected, search) = (self.id.clone(), selected.clone(), search.clone());
|
||||
let (on_change, on_create) = (self.on_change, self.on_create.clone());
|
||||
let name = SharedString::from(query.clone());
|
||||
Rc::new(move |ix, window, cx| {
|
||||
let Some(key) = keys.get(ix) else {
|
||||
log::info!("label picker {id:?}: create {name}");
|
||||
search.update(cx, |input, cx| input.set_text(String::new(), cx));
|
||||
if let Some(on_create) = &on_create {
|
||||
on_create(&name, window, cx);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let on = selected.contains(key);
|
||||
log::info!(
|
||||
"label picker {id:?}: {key} {}",
|
||||
if on { "off" } else { "on" }
|
||||
);
|
||||
let next: Vec<SharedString> = all
|
||||
.iter()
|
||||
.filter(|each| {
|
||||
if *each == key {
|
||||
!on
|
||||
} else {
|
||||
selected.contains(each)
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if let Some(on_change) = &on_change {
|
||||
on_change(&next, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let row = |ix: usize, choice: Choice, mark: Option<AnyElement>, on: bool| {
|
||||
let pick = pick.clone();
|
||||
let key = choice.value.clone();
|
||||
marked_row(
|
||||
(self.id.clone(), format!("label-{key}")),
|
||||
&choice,
|
||||
mark,
|
||||
keyed && ix == at,
|
||||
Some(on),
|
||||
cx,
|
||||
)
|
||||
.debug_selector(move || format!("label {key}"))
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(move |_, window, cx| pick(ix, window, cx))
|
||||
};
|
||||
let mut rows: Vec<AnyElement> = fits
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, label)| {
|
||||
let choice = Choice::new(label.key.clone(), label.name.clone());
|
||||
let dot = hue_dot(&label.name, label.hue, cx).into_any_element();
|
||||
row(ix, choice, Some(dot), selected.contains(&label.key)).into_any_element()
|
||||
})
|
||||
.collect();
|
||||
if fresh {
|
||||
let choice = Choice::new("+create", format!("Create “{query}”")).icon(IconName::Plus);
|
||||
rows.push(row(fits.len(), choice, None, false).into_any_element());
|
||||
}
|
||||
let theme = cx.theme();
|
||||
let none = rows.is_empty().then(|| {
|
||||
div()
|
||||
.px_2()
|
||||
.py_2()
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(match (self.labels.is_empty(), self.on_create.is_some()) {
|
||||
(true, true) => "No labels yet. Type a name to make one.",
|
||||
(true, false) => "No labels yet.",
|
||||
(false, _) => "No label fits.",
|
||||
})
|
||||
});
|
||||
let (up, down, chosen) = (finding.clone(), finding.clone(), finding);
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.capture_action(move |_: &Up, _, cx| {
|
||||
cx.stop_propagation();
|
||||
step(&up, count, -1, cx);
|
||||
})
|
||||
.capture_action(move |_: &Down, _, cx| {
|
||||
cx.stop_propagation();
|
||||
step(&down, count, 1, cx);
|
||||
})
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
if count > 0 {
|
||||
cx.stop_propagation();
|
||||
let at = chosen.read(cx).at.min(count - 1);
|
||||
pick(at, window, cx);
|
||||
}
|
||||
})
|
||||
.child(SearchInput::new((self.id.clone(), "search"), &search))
|
||||
.child(
|
||||
on_axis(
|
||||
div()
|
||||
.id((self.id.clone(), "labels"))
|
||||
.track_scroll(&scroll)
|
||||
.max_h(theme.list_max_height())
|
||||
.overflow_y_scroll(),
|
||||
)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.children(rows)
|
||||
.children(none)
|
||||
.children(shown.map(|(ix, done)| revealer(&scroll, ix, done))),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
mod boxes;
|
||||
mod compose;
|
||||
mod labels;
|
||||
mod list;
|
||||
mod quoted;
|
||||
mod reader;
|
||||
mod send;
|
||||
mod signature;
|
||||
mod snooze;
|
||||
mod thread;
|
||||
mod times;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
|
||||
pub use boxes::{Mailbox, MailboxList};
|
||||
pub use compose::{Draft, MailComposer};
|
||||
pub use labels::{Label, LabelPicker};
|
||||
pub use list::{Mail, MailItem, MailList};
|
||||
pub use quoted::QuotedText;
|
||||
pub use reader::{Contact, MailReader, Message};
|
||||
pub use send::ScheduleSend;
|
||||
pub use signature::SignatureEditor;
|
||||
pub use snooze::SnoozePicker;
|
||||
pub use thread::MailThreadView;
|
||||
+50
-113
@@ -1,49 +1,27 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Window, div, prelude::*};
|
||||
use jiff::{Timestamp, ToSpan, civil::DateTime, tz::TimeZone};
|
||||
use jiff::{Timestamp, tz::TimeZone};
|
||||
|
||||
use super::times::{
|
||||
Ask, OnTime, at_hour, distinct, named, next_monday, pick_item, picking, time_dialog,
|
||||
};
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
forms::{DateTimePicker, Run},
|
||||
forms::Run,
|
||||
menus::{Menu, MenuItem, SplitButton},
|
||||
overlays::Dialog,
|
||||
primitives::IconName,
|
||||
typography::format,
|
||||
};
|
||||
|
||||
pub(super) type OnTime = Rc<dyn Fn(Timestamp, &mut Window, &mut App)>;
|
||||
|
||||
/// Times to send later from `now` in `zone`: tomorrow at 8:00 and at 13:00, and the next Monday at 8:00, each named with its day and hour.
|
||||
fn presets(now: Timestamp, zone: &TimeZone) -> [(String, Timestamp); 3] {
|
||||
/// Times to send later from `now` in `zone`: tomorrow at 8:00 and at 13:00, and the next Monday at 8:00 unless tomorrow is one, each named with its day and hour.
|
||||
fn presets(now: Timestamp, zone: &TimeZone) -> Vec<(String, Timestamp)> {
|
||||
let today = now.to_zoned(zone.clone()).date();
|
||||
let tomorrow = today.tomorrow().expect("a day follows today");
|
||||
let ahead = 7 - i64::from(today.weekday().to_monday_zero_offset());
|
||||
let monday = today
|
||||
.checked_add(ahead.days())
|
||||
.expect("a Monday lies ahead");
|
||||
let at = |day: jiff::civil::Date, hour: i8| {
|
||||
day.at(hour, 0, 0, 0)
|
||||
.to_zoned(zone.clone())
|
||||
.expect("the zone holds the hour")
|
||||
.timestamp()
|
||||
};
|
||||
let named = |name: &str, when: Timestamp| {
|
||||
let hour = format::datetime(when, zone, "%a %H:%M").expect("a fixed pattern formats");
|
||||
(format!("{name} · {hour}"), when)
|
||||
};
|
||||
[
|
||||
named("Tomorrow morning", at(tomorrow, 8)),
|
||||
named("Tomorrow afternoon", at(tomorrow, 13)),
|
||||
named("Monday morning", at(monday, 8)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether the dialog for a time of one's own shows, and the time picked in it.
|
||||
#[derive(Default)]
|
||||
struct Picking {
|
||||
open: bool,
|
||||
chosen: Option<DateTime>,
|
||||
distinct([
|
||||
named("Tomorrow morning", at_hour(tomorrow, 8, zone), zone),
|
||||
named("Tomorrow afternoon", at_hour(tomorrow, 13, zone), zone),
|
||||
named("Monday morning", at_hour(next_monday(today), 8, zone), zone),
|
||||
])
|
||||
}
|
||||
|
||||
/// Send, and beside it the times to send later: tomorrow morning, tomorrow afternoon and Monday morning in the zone given, or a time picked in a dialog. Each only with its handler; disabled, only Send shows, dimmed.
|
||||
@@ -99,8 +77,7 @@ impl RenderOnce for ScheduleSend {
|
||||
let zone = self
|
||||
.zone
|
||||
.unwrap_or_else(|| format::system_zone("schedule send"));
|
||||
let picking =
|
||||
window.use_keyed_state((self.id.clone(), "picking"), cx, |_, _| Picking::default());
|
||||
let picking = picking(&self.id, window, cx);
|
||||
let menu = self
|
||||
.on_schedule
|
||||
.clone()
|
||||
@@ -116,18 +93,9 @@ impl RenderOnce for ScheduleSend {
|
||||
}))
|
||||
},
|
||||
);
|
||||
let opening = picking.clone();
|
||||
presets.separator().item(
|
||||
MenuItem::new("Pick a time…")
|
||||
.icon(IconName::Calendar)
|
||||
.on_click(move |_, cx| {
|
||||
log::info!("schedule send: pick a time");
|
||||
opening.update(cx, |picking, cx| {
|
||||
picking.open = true;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
presets
|
||||
.separator()
|
||||
.item(pick_item("schedule send", &picking))
|
||||
});
|
||||
let send = self.on_send.clone();
|
||||
let button = match menu {
|
||||
@@ -151,56 +119,18 @@ impl RenderOnce for ScheduleSend {
|
||||
})
|
||||
.into_any_element(),
|
||||
};
|
||||
let now = picking.read(cx);
|
||||
let dialog = (now.open).then(|| {
|
||||
let chosen = now.chosen;
|
||||
let (closing, choosing, id) = (picking.clone(), picking.clone(), self.id.clone());
|
||||
let schedule = self.on_schedule.clone();
|
||||
Dialog::new(
|
||||
(self.id.clone(), "dialog"),
|
||||
"Schedule send",
|
||||
move |_, cx| {
|
||||
closing.update(cx, |picking, cx| {
|
||||
*picking = Picking::default();
|
||||
cx.notify();
|
||||
})
|
||||
},
|
||||
)
|
||||
.detail("Pick the day and the hour it goes.")
|
||||
.child(
|
||||
DateTimePicker::new((id.clone(), "when"), chosen).on_change(move |at, _, cx| {
|
||||
choosing.update(cx, |picking, cx| {
|
||||
picking.chosen = Some(at);
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
.action(move |close| {
|
||||
Button::new((id.clone(), "cancel"), "Cancel")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, window, cx| close(window, cx))
|
||||
})
|
||||
.action({
|
||||
let (id, zone) = (self.id.clone(), zone.clone());
|
||||
move |close| {
|
||||
Button::new((id, "schedule"), "Schedule")
|
||||
.variant(ButtonVariant::Primary)
|
||||
.disabled(chosen.is_none())
|
||||
.on_click(move |_, window, cx| {
|
||||
let at = chosen
|
||||
.expect("Schedule waits for a time")
|
||||
.to_zoned(zone.clone())
|
||||
.expect("the zone holds the time")
|
||||
.timestamp();
|
||||
log::info!("schedule send: at {at}");
|
||||
close(window, cx);
|
||||
if let Some(schedule) = &schedule {
|
||||
schedule(at, window, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
});
|
||||
let dialog = time_dialog(
|
||||
&self.id,
|
||||
&picking,
|
||||
Ask {
|
||||
title: "Schedule send",
|
||||
detail: "Pick the day and the hour it goes.",
|
||||
action: "Schedule",
|
||||
},
|
||||
&zone,
|
||||
self.on_schedule.clone(),
|
||||
cx,
|
||||
);
|
||||
div().flex_none().child(button).children(dialog)
|
||||
}
|
||||
}
|
||||
@@ -212,37 +142,44 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(day: i8, hour: i8) -> Timestamp {
|
||||
date(2026, 9, day)
|
||||
.at(hour, 0, 0, 0)
|
||||
.to_zoned(TimeZone::UTC)
|
||||
.expect("a UTC time")
|
||||
.timestamp()
|
||||
at_hour(date(2026, 9, day), hour, &TimeZone::UTC)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_fall_tomorrow_and_on_the_next_monday() {
|
||||
let [(morning, a), (afternoon, b), (monday, c)] = presets(at(26, 12), &TimeZone::UTC);
|
||||
let [(morning, a), (afternoon, b), (monday, c)] = &presets(at(26, 12), &TimeZone::UTC)[..]
|
||||
else {
|
||||
panic!("three times from a Saturday");
|
||||
};
|
||||
assert_eq!(
|
||||
(morning.as_str(), a),
|
||||
(morning.as_str(), *a),
|
||||
("Tomorrow morning · Sun 08:00", at(27, 8))
|
||||
);
|
||||
assert_eq!(
|
||||
(afternoon.as_str(), b),
|
||||
(afternoon.as_str(), *b),
|
||||
("Tomorrow afternoon · Sun 13:00", at(27, 13))
|
||||
);
|
||||
assert_eq!(
|
||||
(monday.as_str(), c),
|
||||
(monday.as_str(), *c),
|
||||
("Monday morning · Mon 08:00", at(28, 8))
|
||||
);
|
||||
let [_, _, (_, next)] = presets(at(28, 9), &TimeZone::UTC);
|
||||
let next = presets(at(28, 9), &TimeZone::UTC).pop().map(|(_, at)| at);
|
||||
assert_eq!(
|
||||
next,
|
||||
date(2026, 10, 5)
|
||||
.at(8, 0, 0, 0)
|
||||
.to_zoned(TimeZone::UTC)
|
||||
.expect("a UTC time")
|
||||
.timestamp(),
|
||||
Some(at_hour(date(2026, 10, 5), 8, &TimeZone::UTC)),
|
||||
"on a Monday, the next one"
|
||||
);
|
||||
let sunday: Vec<String> = presets(at(27, 12), &TimeZone::UTC)
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
sunday,
|
||||
[
|
||||
"Tomorrow morning · Mon 08:00",
|
||||
"Tomorrow afternoon · Mon 13:00"
|
||||
],
|
||||
"on a Sunday, tomorrow morning is Monday morning"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Window, div, prelude::*};
|
||||
use jiff::{Timestamp, civil::Weekday, tz::TimeZone};
|
||||
|
||||
use super::times::{
|
||||
Ask, OnTime, at_hour, distinct, named, next_monday, pick_item, picking, time_dialog,
|
||||
};
|
||||
use crate::{
|
||||
menus::{DropdownMenu, Menu, MenuItem},
|
||||
primitives::IconName,
|
||||
typography::format,
|
||||
};
|
||||
|
||||
/// Times to snooze until from `now` in `zone`, each named with its day and hour: later today at 18:00 while before 17:00, tomorrow at 8:00, Saturday at 8:00 on a weekday, and the next Monday at 8:00. A moment comes once.
|
||||
pub(super) fn snoozes(now: Timestamp, zone: &TimeZone) -> Vec<(String, Timestamp)> {
|
||||
let here = now.to_zoned(zone.clone());
|
||||
let today = here.date();
|
||||
let tomorrow = today.tomorrow().expect("a day follows today");
|
||||
let workday = !matches!(today.weekday(), Weekday::Saturday | Weekday::Sunday);
|
||||
let times = [
|
||||
(here.hour() < 17).then(|| named("Later today", at_hour(today, 18, zone), zone)),
|
||||
Some(named("Tomorrow", at_hour(tomorrow, 8, zone), zone)),
|
||||
workday.then(|| {
|
||||
let saturday = today
|
||||
.nth_weekday(1, Weekday::Saturday)
|
||||
.expect("a Saturday lies ahead");
|
||||
named("This weekend", at_hour(saturday, 8, zone), zone)
|
||||
}),
|
||||
Some(named(
|
||||
"Next week",
|
||||
at_hour(next_monday(today), 8, zone),
|
||||
zone,
|
||||
)),
|
||||
];
|
||||
distinct(times.into_iter().flatten())
|
||||
}
|
||||
|
||||
/// A menu of times to put a message off until: later today, tomorrow, this weekend, next week, or a day and hour picked in a dialog, in the zone given.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SnoozePicker {
|
||||
id: ElementId,
|
||||
zone: Option<TimeZone>,
|
||||
on_snooze: Option<OnTime>,
|
||||
}
|
||||
|
||||
impl SnoozePicker {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
zone: None,
|
||||
on_snooze: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The zone its times read in; the system's otherwise.
|
||||
pub fn zone(mut self, zone: TimeZone) -> Self {
|
||||
self.zone = Some(zone);
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the moment the message comes back.
|
||||
pub fn on_snooze(
|
||||
mut self,
|
||||
handler: impl Fn(Timestamp, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_snooze = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SnoozePicker {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let zone = self
|
||||
.zone
|
||||
.unwrap_or_else(|| format::system_zone("snooze picker"));
|
||||
let picking = picking(&self.id, window, cx);
|
||||
let menu = snoozes(Timestamp::now(), &zone)
|
||||
.into_iter()
|
||||
.fold(Menu::new(), |menu, (name, at)| {
|
||||
let on_snooze = self.on_snooze.clone();
|
||||
menu.item(MenuItem::new(name).on_click(move |window, cx| {
|
||||
log::info!("snooze picker: until {at}");
|
||||
if let Some(on_snooze) = &on_snooze {
|
||||
on_snooze(at, window, cx);
|
||||
}
|
||||
}))
|
||||
})
|
||||
.separator()
|
||||
.item(pick_item("snooze picker", &picking));
|
||||
let dialog = time_dialog(
|
||||
&self.id,
|
||||
&picking,
|
||||
Ask {
|
||||
title: "Snooze until",
|
||||
detail: "Pick the day and the hour it comes back.",
|
||||
action: "Snooze",
|
||||
},
|
||||
&zone,
|
||||
self.on_snooze.clone(),
|
||||
cx,
|
||||
);
|
||||
div()
|
||||
.flex_none()
|
||||
.child(DropdownMenu::new(self.id.clone(), "Snooze", menu).icon(IconName::Clock))
|
||||
.children(dialog)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use jiff::civil::date;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn at(day: i8, hour: i8) -> Timestamp {
|
||||
at_hour(date(2026, 10, day), hour, &TimeZone::UTC)
|
||||
}
|
||||
|
||||
fn names(now: Timestamp) -> Vec<String> {
|
||||
snoozes(now, &TimeZone::UTC)
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_weekday_morning_offers_later_today_tomorrow_the_weekend_and_next_week() {
|
||||
let times: Vec<Timestamp> = snoozes(at(7, 9), &TimeZone::UTC)
|
||||
.into_iter()
|
||||
.map(|(_, at)| at)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names(at(7, 9)),
|
||||
[
|
||||
"Later today · Wed 18:00",
|
||||
"Tomorrow · Thu 08:00",
|
||||
"This weekend · Sat 08:00",
|
||||
"Next week · Mon 08:00",
|
||||
]
|
||||
);
|
||||
assert_eq!(times, [at(7, 18), at(8, 8), at(10, 8), at(12, 8)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_in_the_day_and_at_the_weekend_the_near_times_step_aside() {
|
||||
assert_eq!(
|
||||
names(at(7, 17)),
|
||||
[
|
||||
"Tomorrow · Thu 08:00",
|
||||
"This weekend · Sat 08:00",
|
||||
"Next week · Mon 08:00",
|
||||
],
|
||||
"from 17:00, later today is too near"
|
||||
);
|
||||
assert_eq!(
|
||||
names(at(10, 9)),
|
||||
[
|
||||
"Later today · Sat 18:00",
|
||||
"Tomorrow · Sun 08:00",
|
||||
"Next week · Mon 08:00",
|
||||
],
|
||||
"on a Saturday, the weekend is here"
|
||||
);
|
||||
assert_eq!(
|
||||
names(at(9, 9)),
|
||||
[
|
||||
"Later today · Fri 18:00",
|
||||
"Tomorrow · Sat 08:00",
|
||||
"Next week · Mon 08:00",
|
||||
],
|
||||
"on a Friday, tomorrow is the weekend"
|
||||
);
|
||||
assert_eq!(
|
||||
names(at(4, 9)),
|
||||
["Later today · Sun 18:00", "Tomorrow · Mon 08:00"],
|
||||
"on a Sunday, tomorrow is next week"
|
||||
);
|
||||
let last = snoozes(at(12, 9), &TimeZone::UTC).pop();
|
||||
assert_eq!(
|
||||
last.map(|(_, at)| at),
|
||||
Some(at(19, 8)),
|
||||
"on a Monday, the next one"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use crate::{
|
||||
};
|
||||
|
||||
mod read;
|
||||
mod sort;
|
||||
mod write;
|
||||
|
||||
fn setup(cx: &mut TestAppContext) {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
use gpui::{
|
||||
Context, Entity, IntoElement, Modifiers, ParentElement, Render, SharedString, Styled,
|
||||
TestAppContext, VisualTestContext, Window, div, px,
|
||||
};
|
||||
use jiff::{Timestamp, tz::TimeZone};
|
||||
|
||||
use super::{Mailing, heard, mailing, note, press, settle, setup, tab_to};
|
||||
use crate::{
|
||||
forms,
|
||||
mail::{Label, LabelPicker, SnoozePicker, snooze::snoozes},
|
||||
};
|
||||
|
||||
/// Labels the host keeps, the keys on, and what it heard.
|
||||
struct Labeling {
|
||||
labels: Vec<Label>,
|
||||
on: Vec<SharedString>,
|
||||
heard: Vec<String>,
|
||||
}
|
||||
|
||||
impl Render for Labeling {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (changing, making) = (cx.entity(), cx.entity());
|
||||
div().w(px(320.0)).child(
|
||||
LabelPicker::new("labels", self.labels.clone())
|
||||
.selected(self.on.clone())
|
||||
.on_change(move |keys, _, cx| {
|
||||
changing.update(cx, |host, cx| {
|
||||
host.heard.push(format!("on [{}]", keys.join(" ")));
|
||||
host.on = keys.to_vec();
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.on_create(move |name, _, cx| {
|
||||
making.update(cx, |host, cx| {
|
||||
host.heard.push(format!("make {name}"));
|
||||
let key = SharedString::from(name.to_lowercase());
|
||||
let hue = host.labels.len();
|
||||
host.labels.push(Label::new(key.clone(), name.clone(), hue));
|
||||
host.on.push(key);
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn labeling(cx: &mut TestAppContext) -> (Entity<Labeling>, &mut VisualTestContext) {
|
||||
setup(cx);
|
||||
let (host, cx) = cx.add_window_view(|_, _| Labeling {
|
||||
labels: vec![
|
||||
Label::new("clients", "Clients", 0),
|
||||
Label::new("finance", "Finance", 1),
|
||||
Label::new("press", "Press", 2),
|
||||
Label::new("travel", "Travel", 3),
|
||||
],
|
||||
on: vec!["press".into()],
|
||||
heard: Vec::new(),
|
||||
});
|
||||
cx.update(|window, cx| {
|
||||
window.activate_window();
|
||||
forms::bind_keys(cx);
|
||||
});
|
||||
settle(cx);
|
||||
tab_to(1, cx);
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
fn told(host: &Entity<Labeling>, cx: &mut VisualTestContext) -> Vec<String> {
|
||||
host.read_with(cx, |host, _| host.heard.clone())
|
||||
}
|
||||
|
||||
fn find(words: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_input(words);
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn typing_narrows_the_labels_and_enter_turns_the_first_on(cx: &mut TestAppContext) {
|
||||
let (host, cx) = labeling(cx);
|
||||
find("fin", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(told(&host, cx), ["on [finance press]"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn arrows_wrap_and_a_press_turns_a_label_off(cx: &mut TestAppContext) {
|
||||
let (host, cx) = labeling(cx);
|
||||
press("up", cx);
|
||||
press("enter", cx);
|
||||
let row = cx
|
||||
.debug_bounds("label press")
|
||||
.expect("the press label draws");
|
||||
cx.simulate_click(row.center(), Modifiers::none());
|
||||
settle(cx);
|
||||
assert_eq!(told(&host, cx), ["on [press travel]", "on [travel]"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_name_no_label_has_makes_one_and_the_field_starts_over(cx: &mut TestAppContext) {
|
||||
let (host, cx) = labeling(cx);
|
||||
find("Suppliers", cx);
|
||||
press("enter", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
told(&host, cx),
|
||||
["make Suppliers", "on [clients press suppliers]"],
|
||||
"with the field empty again, Enter takes the first label"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_name_a_label_has_is_not_offered_again(cx: &mut TestAppContext) {
|
||||
let (host, cx) = labeling(cx);
|
||||
find("PRESS", cx);
|
||||
press("down", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(told(&host, cx), ["on []"], "Press is the only row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "label name clients twice")]
|
||||
fn a_label_name_is_listed_once() {
|
||||
let _ = LabelPicker::new(
|
||||
"labels",
|
||||
[Label::new("a", "Clients", 0), Label::new("b", "clients", 1)],
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[should_panic(expected = "no label ghost")]
|
||||
fn what_is_on_is_a_label(cx: &mut TestAppContext) {
|
||||
let _ = mailing(
|
||||
|_| {
|
||||
LabelPicker::new("labels", [Label::new("a", "A", 0)])
|
||||
.selected(["ghost"])
|
||||
.into_any_element()
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn snoozing(owner: Entity<Mailing>) -> gpui::AnyElement {
|
||||
SnoozePicker::new("snooze")
|
||||
.zone(TimeZone::UTC)
|
||||
.on_snooze(move |at, _, cx| note(&owner, format!("until {at}"), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_first_time_offered_puts_the_message_off(cx: &mut TestAppContext) {
|
||||
let (host, cx) = mailing(snoozing, cx);
|
||||
tab_to(1, cx);
|
||||
press("enter", cx);
|
||||
press("enter", cx);
|
||||
let (_, first) = snoozes(Timestamp::now(), &TimeZone::UTC).remove(0);
|
||||
assert_eq!(heard(&host, cx), [format!("until {first}")]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_last_row_asks_for_a_time_of_ones_own(cx: &mut TestAppContext) {
|
||||
let (host, cx) = mailing(snoozing, cx);
|
||||
tab_to(1, cx);
|
||||
press("enter", cx);
|
||||
press("up", cx);
|
||||
press("enter", cx);
|
||||
assert!(cx.debug_bounds("time-dialog").is_some(), "the dialog shows");
|
||||
assert!(heard(&host, cx).is_empty(), "no time yet");
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, InteractiveElement, ParentElement, SharedString, Styled, Window, div,
|
||||
};
|
||||
use jiff::{
|
||||
Timestamp, ToSpan,
|
||||
civil::{Date, DateTime},
|
||||
tz::TimeZone,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
forms::{DateTimePicker, FormError},
|
||||
menus::MenuItem,
|
||||
overlays::Dialog,
|
||||
primitives::IconName,
|
||||
typography::format,
|
||||
};
|
||||
|
||||
pub(super) type OnTime = Rc<dyn Fn(Timestamp, &mut Window, &mut App)>;
|
||||
|
||||
/// `hour`:00 on `day` in `zone`.
|
||||
pub(super) fn at_hour(day: Date, hour: i8, zone: &TimeZone) -> Timestamp {
|
||||
day.at(hour, 0, 0, 0)
|
||||
.to_zoned(zone.clone())
|
||||
.expect("the zone holds the hour")
|
||||
.timestamp()
|
||||
}
|
||||
|
||||
/// The first Monday after `day`: a week on when `day` is a Monday.
|
||||
pub(super) fn next_monday(day: Date) -> Date {
|
||||
let ahead = 7 - i64::from(day.weekday().to_monday_zero_offset());
|
||||
day.checked_add(ahead.days()).expect("a Monday lies ahead")
|
||||
}
|
||||
|
||||
/// `name` beside the day and hour of `at` in `zone`, as a menu row shows a time.
|
||||
pub(super) fn named(name: &str, at: Timestamp, zone: &TimeZone) -> (String, Timestamp) {
|
||||
let hour = format::datetime(at, zone, "%a %H:%M").expect("a fixed pattern formats");
|
||||
(format!("{name} · {hour}"), at)
|
||||
}
|
||||
|
||||
/// `times` less each moment an earlier one names, as tomorrow names next Monday on a Sunday.
|
||||
pub(super) fn distinct(
|
||||
times: impl IntoIterator<Item = (String, Timestamp)>,
|
||||
) -> Vec<(String, Timestamp)> {
|
||||
let mut kept: Vec<(String, Timestamp)> = Vec::new();
|
||||
for (name, at) in times {
|
||||
if kept.iter().all(|(_, seen)| *seen != at) {
|
||||
kept.push((name, at));
|
||||
}
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// Whether the dialog for a time of one's own shows, and the time picked in it.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Picking {
|
||||
open: bool,
|
||||
chosen: Option<DateTime>,
|
||||
}
|
||||
|
||||
pub(super) fn picking(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity<Picking> {
|
||||
window.use_keyed_state((id.clone(), "picking"), cx, |_, _| Picking::default())
|
||||
}
|
||||
|
||||
/// The menu row that opens the dialog.
|
||||
pub(super) fn pick_item(owner: &'static str, picking: &Entity<Picking>) -> MenuItem {
|
||||
let picking = picking.clone();
|
||||
MenuItem::new("Pick a time…")
|
||||
.icon(IconName::Calendar)
|
||||
.on_click(move |_, cx| {
|
||||
log::info!("{owner}: pick a time");
|
||||
picking.update(cx, |picking, cx| {
|
||||
picking.open = true;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// What the dialog asks, and the action that takes its time.
|
||||
pub(super) struct Ask {
|
||||
pub title: &'static str,
|
||||
pub detail: &'static str,
|
||||
pub action: &'static str,
|
||||
}
|
||||
|
||||
/// While open, a dialog to pick a day and an hour in `zone`. Its action waits for a time ahead of now, then closes and hands the time on.
|
||||
pub(super) fn time_dialog(
|
||||
id: &ElementId,
|
||||
picking: &Entity<Picking>,
|
||||
ask: Ask,
|
||||
zone: &TimeZone,
|
||||
on_time: Option<OnTime>,
|
||||
cx: &App,
|
||||
) -> Option<Dialog> {
|
||||
let now = picking.read(cx);
|
||||
if !now.open {
|
||||
return None;
|
||||
}
|
||||
let chosen = now.chosen;
|
||||
let at = chosen.map(|chosen| {
|
||||
chosen
|
||||
.to_zoned(zone.clone())
|
||||
.expect("the zone holds the time")
|
||||
.timestamp()
|
||||
});
|
||||
let ahead = at.filter(|at| *at > Timestamp::now());
|
||||
let (closing, choosing, id) = (picking.clone(), picking.clone(), id.clone());
|
||||
let late = (at.is_some() && ahead.is_none())
|
||||
.then(|| FormError::new((id.clone(), "late"), "That time has passed."));
|
||||
let dialog = Dialog::new((id.clone(), "dialog"), ask.title, move |_, cx| {
|
||||
closing.update(cx, |picking, cx| {
|
||||
*picking = Picking::default();
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.detail(ask.detail)
|
||||
.child(
|
||||
div()
|
||||
.debug_selector(|| "time-dialog".into())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
DateTimePicker::new((id.clone(), "when"), chosen).on_change(move |at, _, cx| {
|
||||
choosing.update(cx, |picking, cx| {
|
||||
picking.chosen = Some(at);
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
.children(late),
|
||||
)
|
||||
.action({
|
||||
let id = id.clone();
|
||||
move |close| {
|
||||
Button::new((id, "cancel"), "Cancel")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, window, cx| close(window, cx))
|
||||
}
|
||||
})
|
||||
.action(move |close| {
|
||||
Button::new((id.clone(), "take"), SharedString::from(ask.action))
|
||||
.variant(ButtonVariant::Primary)
|
||||
.disabled(ahead.is_none())
|
||||
.on_click(move |_, window, cx| {
|
||||
let at = ahead.expect("the action waits for a time ahead");
|
||||
log::info!("time dialog {id:?}: {at}");
|
||||
close(window, cx);
|
||||
if let Some(on_time) = &on_time {
|
||||
on_time(at, window, cx);
|
||||
}
|
||||
})
|
||||
});
|
||||
Some(dialog)
|
||||
}
|
||||
+2
-2
@@ -382,8 +382,8 @@
|
||||
|
||||
## T29d Mail · Sorting
|
||||
|
||||
- [ ] LabelPicker
|
||||
- [ ] SnoozePicker
|
||||
- [x] LabelPicker
|
||||
- [x] SnoozePicker
|
||||
|
||||
## T30 Calendar — `src/calendar`
|
||||
|
||||
|
||||
Reference in new issue
Block a user