Mail · a folded message keeps focus as it opens, and gives way at 280px

Each message in a thread holds one focus handle, a Tab stop while
folded and tracked on the letter once open, so a keyboard open keeps
focus on the message and Tab goes on to its Reply. A press on a fold
leaves focus alone. A fold's name now gives way with its words, so a
long name beside a long date stays inside the row.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 00:07:27 -04:00
1 parent 70e18af092
commit 1ad72bc7aa
3 files changed
+120 -17

No files matched your search

+1 -1
View File
@@ -74,7 +74,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- gpui runs the click of every pressed element under the pointer, a row's with its button's. A pressable row that holds buttons stops their press at their box on mouse down (`files::transfer`), as `overlays::media_button` stops its own.
- gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens.
- gpui clicks a focused element when Enter or Space is released. An overlay that hands focus back picks on release too; a pick on press returns focus first, and the release clicks the opener again. An edit in place that hands focus back keeps its text on Enter's release (`forms::InlineEdit`). The press itself bubbles, so a container that takes Space leaves a focused button its own: `overlays::media_button` stops it, and a button in a `SelectableList` row stops Space and Enter at its box (`mail::MailItem`'s star, `messaging::PinnedMessages`' Unpin).
- A focus handle a component hands back lives across all its modes: take it with `tab_stop` before any early return, or keyed state drops it while the other mode draws and the handed-back focus lands nowhere.
- A focus handle a component hands back lives across all its modes: take it with `tab_stop` before any early return, or keyed state drops it while the other mode draws and the handed-back focus lands nowhere. A thread's message keeps one handle folded and open, a Tab stop only while folded (`mail::MailThreadView`).
- A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. Its close runs on every way out, so a dialog with two answers marks the chosen one before it closes (`messaging::IncomingCallDialog`). AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar. A dialog's card fits the window, a title bar's height clear at each end; its body scrolls and its actions stay.
- Controls on a dark layer over pictures are `overlays::media_button`, light icons that dim when they have nowhere to go, for the lightbox and a video player's bar alike.
- A component's letter keys step aside while Command or Control is held, so the app's shortcuts reach the app (`media::ImageAnnotator`, `VideoPlayer`, `VideoThumbnailStrip`).
+87 -4
View File
@@ -1,8 +1,11 @@
use gpui::{Entity, IntoElement, ParentElement, TestAppContext, div};
use jiff::{Timestamp, tz::TimeZone};
use gpui::{Entity, IntoElement, Modifiers, ParentElement, Styled, TestAppContext, div, px};
use jiff::{SignedDuration, Timestamp, tz::TimeZone};
use super::{Mailing, heard, mailing, note, press, tab_to};
use crate::mail::{Contact, MailReader, MailThreadView, Message, QuotedText};
use super::{Mailing, heard, mailing, note, press, settle, tab_to};
use crate::{
buttons::Button,
mail::{Contact, MailReader, MailThreadView, Message, QuotedText},
};
fn ana() -> Contact {
Contact::new("Ana Lima", "ana@atrium.studio")
@@ -116,3 +119,83 @@ fn quoted_words_stay_folded_until_asked(cx: &mut TestAppContext) {
press("space", cx);
assert!(cx.debug_bounds("quoted quote").is_some());
}
/// A button, then a thread of three whose Reply the host hears.
fn answered(owner: Entity<Mailing>) -> gpui::AnyElement {
let (before, reply) = (owner.clone(), owner);
let message = |key: &'static str| {
Message::new(key, ana(), Timestamp::UNIX_EPOCH)
.to([ben()])
.snippet("Earlier words")
};
div()
.child(
Button::new("before", "Before")
.on_click(move |_, _, cx| note(&before, "before".into(), cx)),
)
.child(
MailThreadView::new("thread", "Friday's review")
.zone(TimeZone::UTC)
.on_reply(move |key, _, cx| note(&reply, format!("reply {key}"), cx))
.message(message("m1"), div().child("First"))
.message(message("m2"), div().child("Second"))
.message(message("m3"), div().child("Newest")),
)
.into_any_element()
}
#[gpui::test]
fn a_press_on_a_fold_leaves_focus_alone(cx: &mut TestAppContext) {
let (host, cx) = mailing(answered, cx);
tab_to(1, cx);
let fold = cx.debug_bounds("fold m1").expect("the first fold draws");
cx.simulate_click(fold.center(), Modifiers::none());
settle(cx);
press("space", cx);
assert_eq!(heard(&host, cx), ["before"], "the button kept focus");
}
#[gpui::test]
fn space_on_a_fold_keeps_focus_on_the_message(cx: &mut TestAppContext) {
let (host, cx) = mailing(answered, cx);
tab_to(2, cx);
press("space", cx);
cx.update(|window, _| window.focus_next());
settle(cx);
press("space", cx);
assert_eq!(
heard(&host, cx),
["reply m1"],
"Tab went on to the opened message's Reply"
);
}
#[gpui::test]
fn a_fold_keeps_its_parts_inside_at_280(cx: &mut TestAppContext) {
let (_, cx) = mailing(
|_| {
let long = Contact::new(
"Anastasia Lima-Fernández de la Torre",
"anastasia@atrium.studio",
);
let old = Timestamp::now() - SignedDuration::from_hours(24 * 20);
div()
.w(px(280.0))
.child(
MailThreadView::new("thread", "Samples")
.zone(TimeZone::UTC)
.message(
Message::new("m1", long, old)
.snippet("The lime reads warmest by the windows."),
div().child("Earlier"),
)
.message(Message::new("m2", ana(), old), div().child("Newest")),
)
.into_any_element()
},
cx,
);
let fold = cx.debug_bounds("fold m1").expect("the fold draws");
let date = cx.debug_bounds("fold-date m1").expect("its date draws");
assert!(date.right() <= fold.right(), "{date:?} inside {fold:?}");
}
+32 -12
View File
@@ -1,8 +1,9 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
AnyElement, App, ElementId, FocusHandle, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
Window, div, prelude::*,
};
use jiff::{Timestamp, tz::TimeZone};
use smallvec::SmallVec;
@@ -10,7 +11,7 @@ use smallvec::SmallVec;
use super::reader::{Asks, Message, open_letter};
use crate::{
data_display::Avatar,
primitives::FocusRing,
primitives::{FocusRing, tab_stop},
theme::{ActiveTheme, AvatarSize, Radius, TextSize},
typography::{Ellipsis, format},
};
@@ -103,21 +104,34 @@ impl RenderOnce for MailThreadView {
let now = Timestamp::now();
let newest = self.messages.len() - 1;
let open_now = opened.read(cx).clone();
let focuses: Vec<FocusHandle> = self
.messages
.iter()
.enumerate()
.map(|(ix, (message, _))| {
let folded = ix != newest && !open_now.contains(&message.key);
let id = (self.id.clone(), format!("message-{}", message.key)).into();
tab_stop(id, folded, window, cx)
})
.collect();
let theme = cx.theme();
let colors = &theme.colors;
let messages = self
.messages
.into_iter()
.enumerate()
.map(|(ix, (message, body))| {
let messages = self.messages.into_iter().zip(focuses).enumerate().map(
|(ix, ((message, body), focus))| {
let open = ix == newest || open_now.contains(&message.key);
let part = if open {
open_letter(&self.id, &message, body, &zone, &self.asks, cx).into_any_element()
open_letter(&self.id, &message, body, &zone, &self.asks, cx)
.track_focus(&focus)
.into_any_element()
} else {
let (key, opening, id) = (message.key.clone(), opened.clone(), self.id.clone());
let date = format::relative(message.at, now);
div()
.id((self.id.clone(), format!("fold-{}", message.key)))
.debug_selector({
let key = message.key.clone();
move || format!("fold {key}")
})
.flex()
.items_center()
.gap_3()
@@ -126,11 +140,12 @@ impl RenderOnce for MailThreadView {
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(gpui::transparent_black())
.tab_index(0)
.track_focus(&focus)
.focus_ring(cx)
.cursor_pointer()
.hover(|style| style.bg(colors.hover))
.text_size(theme.text_size(TextSize::Sm))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, _, cx| {
log::info!("mail thread {id}: open {key}");
opening.update(cx, |opened, cx| {
@@ -149,7 +164,7 @@ impl RenderOnce for MailThreadView {
)
.child(
div()
.flex_none()
.min_w_0()
.max_w(theme.label_width())
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
@@ -164,6 +179,10 @@ impl RenderOnce for MailThreadView {
)
.child(
div()
.debug_selector({
let key = message.key.clone();
move || format!("fold-date {key}")
})
.flex_none()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
@@ -176,7 +195,8 @@ impl RenderOnce for MailThreadView {
row.pt_3().border_t_1().border_color(colors.border)
})
.child(part)
});
},
);
div()
.debug_selector(|| "mail-thread".into())
.w_full()