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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-26 23:35:04 -04:00
1 parent 6f599803ce
commit b6b1224c27
17 files changed
+1244 -153

No files matched your search

+13
View File
@@ -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| {
+2
View File
@@ -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;
+120
View File
@@ -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)
}
}