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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f946af6216
commit
48118499ae
3 files changed
+120
-17
No files matched your search
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user