Mail · Boxes and lists: mailboxes, folders and labels, the mail list, inbox zero
MailboxList sets mailboxes, folders and labels in sections: a press, an arrow or Enter opens one, the open one lit; a box with mail unread stands out with its count, and a label wears one of the theme's chart hues. MailList holds messages in a multiple-select list: the sender strong beside a dot while unread, the subject and first words under it, the time today, the weekday this week, the date this year or with its year, a paperclip for files, and a star whose press stays off the row. InboxZeroState points to feedback::EmptyState. Sections of selectable lists now have one home, lists::Sections, which ChannelList and MemberList use too; ListItem gains a third line.
This commit is contained in:
1 parent
7b38f99dcf
commit
7d20567921
17 files changed
+1244
-153
No files matched your search
@@ -134,7 +134,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- A mode that leaves on Escape holds its own focus through `primitives::hold_focus`: turned on while focus is elsewhere, it takes focus so Escape reaches it, and `hand_back` returns it (`documents::ZenMode`, `collab::FollowMode`).
|
||||
- A chat holds at its newest message through gpui's `list` with `ListAlignment::Bottom`: it sticks while scrolled to the end, and `scroll_to` one past the last item sticks it again; that call sends no scroll event, so the owner clears its own away state (`chat::MessageList`).
|
||||
- Anything pressable is a Tab stop: a row, card or toggle takes `tab_index(0)` and `focus_ring`, with a 1px border that stays transparent where it shows none; gpui presses it on the release of Enter or Space.
|
||||
- A list of pressable rows goes through `lists::SelectableList`, whose cursor reaches them by key: Up and Down move, Enter or a double press activates (`media::Playlist`). A bare `ListItem` press is the pointer's only.
|
||||
- A list of pressable rows goes through `lists::SelectableList`, whose cursor reaches them by key: Up and Down move, Enter or a double press activates (`media::Playlist`). A bare `ListItem` press is the pointer's only. Titled sections of such lists go through `lists::Sections`, for ChannelList, MemberList and MailboxList; each owner builds its rows and keeps its own checks.
|
||||
- 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.
|
||||
|
||||
@@ -102,8 +102,11 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
- [x] T28b Messaging · Messages
|
||||
- [x] T28c Messaging · People
|
||||
- [x] T28d Messaging · Calls
|
||||
- [ ] T28e Messaging · Stickers and GIFs
|
||||
- [ ] T29 Mail
|
||||
- [x] T28e Messaging · Stickers and GIFs
|
||||
- [ ] T29a Mail · Boxes and lists
|
||||
- [ ] T29b Mail · Reading
|
||||
- [ ] T29c Mail · Writing
|
||||
- [ ] T29d Mail · Sorting
|
||||
- [ ] T30 Calendar
|
||||
- [ ] T31 Project — `tasks/ch31-43.md`
|
||||
- [ ] T32 Canvas & Design
|
||||
@@ -337,3 +340,5 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T28b | 1 | PASS | |
|
||||
| T28c | 1 | PASS | |
|
||||
| T28d | 1 | PASS | |
|
||||
| T28e | 1 | FAIL | a GIF focused past the fold stayed out of view |
|
||||
| T28e | 2 | PASS | |
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-octagon-alert"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 16h.01" />
|
||||
<path d="M12 8v4" />
|
||||
<path d="M15.312 2a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586l-4.688-4.688A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1,204 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::Button,
|
||||
feedback::EmptyState,
|
||||
mail::{Mail, MailList, Mailbox, MailboxList},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, Radius},
|
||||
};
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
use jiff::{SignedDuration, Timestamp};
|
||||
|
||||
use super::Page;
|
||||
use crate::{
|
||||
probe::probe,
|
||||
script::Step,
|
||||
ui::{change, keep, section},
|
||||
};
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 29,
|
||||
slug: "mail",
|
||||
title: "Mail",
|
||||
summary: "Mailboxes and the messages in them: reading, writing, sorting and putting off.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
|
||||
const SCRIPT: &[Step] = &[
|
||||
Step::DownAt("mail-boxes", 100.0, 139.0),
|
||||
Step::UpAt("mail-boxes", 100.0, 139.0),
|
||||
Step::Wait(200),
|
||||
Step::Shot("box-opened"),
|
||||
Step::DownAt("mail-list", 417.0, 112.0),
|
||||
Step::UpAt("mail-list", 417.0, 112.0),
|
||||
Step::Wait(200),
|
||||
Step::Shot("starred"),
|
||||
];
|
||||
|
||||
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();
|
||||
section(
|
||||
"MailboxList / FolderList",
|
||||
"Mailboxes, folders and labels in sections: a press, an arrow or Enter opens one, and the one open is lit. A box with mail unread stands out with its count; a label wears its hue.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"mail-boxes",
|
||||
div().w(px(260.)).child(
|
||||
MailboxList::new("mail-boxes")
|
||||
.section(
|
||||
"Mailboxes",
|
||||
[
|
||||
Mailbox::new("inbox", "Inbox", IconName::Inbox).unread(12),
|
||||
Mailbox::new("starred", "Starred", IconName::Star),
|
||||
Mailbox::new("snoozed", "Snoozed", IconName::Clock),
|
||||
Mailbox::new("sent", "Sent", IconName::Send),
|
||||
Mailbox::new("drafts", "Drafts", IconName::FileText).unread(2),
|
||||
Mailbox::new("archive", "Archive", IconName::Archive),
|
||||
Mailbox::new("spam", "Spam", IconName::OctagonAlert).unread(4),
|
||||
Mailbox::new("trash", "Trash", IconName::Trash2),
|
||||
],
|
||||
)
|
||||
.section(
|
||||
"Folders",
|
||||
[
|
||||
Mailbox::new("atrium", "Atrium", IconName::Folder).unread(3),
|
||||
Mailbox::new("invoices", "Invoices", IconName::Folder),
|
||||
],
|
||||
)
|
||||
.section(
|
||||
"Labels",
|
||||
[
|
||||
Mailbox::label("clients", "Clients", 0),
|
||||
Mailbox::label("studio", "Studio", 5),
|
||||
Mailbox::label("press", "Press", 3),
|
||||
],
|
||||
)
|
||||
.open(shown)
|
||||
.on_open(move |key, _, cx| change(&open, cx, |open| *open = key.clone())),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// The demo's inbox: dated back from now, so the list reads the time, the weekday, the date and the year.
|
||||
fn inbox() -> Vec<Mail> {
|
||||
let now = Timestamp::now();
|
||||
let ago = |minutes: i64| now - SignedDuration::from_mins(minutes);
|
||||
vec![
|
||||
Mail::new(
|
||||
"review",
|
||||
"Ana Lima",
|
||||
"Friday's review moves to 10:00",
|
||||
ago(12),
|
||||
)
|
||||
.snippet("We'll meet in the model room. Bring the plaster samples if they've dried.")
|
||||
.unread(true),
|
||||
Mail::new("samples", "Ben Ito", "Plaster samples", ago(95))
|
||||
.snippet("Three finishes are in the stair hall: lime, tadelakt and a sanded gypsum.")
|
||||
.attachments(3)
|
||||
.unread(true),
|
||||
Mail::new("brief", "Chloé Martin", "The brief, final", ago(60 * 26))
|
||||
.snippet("Light from above, one stair, olive trees by the windows.")
|
||||
.starred(true),
|
||||
Mail::new(
|
||||
"invoice",
|
||||
"Studio Office",
|
||||
"Invoice 2026-114",
|
||||
ago(60 * 24 * 3),
|
||||
)
|
||||
.snippet("Please find the invoice for the September model work attached.")
|
||||
.attachments(1),
|
||||
Mail::new(
|
||||
"press",
|
||||
"Dev Rao",
|
||||
"A visit from the press",
|
||||
ago(60 * 24 * 9),
|
||||
)
|
||||
.snippet("They'd like to see the atrium before the opening. Would the 14th work?"),
|
||||
Mail::new(
|
||||
"welcome",
|
||||
"Eli Park",
|
||||
"Welcome to the studio",
|
||||
ago(60 * 24 * 400),
|
||||
)
|
||||
.snippet("A few notes to start: where the models live, and who to ask.")
|
||||
.starred(true),
|
||||
]
|
||||
}
|
||||
|
||||
fn list(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let selected = keep(
|
||||
"mail-selected",
|
||||
|| vec![SharedString::from("review")],
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let stars = keep(
|
||||
"mail-stars",
|
||||
|| vec![SharedString::from("brief"), SharedString::from("welcome")],
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let starred = stars.read(cx).clone();
|
||||
let mails = inbox()
|
||||
.into_iter()
|
||||
.map(|mail| {
|
||||
let on = starred.contains(&mail.key);
|
||||
mail.starred(on)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let chosen = selected.read(cx).clone();
|
||||
section(
|
||||
"MailList / MailItem",
|
||||
"Messages with the sender strong while unread, the subject and first words under it, when each came, a paperclip for files, and a star to press. A press or an arrow selects; Command or Shift takes several.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"mail-list",
|
||||
div().w(px(440.)).child(
|
||||
MailList::new("mail-list", mails)
|
||||
.selected(chosen)
|
||||
.on_select(move |keys, _, cx| change(&selected, cx, |selected| *selected = keys.to_vec()))
|
||||
.on_star(move |key, on, _, cx| {
|
||||
change(&stars, cx, |stars| {
|
||||
stars.retain(|each| each != key);
|
||||
if on {
|
||||
stars.push(key.clone());
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn zero(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
section(
|
||||
"InboxZeroState → EmptyState",
|
||||
"An inbox with nothing left in it says so, calm and brief.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"mail-zero",
|
||||
div()
|
||||
.w(px(440.))
|
||||
.py_6()
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.child(
|
||||
EmptyState::new("mail-zero", IconName::Inbox, "All caught up")
|
||||
.body("Nothing waits in your inbox. New mail lands here, newest first.")
|
||||
.action(Button::new("mail-zero-archive", "Open the archive")),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div()
|
||||
.child(boxes(window, cx))
|
||||
.child(list(window, cx))
|
||||
.child(zero(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -15,6 +15,7 @@ mod generative;
|
||||
mod git;
|
||||
mod layout;
|
||||
mod lists;
|
||||
mod mail;
|
||||
mod media;
|
||||
mod menus;
|
||||
mod messaging;
|
||||
@@ -71,6 +72,7 @@ pub const ALL: &[Page] = &[
|
||||
media::PAGE,
|
||||
files::PAGE,
|
||||
messaging::PAGE,
|
||||
mail::PAGE,
|
||||
theme::PAGE,
|
||||
];
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod generative;
|
||||
pub mod git;
|
||||
pub mod layout;
|
||||
pub mod lists;
|
||||
pub mod mail;
|
||||
pub mod media;
|
||||
pub mod menus;
|
||||
pub mod messaging;
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct ListItem {
|
||||
id: ElementId,
|
||||
title: SharedString,
|
||||
description: Option<SharedString>,
|
||||
detail: Option<SharedString>,
|
||||
leading: Option<AnyElement>,
|
||||
trailing: Option<AnyElement>,
|
||||
selected: bool,
|
||||
@@ -36,6 +37,7 @@ impl ListItem {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
description: None,
|
||||
detail: None,
|
||||
leading: None,
|
||||
trailing: None,
|
||||
selected: false,
|
||||
@@ -65,6 +67,12 @@ impl ListItem {
|
||||
self
|
||||
}
|
||||
|
||||
/// A third line, quieter still, such as a message's first words.
|
||||
pub fn detail(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.detail = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// An icon or an avatar before the text.
|
||||
pub fn leading(mut self, element: impl IntoElement) -> Self {
|
||||
self.leading = Some(element.into_any_element());
|
||||
@@ -164,6 +172,11 @@ impl RenderOnce for ListItem {
|
||||
line(text)
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_muted)
|
||||
}))
|
||||
.children(self.detail.map(|text| {
|
||||
line(text)
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_subtle)
|
||||
})),
|
||||
)
|
||||
.children(self.trailing.map(|trailing| {
|
||||
|
||||
@@ -4,6 +4,7 @@ mod grouped;
|
||||
mod item;
|
||||
mod long;
|
||||
mod outline;
|
||||
mod sections;
|
||||
mod select;
|
||||
mod sortable;
|
||||
mod swipe;
|
||||
@@ -19,6 +20,7 @@ pub use grouped::GroupedList;
|
||||
pub use item::{List, ListItem};
|
||||
pub use long::{InfiniteList, VirtualList};
|
||||
pub use outline::Outline;
|
||||
pub(crate) use sections::Sections;
|
||||
pub use select::SelectableList;
|
||||
pub(crate) use select::{Pick, picked};
|
||||
pub use sortable::SortableList;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div,
|
||||
};
|
||||
|
||||
use super::{ListItem, SelectableList};
|
||||
use crate::{
|
||||
forms::OnValue,
|
||||
theme::{ActiveTheme, TextSize},
|
||||
};
|
||||
|
||||
/// Lists under titles down a column, each a `SelectableList` keyed by its title under the id: Tab moves between them, and the chosen key is lit wherever it sits. A press or an arrow selects, Enter or a double press activates. Owners check their titles and keys.
|
||||
#[derive(IntoElement)]
|
||||
pub(crate) struct Sections {
|
||||
id: ElementId,
|
||||
sections: Vec<(SharedString, Vec<(SharedString, ListItem)>)>,
|
||||
counted: bool,
|
||||
selected: Option<SharedString>,
|
||||
on_select: Option<OnValue>,
|
||||
on_activate: Option<OnValue>,
|
||||
}
|
||||
|
||||
impl Sections {
|
||||
pub(crate) fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
sections: Vec::new(),
|
||||
counted: false,
|
||||
selected: None,
|
||||
on_select: None,
|
||||
on_activate: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn section(
|
||||
mut self,
|
||||
title: SharedString,
|
||||
rows: Vec<(SharedString, ListItem)>,
|
||||
) -> Self {
|
||||
self.sections.push((title, rows));
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows how many rows each section holds beside its title.
|
||||
pub(crate) fn counted(mut self) -> Self {
|
||||
self.counted = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn selected(mut self, key: Option<SharedString>) -> Self {
|
||||
self.selected = key;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn on_select(mut self, handler: OnValue) -> Self {
|
||||
self.on_select = Some(handler);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn on_activate(mut self, handler: OnValue) -> Self {
|
||||
self.on_activate = Some(handler);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Sections {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let sections =
|
||||
self.sections.into_iter().map(|(title, rows)| {
|
||||
let count = rows.len();
|
||||
let lit = self
|
||||
.selected
|
||||
.clone()
|
||||
.filter(|key| rows.iter().any(|(each, _)| each == key));
|
||||
let list = rows.into_iter().fold(
|
||||
SelectableList::new((self.id.clone(), format!("section-{title}"))),
|
||||
|list, (key, row)| list.row(key, row),
|
||||
);
|
||||
let (select, activate) = (self.on_select.clone(), self.on_activate.clone());
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(title),
|
||||
)
|
||||
.children(self.counted.then(|| {
|
||||
div().text_color(colors.fg_subtle).child(count.to_string())
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
list.selected(lit)
|
||||
.on_change(move |keys, window, cx| {
|
||||
let key = keys.first().expect("a pick names a row");
|
||||
if let Some(select) = &select {
|
||||
select(key, window, cx);
|
||||
}
|
||||
})
|
||||
.on_activate(move |key, window, cx| {
|
||||
if let Some(activate) = &activate {
|
||||
activate(key, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
});
|
||||
div().w_full().flex().flex_col().gap_4().children(sections)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
data_display::CountBadge,
|
||||
forms::OnValue,
|
||||
lists::{ListItem, Sections},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize},
|
||||
};
|
||||
|
||||
/// What marks a mailbox: an icon, or a label's hue among the theme's chart colors.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Mark {
|
||||
Icon(IconName),
|
||||
Hue(usize),
|
||||
}
|
||||
|
||||
/// A mailbox, folder or label: its key, its name, its mark, and how many messages wait unread.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Mailbox {
|
||||
pub key: SharedString,
|
||||
pub name: SharedString,
|
||||
mark: Mark,
|
||||
pub unread: u32,
|
||||
}
|
||||
|
||||
impl Mailbox {
|
||||
pub fn new(
|
||||
key: impl Into<SharedString>,
|
||||
name: impl Into<SharedString>,
|
||||
icon: IconName,
|
||||
) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
name: name.into(),
|
||||
mark: Mark::Icon(icon),
|
||||
unread: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A label, marked by a dot in the theme's chart color at `hue`.
|
||||
pub fn label(key: impl Into<SharedString>, name: impl Into<SharedString>, hue: usize) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
name: name.into(),
|
||||
mark: Mark::Hue(hue),
|
||||
unread: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unread(mut self, count: u32) -> Self {
|
||||
self.unread = count;
|
||||
self
|
||||
}
|
||||
|
||||
fn item(&self, id: &ElementId, cx: &App) -> ListItem {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let mark = match self.mark {
|
||||
Mark::Icon(icon) => Icon::new(icon)
|
||||
.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()
|
||||
}
|
||||
};
|
||||
let row = ListItem::new((id.clone(), format!("box-{}", self.key)), self.name.clone())
|
||||
.leading(mark)
|
||||
.strong(self.unread > 0);
|
||||
match self.unread {
|
||||
0 => row,
|
||||
unread => row.trailing(
|
||||
CountBadge::new(
|
||||
(id.clone(), format!("unread-{}", self.key)),
|
||||
unread as usize,
|
||||
)
|
||||
.max(999),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mailboxes, folders and labels in sections down a column: a press, an arrow or Enter opens one, and the one open is lit. A box with mail unread stands out with its count.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MailboxList {
|
||||
id: ElementId,
|
||||
sections: Vec<(SharedString, Vec<Mailbox>)>,
|
||||
open: Option<SharedString>,
|
||||
on_open: Option<OnValue>,
|
||||
}
|
||||
|
||||
impl MailboxList {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
sections: Vec::new(),
|
||||
open: None,
|
||||
on_open: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A section under `title`; a title names one section, and a section holds a box.
|
||||
pub fn section(
|
||||
mut self,
|
||||
title: impl Into<SharedString>,
|
||||
boxes: impl IntoIterator<Item = Mailbox>,
|
||||
) -> Self {
|
||||
let title = title.into();
|
||||
let boxes: Vec<Mailbox> = boxes.into_iter().collect();
|
||||
assert!(!boxes.is_empty(), "section {title} holds no mailbox");
|
||||
let named = self.sections.iter().any(|(other, _)| *other == title);
|
||||
assert!(!named, "section {title} twice");
|
||||
for (ix, mailbox) in boxes.iter().enumerate() {
|
||||
let seen = self
|
||||
.sections
|
||||
.iter()
|
||||
.flat_map(|(_, boxes)| boxes)
|
||||
.chain(&boxes[..ix])
|
||||
.any(|other| other.key == mailbox.key);
|
||||
assert!(!seen, "mailbox {} twice", mailbox.key);
|
||||
}
|
||||
self.sections.push((title, boxes));
|
||||
self
|
||||
}
|
||||
|
||||
/// The box open now, by key.
|
||||
pub fn open(mut self, key: impl Into<SharedString>) -> Self {
|
||||
self.open = Some(key.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the key of the box to open.
|
||||
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 MailboxList {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
if let Some(open) = &self.open {
|
||||
let listed = self
|
||||
.sections
|
||||
.iter()
|
||||
.flat_map(|(_, boxes)| boxes)
|
||||
.any(|mailbox| mailbox.key == *open);
|
||||
assert!(listed, "no mailbox {open}");
|
||||
}
|
||||
let open: OnValue = {
|
||||
let (id, on_open) = (self.id.clone(), self.on_open.clone());
|
||||
Rc::new(move |key, window, cx| {
|
||||
log::info!("mailbox list {id}: open {key}");
|
||||
if let Some(on_open) = &on_open {
|
||||
on_open(key, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let sections = self.sections.iter().fold(
|
||||
Sections::new(self.id.clone()),
|
||||
|sections, (title, boxes)| {
|
||||
let rows = boxes
|
||||
.iter()
|
||||
.map(|mailbox| (mailbox.key.clone(), mailbox.item(&self.id, cx)))
|
||||
.collect();
|
||||
sections.section(title.clone(), rows)
|
||||
},
|
||||
);
|
||||
div()
|
||||
.debug_selector(|| "mailbox-list".into())
|
||||
.w_full()
|
||||
.child(
|
||||
sections
|
||||
.selected(self.open)
|
||||
.on_select(open.clone())
|
||||
.on_activate(open),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||
};
|
||||
use jiff::{Timestamp, tz::TimeZone};
|
||||
|
||||
use crate::{
|
||||
forms::{OnFlag, OnValues},
|
||||
lists::{ListItem, SelectableList},
|
||||
primitives::{FocusRing, Icon, IconName, Tooltip},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
typography::format,
|
||||
};
|
||||
|
||||
type OnStar = Rc<dyn Fn(&SharedString, bool, &mut Window, &mut App)>;
|
||||
|
||||
/// A message as a list shows it: who sent it, its subject and first words, when it came, and its flags.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Mail {
|
||||
pub key: SharedString,
|
||||
pub from: SharedString,
|
||||
pub subject: SharedString,
|
||||
pub snippet: Option<SharedString>,
|
||||
pub at: Timestamp,
|
||||
pub unread: bool,
|
||||
pub starred: bool,
|
||||
pub attachments: usize,
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
pub fn new(
|
||||
key: impl Into<SharedString>,
|
||||
from: impl Into<SharedString>,
|
||||
subject: impl Into<SharedString>,
|
||||
at: Timestamp,
|
||||
) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
from: from.into(),
|
||||
subject: subject.into(),
|
||||
snippet: None,
|
||||
at,
|
||||
unread: false,
|
||||
starred: false,
|
||||
attachments: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Its first words.
|
||||
pub fn snippet(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.snippet = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn unread(mut self, unread: bool) -> Self {
|
||||
self.unread = unread;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn starred(mut self, starred: bool) -> Self {
|
||||
self.starred = starred;
|
||||
self
|
||||
}
|
||||
|
||||
/// How many files it carries.
|
||||
pub fn attachments(mut self, count: usize) -> Self {
|
||||
self.attachments = count;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// When a message came, as a list says it: the time today, the weekday within the week, the date within the year, and the year past that.
|
||||
fn when(at: Timestamp, now: Timestamp, zone: &TimeZone) -> String {
|
||||
let (at, now) = (at.to_zoned(zone.clone()), now.to_zoned(zone.clone()));
|
||||
let days = at
|
||||
.date()
|
||||
.until(now.date())
|
||||
.expect("two dates have a span")
|
||||
.get_days();
|
||||
let pattern = match days {
|
||||
0 => "%H:%M",
|
||||
1..=6 => "%a",
|
||||
_ if at.year() == now.year() => "%b %-d",
|
||||
_ => "%b %-d, %Y",
|
||||
};
|
||||
at.strftime(pattern).to_string()
|
||||
}
|
||||
|
||||
/// A message as a row: its sender, strong beside a dot while unread, the subject and first words under it, when it came, a paperclip when it carries files, and its star. With a handler the star toggles, its press kept off the row.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MailItem {
|
||||
id: ElementId,
|
||||
mail: Mail,
|
||||
zone: Option<TimeZone>,
|
||||
on_star: Option<OnFlag>,
|
||||
}
|
||||
|
||||
impl MailItem {
|
||||
pub fn new(id: impl Into<ElementId>, mail: Mail) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
mail,
|
||||
zone: None,
|
||||
on_star: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The zone its date reads in; the system's otherwise.
|
||||
pub fn zone(mut self, zone: TimeZone) -> Self {
|
||||
self.zone = Some(zone);
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the star's new state.
|
||||
pub fn on_star(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_star = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
fn item(self, cx: &App) -> ListItem {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let mail = self.mail;
|
||||
let zone = self
|
||||
.zone
|
||||
.unwrap_or_else(|| format::system_zone("mail item"));
|
||||
let date = when(mail.at, Timestamp::now(), &zone);
|
||||
let dot = div()
|
||||
.size(theme.icon_size(IconSize::Xs))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div()
|
||||
.size(theme.status_dot())
|
||||
.rounded_full()
|
||||
.when(mail.unread, |dot| dot.bg(colors.accent)),
|
||||
);
|
||||
let starred = mail.starred;
|
||||
let tint = if starred {
|
||||
colors.warning
|
||||
} else {
|
||||
colors.fg_subtle
|
||||
};
|
||||
let mark = |icon| Icon::new(icon).size(IconSize::Sm).color(tint);
|
||||
let star: Option<AnyElement> = match self.on_star {
|
||||
Some(set) => {
|
||||
let key = mail.key.clone();
|
||||
let named = key.clone();
|
||||
Some(
|
||||
div()
|
||||
.id((self.id.clone(), "star"))
|
||||
.debug_selector(move || format!("star {named}"))
|
||||
.p_0p5()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.border_1()
|
||||
.border_color(gpui::transparent_black())
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.cursor_pointer()
|
||||
.hover(|style| style.bg(colors.hover))
|
||||
.tooltip(Tooltip::text(if starred { "Unstar" } else { "Star" }))
|
||||
.on_mouse_down(MouseButton::Left, |_, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("mail {key}: star {}", !starred);
|
||||
set(!starred, window, cx)
|
||||
})
|
||||
.child(mark(IconName::Star))
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
None => starred.then(|| mark(IconName::Star).into_any_element()),
|
||||
};
|
||||
let clip = (mail.attachments > 0).then(|| {
|
||||
Icon::new(IconName::Paperclip)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_subtle)
|
||||
});
|
||||
let trail = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_end()
|
||||
.gap_1()
|
||||
.child(div().text_size(theme.text_size(TextSize::Xs)).child(date))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.children(clip)
|
||||
.children(star),
|
||||
);
|
||||
let row = ListItem::new(self.id, mail.from)
|
||||
.leading(dot)
|
||||
.strong(mail.unread)
|
||||
.description(mail.subject)
|
||||
.trailing(trail);
|
||||
match mail.snippet {
|
||||
Some(snippet) => row.detail(snippet),
|
||||
None => row,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MailItem {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
self.item(cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Messages in a list, in the order given: a press or an arrow selects one, and Command or Shift with a press takes several; Space toggles. Unread ones stand out.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MailList {
|
||||
id: ElementId,
|
||||
mails: Vec<Mail>,
|
||||
selected: Vec<SharedString>,
|
||||
zone: Option<TimeZone>,
|
||||
on_select: Option<OnValues>,
|
||||
on_star: Option<OnStar>,
|
||||
}
|
||||
|
||||
impl MailList {
|
||||
pub fn new(id: impl Into<ElementId>, mails: impl IntoIterator<Item = Mail>) -> Self {
|
||||
let mails: Vec<Mail> = mails.into_iter().collect();
|
||||
for (ix, mail) in mails.iter().enumerate() {
|
||||
let twice = mails[..ix].iter().any(|other| other.key == mail.key);
|
||||
assert!(!twice, "mail {} twice", mail.key);
|
||||
}
|
||||
Self {
|
||||
id: id.into(),
|
||||
mails,
|
||||
selected: Vec::new(),
|
||||
zone: None,
|
||||
on_select: None,
|
||||
on_star: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The messages selected now, by key.
|
||||
pub fn selected(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
|
||||
self.selected = keys.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// The zone dates read in; the system's otherwise.
|
||||
pub fn zone(mut self, zone: TimeZone) -> Self {
|
||||
self.zone = Some(zone);
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the keys selected, in list order.
|
||||
pub fn on_select(
|
||||
mut self,
|
||||
handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_select = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets a message's key and its star's new state.
|
||||
pub fn on_star(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_star = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MailList {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
for key in &self.selected {
|
||||
let listed = self.mails.iter().any(|mail| mail.key == *key);
|
||||
assert!(listed, "no mail {key}");
|
||||
}
|
||||
let theme = cx.theme();
|
||||
if self.mails.is_empty() {
|
||||
return div()
|
||||
.debug_selector(|| "mail-none".into())
|
||||
.w_full()
|
||||
.px_3()
|
||||
.py_4()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child("No mail here.")
|
||||
.into_any_element();
|
||||
}
|
||||
let zone = self
|
||||
.zone
|
||||
.unwrap_or_else(|| format::system_zone("mail list"));
|
||||
let list = self.mails.into_iter().fold(
|
||||
SelectableList::new((self.id.clone(), "mails")).multiple(),
|
||||
|list, mail| {
|
||||
let key = mail.key.clone();
|
||||
let item = MailItem::new((self.id.clone(), format!("mail-{key}")), mail)
|
||||
.zone(zone.clone());
|
||||
let item = match self.on_star.clone() {
|
||||
Some(star) => {
|
||||
let starred = key.clone();
|
||||
item.on_star(move |on, window, cx| star(&starred, on, window, cx))
|
||||
}
|
||||
None => item,
|
||||
};
|
||||
list.row(key, item.item(cx))
|
||||
},
|
||||
);
|
||||
let (id, on_select) = (self.id.clone(), self.on_select);
|
||||
div()
|
||||
.debug_selector(|| "mail-list".into())
|
||||
.w_full()
|
||||
.child(
|
||||
list.selected(self.selected)
|
||||
.on_change(move |keys, window, cx| {
|
||||
log::info!("mail list {id}: select {keys:?}");
|
||||
if let Some(on_select) = &on_select {
|
||||
on_select(keys, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use jiff::civil::date;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_date_reads_as_the_time_the_weekday_the_date_or_the_year() {
|
||||
let zone = TimeZone::UTC;
|
||||
let at = |y, m, d, h| {
|
||||
date(y, m, d)
|
||||
.at(h, 5, 0, 0)
|
||||
.to_zoned(zone.clone())
|
||||
.expect("a UTC time")
|
||||
.timestamp()
|
||||
};
|
||||
let now = at(2026, 9, 26, 12);
|
||||
assert_eq!(when(at(2026, 9, 26, 9), now, &zone), "09:05");
|
||||
assert_eq!(when(at(2026, 9, 25, 23), now, &zone), "Fri");
|
||||
assert_eq!(when(at(2026, 9, 20, 9), now, &zone), "Sun");
|
||||
assert_eq!(when(at(2026, 9, 19, 9), now, &zone), "Sep 19");
|
||||
assert_eq!(when(at(2025, 12, 31, 9), now, &zone), "Dec 31, 2025");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mod boxes;
|
||||
mod list;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
|
||||
pub use boxes::{Mailbox, MailboxList};
|
||||
pub use list::{Mail, MailItem, MailList};
|
||||
@@ -0,0 +1,206 @@
|
||||
use gpui::{
|
||||
Context, Entity, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, ParentElement,
|
||||
Render, Styled, TestAppContext, VisualTestContext, Window, div, px,
|
||||
};
|
||||
use jiff::{Timestamp, civil::date, tz::TimeZone};
|
||||
|
||||
use super::{Mail, MailList, Mailbox, MailboxList};
|
||||
use crate::{
|
||||
primitives::{FocusNext, IconName},
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
fn setup(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||
});
|
||||
}
|
||||
|
||||
fn settle(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
}
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(key);
|
||||
settle(cx);
|
||||
cx.simulate_event(KeyUpEvent {
|
||||
keystroke: Keystroke::parse(key).expect("a key"),
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
/// Moves focus to the `nth` Tab stop from none.
|
||||
fn tab_to(nth: usize, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, _| {
|
||||
window.blur();
|
||||
for _ in 0..nth {
|
||||
window.focus_next();
|
||||
}
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
/// A view that shows one mail part and keeps what it heard.
|
||||
struct Mailing {
|
||||
part: fn(Entity<Mailing>) -> gpui::AnyElement,
|
||||
heard: Vec<String>,
|
||||
}
|
||||
|
||||
impl Render for Mailing {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().w(px(360.0)).child((self.part)(cx.entity()))
|
||||
}
|
||||
}
|
||||
|
||||
fn mailing(
|
||||
part: fn(Entity<Mailing>) -> gpui::AnyElement,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Mailing>, &mut VisualTestContext) {
|
||||
setup(cx);
|
||||
let (host, cx) = cx.add_window_view(|_, _| Mailing {
|
||||
part,
|
||||
heard: Vec::new(),
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
fn note(owner: &Entity<Mailing>, what: String, cx: &mut gpui::App) {
|
||||
owner.update(cx, |host, cx| {
|
||||
host.heard.push(what);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn heard(host: &Entity<Mailing>, cx: &mut VisualTestContext) -> Vec<String> {
|
||||
host.read_with(cx, |host, _| host.heard.clone())
|
||||
}
|
||||
|
||||
fn boxes(owner: Entity<Mailing>) -> gpui::AnyElement {
|
||||
MailboxList::new("boxes")
|
||||
.section(
|
||||
"Mailboxes",
|
||||
[
|
||||
Mailbox::new("inbox", "Inbox", IconName::Inbox).unread(3),
|
||||
Mailbox::new("starred", "Starred", IconName::Star),
|
||||
Mailbox::new("sent", "Sent", IconName::Send),
|
||||
],
|
||||
)
|
||||
.section(
|
||||
"Labels",
|
||||
[
|
||||
Mailbox::label("atrium", "Atrium", 0),
|
||||
Mailbox::label("clients", "Clients", 3),
|
||||
],
|
||||
)
|
||||
.open("inbox")
|
||||
.on_open(move |key, _, cx| note(&owner, format!("open {key}"), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_arrow_or_enter_opens_a_box(cx: &mut TestAppContext) {
|
||||
let (host, cx) = mailing(boxes, cx);
|
||||
tab_to(1, cx);
|
||||
press("down", cx);
|
||||
tab_to(2, cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(heard(&host, cx), ["open starred", "open atrium"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "mailbox inbox twice")]
|
||||
fn a_box_is_listed_once() {
|
||||
let _ = MailboxList::new("boxes")
|
||||
.section("A", [Mailbox::new("inbox", "Inbox", IconName::Inbox)])
|
||||
.section("B", [Mailbox::label("inbox", "Inbox again", 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "section Labels holds no mailbox")]
|
||||
fn a_section_holds_a_box() {
|
||||
let _ = MailboxList::new("boxes").section("Labels", Vec::new());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[should_panic(expected = "no chart hue 9")]
|
||||
fn a_label_takes_one_of_the_chart_hues(cx: &mut TestAppContext) {
|
||||
let _ = mailing(
|
||||
|_| {
|
||||
MailboxList::new("boxes")
|
||||
.section("Labels", [Mailbox::label("odd", "Odd", 9)])
|
||||
.into_any_element()
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn at(minute: i8) -> Timestamp {
|
||||
date(2026, 9, 26)
|
||||
.at(9, minute, 0, 0)
|
||||
.to_zoned(TimeZone::UTC)
|
||||
.expect("a UTC time")
|
||||
.timestamp()
|
||||
}
|
||||
|
||||
fn mails(owner: Entity<Mailing>) -> gpui::AnyElement {
|
||||
let (select, star) = (owner.clone(), owner);
|
||||
MailList::new(
|
||||
"mails",
|
||||
[
|
||||
Mail::new("m1", "Ana Lima", "Friday's review", at(40)).unread(true),
|
||||
Mail::new("m2", "Ben Ito", "Samples", at(20)).attachments(2),
|
||||
Mail::new("m3", "Chloé Martin", "The brief", at(5)).starred(true),
|
||||
],
|
||||
)
|
||||
.selected(["m1"])
|
||||
.zone(TimeZone::UTC)
|
||||
.on_select(move |keys, _, cx| {
|
||||
let keys: Vec<&str> = keys.iter().map(|key| key.as_ref()).collect();
|
||||
note(&select, format!("select {}", keys.join(" ")), cx)
|
||||
})
|
||||
.on_star(move |key, on, _, cx| note(&star, format!("star {key} {on}"), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_arrow_moves_the_selection_down_the_list(cx: &mut TestAppContext) {
|
||||
let (host, cx) = mailing(mails, cx);
|
||||
tab_to(1, cx);
|
||||
press("down", cx);
|
||||
assert_eq!(heard(&host, cx), ["select m2"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_star_toggles_and_leaves_the_selection(cx: &mut TestAppContext) {
|
||||
let (host, cx) = mailing(mails, cx);
|
||||
let star = cx.debug_bounds("star m3").expect("the third star draws");
|
||||
cx.simulate_click(star.center(), Modifiers::none());
|
||||
settle(cx);
|
||||
assert_eq!(heard(&host, cx), ["star m3 false"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn no_mail_says_so_and_takes_no_tab_stop(cx: &mut TestAppContext) {
|
||||
let (_, cx) = mailing(
|
||||
|_| MailList::new("mails", Vec::new()).into_any_element(),
|
||||
cx,
|
||||
);
|
||||
assert!(cx.debug_bounds("mail-none").is_some());
|
||||
tab_to(1, cx);
|
||||
assert!(cx.update(|window, cx| window.focused(cx).is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "mail m1 twice")]
|
||||
fn a_mail_is_listed_once() {
|
||||
let mail = Mail::new("m1", "Ana", "Hi", Timestamp::UNIX_EPOCH);
|
||||
let _ = MailList::new("mails", [mail.clone(), mail]);
|
||||
}
|
||||
+30
-54
@@ -1,16 +1,16 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, ImageSource, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled, Window, div, prelude::*,
|
||||
App, ElementId, ImageSource, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
data_display::{Avatar, CountBadge, Presence},
|
||||
forms::OnValue,
|
||||
lists::{ListItem, SelectableList},
|
||||
lists::{ListItem, Sections},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, AvatarSize, IconSize, TextSize},
|
||||
theme::{ActiveTheme, AvatarSize, IconSize},
|
||||
};
|
||||
|
||||
/// What unread messages add at a row's end: a count, and a pin before it when pinned.
|
||||
@@ -247,63 +247,39 @@ impl RenderOnce for ChannelList {
|
||||
.any(|(key, _)| key == open);
|
||||
assert!(listed, "no chat {open}");
|
||||
}
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let sections: Vec<_> = self
|
||||
.sections
|
||||
.into_iter()
|
||||
.map(|(title, rows)| {
|
||||
let lit = self
|
||||
.open
|
||||
.clone()
|
||||
.filter(|open| rows.iter().any(|(key, _)| key == open));
|
||||
let list = rows.into_iter().fold(
|
||||
SelectableList::new((self.id.clone(), format!("section-{title}"))),
|
||||
|list, (key, row)| {
|
||||
let open: OnValue = {
|
||||
let (id, on_open) = (self.id.clone(), self.on_open.clone());
|
||||
Rc::new(move |key, window, cx| {
|
||||
log::info!("channel list {id:?}: open {key}");
|
||||
if let Some(on_open) = &on_open {
|
||||
on_open(key, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let sections = self.sections.into_iter().fold(
|
||||
Sections::new(self.id.clone()),
|
||||
|sections, (title, rows)| {
|
||||
let rows = rows
|
||||
.into_iter()
|
||||
.map(|(key, row)| {
|
||||
let item = match row {
|
||||
ChatRow::Channel(item) => item.item(cx),
|
||||
ChatRow::Direct(item) => item.item(cx),
|
||||
};
|
||||
list.row(key, item)
|
||||
},
|
||||
);
|
||||
let open: OnValue = {
|
||||
let (id, on_open) = (self.id.clone(), self.on_open.clone());
|
||||
Rc::new(move |key, window, cx| {
|
||||
log::info!("channel list {id:?}: open {key}");
|
||||
if let Some(on_open) = &on_open {
|
||||
on_open(key, window, cx);
|
||||
}
|
||||
(key, item)
|
||||
})
|
||||
};
|
||||
let picked = open.clone();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(title),
|
||||
)
|
||||
.child(
|
||||
list.selected(lit)
|
||||
.on_change(move |keys, window, cx| {
|
||||
picked(keys.first().expect("a pick names a chat"), window, cx)
|
||||
})
|
||||
.on_activate(move |key, window, cx| open(key, window, cx)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
.collect();
|
||||
sections.section(title, rows)
|
||||
},
|
||||
);
|
||||
div()
|
||||
.debug_selector(|| "channel-list".into())
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.children(sections)
|
||||
.child(
|
||||
sections
|
||||
.selected(self.open)
|
||||
.on_select(open.clone())
|
||||
.on_activate(open),
|
||||
)
|
||||
}
|
||||
}
|
||||
+66
-88
@@ -1,14 +1,14 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, ImageSource, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled, Window, div, prelude::*,
|
||||
App, ElementId, ImageSource, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
data_display::{Avatar, Badge, Presence},
|
||||
forms::OnValue,
|
||||
lists::{ListItem, SelectableList},
|
||||
lists::{ListItem, Sections},
|
||||
theme::{ActiveTheme, AvatarSize, TextSize},
|
||||
};
|
||||
|
||||
@@ -128,87 +128,61 @@ impl RenderOnce for MemberList {
|
||||
}
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let groups: Vec<_> = grouped(&self.members)
|
||||
.into_iter()
|
||||
.map(|(title, group)| {
|
||||
let count = group.len();
|
||||
let lit = self
|
||||
.selected
|
||||
.clone()
|
||||
.filter(|key| group.iter().any(|member| member.key == *key));
|
||||
let list = group.into_iter().fold(
|
||||
SelectableList::new((self.id.clone(), format!("group-{title}"))),
|
||||
|list, member| {
|
||||
let avatar = Avatar::new(
|
||||
(self.id.clone(), format!("avatar-{}", member.key)),
|
||||
member.name.clone(),
|
||||
)
|
||||
.size(AvatarSize::Sm)
|
||||
.presence(member.presence);
|
||||
let avatar = match member.picture.clone() {
|
||||
Some(picture) => avatar.image(picture),
|
||||
None => avatar,
|
||||
};
|
||||
let row = ListItem::new(
|
||||
(self.id.clone(), format!("member-{}", member.key)),
|
||||
member.name.clone(),
|
||||
)
|
||||
.leading(avatar)
|
||||
.quiet(member.presence == Presence::Offline);
|
||||
let row = match member.status.clone() {
|
||||
Some(status) => row.description(status),
|
||||
None => row,
|
||||
};
|
||||
let row = match member.role.clone() {
|
||||
Some(role) => row.trailing(Badge::new(role)),
|
||||
None => row,
|
||||
};
|
||||
list.row(member.key.clone(), row)
|
||||
},
|
||||
);
|
||||
let (id, select, activate) = (
|
||||
self.id.clone(),
|
||||
self.on_select.clone(),
|
||||
self.on_activate.clone(),
|
||||
);
|
||||
let acted = id.clone();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(title),
|
||||
)
|
||||
.child(div().text_color(colors.fg_subtle).child(count.to_string())),
|
||||
)
|
||||
.child(
|
||||
list.selected(lit)
|
||||
.on_change(move |keys, window, cx| {
|
||||
let key = keys.first().expect("a pick names a member");
|
||||
log::info!("member list {id}: select {key}");
|
||||
if let Some(select) = &select {
|
||||
select(key, window, cx);
|
||||
}
|
||||
})
|
||||
.on_activate(move |key, window, cx| {
|
||||
log::info!("member list {acted}: activate {key}");
|
||||
if let Some(activate) = &activate {
|
||||
activate(key, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let row = |member: &Member| {
|
||||
let avatar = Avatar::new(
|
||||
(self.id.clone(), format!("avatar-{}", member.key)),
|
||||
member.name.clone(),
|
||||
)
|
||||
.size(AvatarSize::Sm)
|
||||
.presence(member.presence);
|
||||
let avatar = match member.picture.clone() {
|
||||
Some(picture) => avatar.image(picture),
|
||||
None => avatar,
|
||||
};
|
||||
let row = ListItem::new(
|
||||
(self.id.clone(), format!("member-{}", member.key)),
|
||||
member.name.clone(),
|
||||
)
|
||||
.leading(avatar)
|
||||
.quiet(member.presence == Presence::Offline);
|
||||
let row = match member.status.clone() {
|
||||
Some(status) => row.description(status),
|
||||
None => row,
|
||||
};
|
||||
let row = match member.role.clone() {
|
||||
Some(role) => row.trailing(Badge::new(role)),
|
||||
None => row,
|
||||
};
|
||||
(member.key.clone(), row)
|
||||
};
|
||||
let groups = grouped(&self.members).into_iter().fold(
|
||||
Sections::new(self.id.clone()).counted(),
|
||||
|groups, (title, group)| {
|
||||
groups.section(title.into(), group.into_iter().map(row).collect())
|
||||
},
|
||||
);
|
||||
let (select, activate): (OnValue, OnValue) = {
|
||||
let (id, acted, on_select, on_activate) = (
|
||||
self.id.clone(),
|
||||
self.id.clone(),
|
||||
self.on_select.clone(),
|
||||
self.on_activate.clone(),
|
||||
);
|
||||
(
|
||||
Rc::new(move |key, window, cx| {
|
||||
log::info!("member list {id}: select {key}");
|
||||
if let Some(on_select) = &on_select {
|
||||
on_select(key, window, cx);
|
||||
}
|
||||
}),
|
||||
Rc::new(move |key, window, cx| {
|
||||
log::info!("member list {acted}: activate {key}");
|
||||
if let Some(on_activate) = &on_activate {
|
||||
on_activate(key, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
};
|
||||
let empty = self.members.is_empty().then(|| {
|
||||
div()
|
||||
.px_3()
|
||||
@@ -220,11 +194,15 @@ impl RenderOnce for MemberList {
|
||||
div()
|
||||
.debug_selector(|| "member-list".into())
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.children(empty)
|
||||
.children(groups)
|
||||
.when(!self.members.is_empty(), |list| {
|
||||
list.child(
|
||||
groups
|
||||
.selected(self.selected)
|
||||
.on_select(select)
|
||||
.on_activate(activate),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +218,7 @@ icons! {
|
||||
Music => "music",
|
||||
Network => "network",
|
||||
NotebookPen => "notebook-pen",
|
||||
OctagonAlert => "octagon-alert",
|
||||
Package => "package",
|
||||
Palette => "palette",
|
||||
PanelBottom => "panel-bottom",
|
||||
|
||||
+17
-8
@@ -360,21 +360,30 @@
|
||||
- [x] StickerPicker
|
||||
- [x] GifPicker — host images
|
||||
|
||||
## T29 Mail — `src/mail`
|
||||
## T29a Mail · Boxes and lists — `src/mail`
|
||||
|
||||
- [x] MailboxList / FolderList
|
||||
- [x] MailList
|
||||
- [x] MailItem
|
||||
- [x] InboxZeroState → `feedback::EmptyState`
|
||||
|
||||
## T29b Mail · Reading
|
||||
|
||||
- [ ] MailboxList / FolderList
|
||||
- [ ] MailList
|
||||
- [ ] MailItem
|
||||
- [ ] MailReader
|
||||
- [ ] MailComposer
|
||||
- [ ] RecipientInput
|
||||
- [ ] MailThreadView
|
||||
- [ ] QuotedText
|
||||
|
||||
## T29c Mail · Writing
|
||||
|
||||
- [ ] MailComposer
|
||||
- [ ] RecipientInput
|
||||
- [ ] SignatureEditor
|
||||
- [ ] ScheduleSend
|
||||
|
||||
## T29d Mail · Sorting
|
||||
|
||||
- [ ] LabelPicker
|
||||
- [ ] SnoozePicker
|
||||
- [ ] ScheduleSend
|
||||
- [ ] InboxZeroState
|
||||
|
||||
## T30 Calendar — `src/calendar`
|
||||
|
||||
|
||||
Reference in new issue
Block a user