From 48118499aed6ee9e0227fcba67d3f8733e741d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 00:07:27 -0400 Subject: [PATCH] =?UTF-8?q?Mail=20=C2=B7=20a=20folded=20message=20keeps=20?= =?UTF-8?q?focus=20as=20it=20opens,=20and=20gives=20way=20at=20280px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/mail/tests/read.rs | 91 ++++++++++++++++++++++++++++++++++++++++-- src/mail/thread.rs | 44 ++++++++++++++------ 3 files changed, 120 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d1eb36e..8a0f863 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). diff --git a/src/mail/tests/read.rs b/src/mail/tests/read.rs index f0f487d..ce17d55 100644 --- a/src/mail/tests/read.rs +++ b/src/mail/tests/read.rs @@ -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) -> 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:?}"); +} diff --git a/src/mail/thread.rs b/src/mail/thread.rs index 05fd08a..c9521fc 100644 --- a/src/mail/thread.rs +++ b/src/mail/thread.rs @@ -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 = 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()