From 7d2056792132ad7bd20bbec2ec8c0c67705e81ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 23:35:04 -0400 Subject: [PATCH] =?UTF-8?q?Mail=20=C2=B7=20Boxes=20and=20lists:=20mailboxe?= =?UTF-8?q?s,=20folders=20and=20labels,=20the=20mail=20list,=20inbox=20zer?= =?UTF-8?q?o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 2 +- TASKS.md | 9 +- assets/icons/octagon-alert.svg | 17 ++ examples/gallery/pages/mail/mod.rs | 204 +++++++++++++++++ examples/gallery/pages/mod.rs | 2 + src/lib.rs | 1 + src/lists/item.rs | 13 ++ src/lists/mod.rs | 2 + src/lists/sections.rs | 120 ++++++++++ src/mail/boxes.rs | 197 ++++++++++++++++ src/mail/list.rs | 352 +++++++++++++++++++++++++++++ src/mail/mod.rs | 8 + src/mail/tests/mod.rs | 206 +++++++++++++++++ src/messaging/channels.rs | 84 +++---- src/messaging/members.rs | 154 ++++++------- src/primitives/icon.rs | 1 + tasks/ch21-30.md | 25 +- 17 files changed, 1244 insertions(+), 153 deletions(-) create mode 100644 assets/icons/octagon-alert.svg create mode 100644 examples/gallery/pages/mail/mod.rs create mode 100644 src/lists/sections.rs create mode 100644 src/mail/boxes.rs create mode 100644 src/mail/list.rs create mode 100644 src/mail/mod.rs create mode 100644 src/mail/tests/mod.rs diff --git a/AGENTS.md b/AGENTS.md index 743f213..e39f9a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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>`. A chapter shares one alias per shape. diff --git a/TASKS.md b/TASKS.md index 94fa4fd..5a12a33 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | | diff --git a/assets/icons/octagon-alert.svg b/assets/icons/octagon-alert.svg new file mode 100644 index 0000000..1759399 --- /dev/null +++ b/assets/icons/octagon-alert.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/examples/gallery/pages/mail/mod.rs b/examples/gallery/pages/mail/mod.rs new file mode 100644 index 0000000..f70d92a --- /dev/null +++ b/examples/gallery/pages/mail/mod.rs @@ -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 { + 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::>(); + 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() +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index 8b98345..c79ccea 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -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, ]; diff --git a/src/lib.rs b/src/lib.rs index e6d8aca..808d473 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/lists/item.rs b/src/lists/item.rs index cdb3b6e..77f010b 100644 --- a/src/lists/item.rs +++ b/src/lists/item.rs @@ -20,6 +20,7 @@ pub struct ListItem { id: ElementId, title: SharedString, description: Option, + detail: Option, leading: Option, trailing: Option, 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) -> 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| { diff --git a/src/lists/mod.rs b/src/lists/mod.rs index 60ae88f..eb00d84 100644 --- a/src/lists/mod.rs +++ b/src/lists/mod.rs @@ -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; diff --git a/src/lists/sections.rs b/src/lists/sections.rs new file mode 100644 index 0000000..8d7757e --- /dev/null +++ b/src/lists/sections.rs @@ -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, + on_select: Option, + on_activate: Option, +} + +impl Sections { + pub(crate) fn new(id: impl Into) -> 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) -> 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) + } +} diff --git a/src/mail/boxes.rs b/src/mail/boxes.rs new file mode 100644 index 0000000..e82f88b --- /dev/null +++ b/src/mail/boxes.rs @@ -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, + name: impl Into, + 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, name: impl Into, 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)>, + open: Option, + on_open: Option, +} + +impl MailboxList { + pub fn new(id: impl Into) -> 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, + boxes: impl IntoIterator, + ) -> Self { + let title = title.into(); + let boxes: Vec = 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) -> 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), + ) + } +} diff --git a/src/mail/list.rs b/src/mail/list.rs new file mode 100644 index 0000000..2513274 --- /dev/null +++ b/src/mail/list.rs @@ -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; + +/// 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, + pub at: Timestamp, + pub unread: bool, + pub starred: bool, + pub attachments: usize, +} + +impl Mail { + pub fn new( + key: impl Into, + from: impl Into, + subject: impl Into, + 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) -> 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, + on_star: Option, +} + +impl MailItem { + pub fn new(id: impl Into, 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 = 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, + selected: Vec, + zone: Option, + on_select: Option, + on_star: Option, +} + +impl MailList { + pub fn new(id: impl Into, mails: impl IntoIterator) -> Self { + let mails: Vec = 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>) -> 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"); + } +} diff --git a/src/mail/mod.rs b/src/mail/mod.rs new file mode 100644 index 0000000..81f5296 --- /dev/null +++ b/src/mail/mod.rs @@ -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}; diff --git a/src/mail/tests/mod.rs b/src/mail/tests/mod.rs new file mode 100644 index 0000000..91c0626 --- /dev/null +++ b/src/mail/tests/mod.rs @@ -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) -> gpui::AnyElement, + heard: Vec, +} + +impl Render for Mailing { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div().w(px(360.0)).child((self.part)(cx.entity())) + } +} + +fn mailing( + part: fn(Entity) -> gpui::AnyElement, + cx: &mut TestAppContext, +) -> (Entity, &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, what: String, cx: &mut gpui::App) { + owner.update(cx, |host, cx| { + host.heard.push(what); + cx.notify(); + }); +} + +fn heard(host: &Entity, cx: &mut VisualTestContext) -> Vec { + host.read_with(cx, |host, _| host.heard.clone()) +} + +fn boxes(owner: Entity) -> 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) -> 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]); +} diff --git a/src/messaging/channels.rs b/src/messaging/channels.rs index 3252149..0221c8b 100644 --- a/src/messaging/channels.rs +++ b/src/messaging/channels.rs @@ -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), + ) } } diff --git a/src/messaging/members.rs b/src/messaging/members.rs index 4a4796d..3312d58 100644 --- a/src/messaging/members.rs +++ b/src/messaging/members.rs @@ -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), + ) + }) } } diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index 0e4fac2..e4121e9 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -218,6 +218,7 @@ icons! { Music => "music", Network => "network", NotebookPen => "notebook-pen", + OctagonAlert => "octagon-alert", Package => "package", Palette => "palette", PanelBottom => "panel-bottom", diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index f63aaa4..d21c8ae 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -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`