From 8d91554f8b4a3ff2c6060e0cb3f7a25ef075196d 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 05:56:48 -0400 Subject: [PATCH] T23a round 1 fixes - your messages shrink within their column and wrap there - pictures, grids and video take their own shape up to their column's width; files, voice notes and link cards stay inside it - a finished stream shows whole - avatar pictures load from web addresses too - the list drops its way down once everything fits again - the reasoning toggle is a Tab stop with a focus ring - a link's thumbnail keeps its corners - gallery: a narrow column of media --- TASKS.md | 1 + examples/gallery/pages/chat/blocks.rs | 55 ++++--- src/chat/list.rs | 26 +++- src/chat/media.rs | 57 +++---- src/chat/message.rs | 40 +++-- src/chat/play.rs | 20 +-- src/chat/status.rs | 10 +- src/chat/stream.rs | 47 +++++- src/chat/tests.rs | 206 +++++++++++++++++++++++++- 9 files changed, 371 insertions(+), 91 deletions(-) diff --git a/TASKS.md b/TASKS.md index 814e78a..ebdaae6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -256,3 +256,4 @@ Per-component lines live in `tasks/`. Tags there: | T21c | 2 | PASS | | | T22 | 1 | FAIL | a remote cursor scrolled out sideways still drew its flag; presence avatars showed no focus ring; the access list hid roles it could not change; status dots used a fixed size | | T22 | 2 | PASS | | +| T23a | 1 | FAIL | a long message of yours overflowed a narrow column; pictures and video sized to the theme, not their column; a finished stream kept revealing; avatar pictures from the web were read as files; the list stayed away after it fit again; the reasoning toggle had no Tab stop; a link's thumbnail lost its corners | diff --git a/examples/gallery/pages/chat/blocks.rs b/examples/gallery/pages/chat/blocks.rs index 499a4c6..fa84b07 100644 --- a/examples/gallery/pages/chat/blocks.rs +++ b/examples/gallery/pages/chat/blocks.rs @@ -91,31 +91,48 @@ pub fn media(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { .child( div() .flex() - .flex_wrap() - .items_start() + .flex_col() .gap_6() .child( div() .flex() - .flex_col() - .gap_4() - .child(ImageMessage::new("chat-image", asset!("atrium.jpg"), 960.0, 640.0).caption("The atrium at noon")) - .child(FileMessage::new("chat-file", "lift-notes.pdf", 482_000).on_download(|_, _| log::info!("gallery: download file"))) - .child( - AudioMessage::new("chat-audio", peaks, Duration::from_secs(42)) - .played(played, on) - .on_toggle(move |next, _, cx| { - let at = toggle.read(cx).0; - set(&toggle, (at, next), cx) - }) - .on_seek(move |share, _, cx| { - let on = seek.read(cx).1; - set(&seek, (Duration::from_secs_f32(42.0 * share), on), cx) - }), - ), + .items_start() + .gap_6() + .child( + div() + .w(px(320.)) + .flex() + .flex_col() + .gap_4() + .child(ImageMessage::new("chat-image", asset!("atrium.jpg"), 960.0, 640.0).caption("The atrium at noon")) + .child(FileMessage::new("chat-file", "lift-notes.pdf", 482_000).on_download(|_, _| log::info!("gallery: download file"))) + .child( + AudioMessage::new("chat-audio", peaks, Duration::from_secs(42)) + .played(played, on) + .on_toggle(move |next, _, cx| { + let at = toggle.read(cx).0; + set(&toggle, (at, next), cx) + }) + .on_seek(move |share, _, cx| { + let on = seek.read(cx).1; + set(&seek, (Duration::from_secs_f32(42.0 * share), on), cx) + }), + ), + ) + .child( + div() + .w(px(280.)) + .flex() + .flex_col() + .gap_3() + .child(ImageMessage::new("chat-narrow-image", asset!("atrium-stair.jpg"), 600.0, 400.0).caption("In a narrow column, in its own shape")) + .child(VideoMessage::new("chat-narrow-video", asset!("dunes-sun.jpg"), 600.0, 400.0, Duration::from_secs(31))) + .child(FileMessage::new("chat-narrow-file", "a-long-name-for-the-lift-notes.pdf", 482_000)), + ), ) .child( div() + .w(px(520.)) .flex() .flex_col() .gap_4() @@ -136,6 +153,6 @@ pub fn media(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { .picture(asset!("dunes-square.jpg")) .on_open(|_, _| log::info!("gallery: open link")), ), - ), + ) ) } diff --git a/src/chat/list.rs b/src/chat/list.rs index 0f99b8e..8f45771 100644 --- a/src/chat/list.rs +++ b/src/chat/list.rs @@ -2,7 +2,7 @@ use std::rc::Rc; use gpui::{ AnyElement, App, Context, ElementId, IntoElement, ListAlignment, ListOffset, ListState, - ParentElement, Pixels, RenderOnce, SharedString, Styled, Window, div, list, prelude::*, + ParentElement, Pixels, RenderOnce, SharedString, Styled, Window, canvas, div, list, prelude::*, }; use jiff::civil::Date; @@ -101,11 +101,33 @@ impl RenderOnce for MessageList { (feed.list.clone(), feed.away, feed.fresh) }; let render = self.render; - let down = feed.clone(); + let (down, settled) = (feed.clone(), feed.clone()); div() .relative() .size_full() .child(list(state, move |ix, window, cx| render(ix, window, cx)).size_full()) + .child( + canvas( + move |_, window, cx| { + let feed = settled.read(cx); + let held = feed.list.logical_scroll_top().item_ix >= feed.list.item_count(); + if feed.away && held { + log::info!("message list: back at the newest"); + settled.update(cx, |feed, cx| { + feed.away = false; + feed.fresh = 0; + cx.notify(); + }); + window.request_animation_frame(); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) .when(away, |view| { view.child( div() diff --git a/src/chat/media.rs b/src/chat/media.rs index 738e9ee..837c277 100644 --- a/src/chat/media.rs +++ b/src/chat/media.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use gpui::{ - AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ObjectFit, + AnyElement, App, Div, ElementId, FontWeight, InteractiveElement, IntoElement, ObjectFit, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, }; @@ -15,14 +15,17 @@ use crate::{ typography::{Ellipsis, format::file_size}, }; -/// A picture's box: its own shape, no wider than `widest`. -pub(crate) fn fitted(width: f32, height: f32, widest: f32) -> (f32, f32) { +/// A box in a picture's own shape: as wide as the picture, but no wider than `widest` or its container. +pub(crate) fn shaped(width: f32, height: f32, widest: Pixels) -> Div { assert!( width > 0.0 && height > 0.0, "a picture of {width}x{height} has no shape" ); - let shown = width.min(widest); - (shown, shown * height / width) + let mut frame = div() + .w(Pixels::from(width.min(f32::from(widest)))) + .max_w_full(); + frame.style().aspect_ratio = Some(width / height); + frame } /// A picture in a message, in its own shape up to the column's width, a caption under it; a press asks the owner to open it large. @@ -66,19 +69,17 @@ impl ImageMessage { impl RenderOnce for ImageMessage { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); - let widest = f32::from(theme.prose_width().to_pixels(window.rem_size())); - let (width, height) = fitted(self.size.0, self.size.1, widest); + let widest = theme.prose_width().to_pixels(window.rem_size()); let open = self.on_open; div() + .w_full() .flex() .flex_col() .gap_1() .child( - div() + shaped(self.size.0, self.size.1, widest) .id(self.id.clone()) .relative() - .w(Pixels::from(width)) - .h(Pixels::from(height)) .when_some(open, |picture, open| { picture .cursor_pointer() @@ -129,7 +130,7 @@ impl ImageGrid { } impl RenderOnce for ImageGrid { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors.clone(); let side = theme.prose_width(); @@ -173,12 +174,19 @@ impl RenderOnce for ImageGrid { .into_any_element() }; let row = || div().flex().flex_1().min_h_0().gap_0p5(); - let grid = div().w(side).h(side).flex().flex_col().gap_0p5(); - match self.sources.len() { + let (count, rem) = (self.sources.len(), window.rem_size()); + let wide = f32::from(side.to_pixels(rem)); + let grid = shaped( + wide, + if count == 2 { wide / 2.0 } else { wide }, + side.to_pixels(rem), + ) + .flex() + .flex_col() + .gap_0p5(); + match count { 1 => grid.child(row().child(tile(0))), - 2 => grid - .h(side * 0.5) - .child(row().child(tile(0)).child(tile(1))), + 2 => grid.child(row().child(tile(0)).child(tile(1))), 3 => grid.child( div().flex().flex_1().gap_0p5().child(tile(0)).child( div() @@ -237,7 +245,8 @@ impl RenderOnce for FileMessage { let theme = cx.theme(); let colors = theme.colors.clone(); div() - .w(theme.prose_width()) + .w_full() + .max_w(theme.prose_width()) .flex() .items_center() .gap_3() @@ -340,7 +349,8 @@ impl RenderOnce for LinkPreviewCard { let open = self.on_open; div() .id(self.id.clone()) - .w(theme.container_width(ContainerSize::Sm)) + .w_full() + .max_w(theme.container_width(ContainerSize::Sm)) .flex() .gap_3() .p_3() @@ -396,6 +406,7 @@ impl RenderOnce for LinkPreviewCard { .child( Image::new((self.id.clone(), "picture"), source(&picture)) .fit(ObjectFit::Cover) + .rounded(theme.radius(Radius::Md)) .size_full(), ) })) @@ -406,16 +417,6 @@ impl RenderOnce for LinkPreviewCard { mod tests { use super::*; - #[test] - fn a_picture_keeps_its_shape_within_the_column() { - assert_eq!(fitted(800.0, 600.0, 320.0), (320.0, 240.0)); - assert_eq!( - fitted(200.0, 100.0, 320.0), - (200.0, 100.0), - "small ones stay" - ); - } - #[test] fn a_file_name_picks_its_icon() { assert_eq!(file_icon("lift.PNG"), IconName::Image); diff --git a/src/chat/message.rs b/src/chat/message.rs index 6a039dc..68d12ad 100644 --- a/src/chat/message.rs +++ b/src/chat/message.rs @@ -1,5 +1,3 @@ -use std::path::Path; - use gpui::{ AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, prelude::*, relative, @@ -9,6 +7,7 @@ use smallvec::SmallVec; use crate::{ data_display::Avatar, + documents::source, primitives::{Icon, IconName}, theme::{ActiveTheme, AvatarSize, ContainerSize, IconSize, Radius, TextSize}, typography::RelativeTime, @@ -57,7 +56,7 @@ impl RenderOnce for MessageAvatar { Role::User | Role::System => { let avatar = Avatar::new(self.id, self.name).size(AvatarSize::Sm); match self.picture { - Some(picture) => avatar.image(Path::new(picture.as_ref())), + Some(picture) => avatar.image(source(&picture)), None => avatar, } .into_any_element() @@ -250,26 +249,35 @@ impl RenderOnce for MessageBubble { Role::User => div() .w_full() .flex() - .justify_end() .gap_3() .child( div() - .max_w(theme.container_width(ContainerSize::Sm)) + .flex_1() + .min_w_0() .flex() .flex_col() - .items_end() .gap_1() - .children(self.header) - .child( - div() - .px_4() - .py_2p5() - .rounded(theme.radius(Radius::Xl)) - .bg(colors.hover) - .text_color(colors.fg) - .child(body), + .children( + self.header + .map(|header| div().flex().justify_end().child(header)), ) - .children(self.footer), + .child( + div().flex().justify_end().child( + div() + .min_w_0() + .max_w(theme.container_width(ContainerSize::Sm)) + .px_4() + .py_2p5() + .rounded(theme.radius(Radius::Xl)) + .bg(colors.hover) + .text_color(colors.fg) + .child(body), + ), + ) + .children( + self.footer + .map(|footer| div().flex().justify_end().child(footer)), + ), ) .children(self.avatar) .into_any_element(), diff --git a/src/chat/play.rs b/src/chat/play.rs index 50c522b..de933de 100644 --- a/src/chat/play.rs +++ b/src/chat/play.rs @@ -1,12 +1,12 @@ use std::{rc::Rc, time::Duration}; use gpui::{ - App, ElementId, InteractiveElement, IntoElement, MouseButton, ObjectFit, ParentElement, Pixels, + App, ElementId, InteractiveElement, IntoElement, MouseButton, ObjectFit, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, relative, }; -use super::media::fitted; +use super::media::shaped; use crate::{ buttons::{ButtonVariant, IconButton}, documents::source, @@ -92,7 +92,8 @@ impl RenderOnce for AudioMessage { let playing = self.playing; let tall = theme.control_height(ControlSize::Md); div() - .w(theme.prose_width()) + .w_full() + .max_w(theme.prose_width()) .flex() .items_center() .gap_3() @@ -213,12 +214,9 @@ impl RenderOnce for VideoMessage { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors.clone(); - let widest = f32::from( - theme - .container_width(ContainerSize::Sm) - .to_pixels(window.rem_size()), - ); - let (width, height) = fitted(self.size.0, self.size.1, widest); + let widest = theme + .container_width(ContainerSize::Sm) + .to_pixels(window.rem_size()); let share = if self.length.is_zero() { 0.0 } else { @@ -226,11 +224,9 @@ impl RenderOnce for VideoMessage { }; let playing = self.playing; let toggle = self.on_toggle.clone(); - div() + shaped(self.size.0, self.size.1, widest) .id(self.id.clone()) .relative() - .w(Pixels::from(width)) - .h(Pixels::from(height)) .bg(colors.media_backdrop) .when_some(toggle, |video, toggle| { video.cursor_pointer().on_click(move |_, window, cx| { diff --git a/src/chat/status.rs b/src/chat/status.rs index 49b99cd..6200576 100644 --- a/src/chat/status.rs +++ b/src/chat/status.rs @@ -6,7 +6,7 @@ use std::{ use gpui::{ App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, - prelude::*, relative, + prelude::*, relative, transparent_black, }; use super::stream::StreamingText; @@ -14,7 +14,7 @@ use crate::{ buttons::{Button, ButtonVariant}, feedback::{Alert, Countdown}, forms::{Choice, ChoiceChips, Input, OnValues, Run, TextInput}, - primitives::{Disclosure, Icon, IconName, Severity}, + primitives::{Disclosure, FocusRing, Icon, IconName, Severity}, theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, typography::ShimmerText, }; @@ -367,6 +367,12 @@ impl RenderOnce for ThinkingBlock { .flex() .items_center() .gap_1() + .px_1() + .rounded(theme.radius(Radius::Sm)) + .border_1() + .border_color(transparent_black()) + .tab_index(0) + .focus_ring(cx) .cursor_pointer() .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) .on_click(move |_, _, cx| { diff --git a/src/chat/stream.rs b/src/chat/stream.rs index d833eda..0df6684 100644 --- a/src/chat/stream.rs +++ b/src/chat/stream.rs @@ -36,6 +36,20 @@ pub(crate) fn advanced(shown: f32, total: usize, elapsed: Duration) -> f32 { (shown + rate * elapsed.as_secs_f32()).min(total as f32) } +/// Graphemes shown next: `advanced` toward `total` while live, or all of them once finished or when motion rests. +pub(crate) fn next_shown( + shown: f32, + total: usize, + still: bool, + elapsed: Duration, + live: bool, +) -> f32 { + if still || !live { + return total as f32; + } + advanced(shown.min(total as f32), total, elapsed) +} + /// Where the first `count` graphemes of `text` end, in bytes. pub(crate) fn grapheme_end(text: &str, count: usize) -> usize { text.grapheme_indices(true) @@ -51,12 +65,14 @@ fn revealed(id: &ElementId, text: &str, live: bool, window: &mut Window, cx: &mu shown: if live { 0.0 } else { total as f32 }, at: now, }); - let (shown, at) = (state.read(cx).shown.min(total as f32), state.read(cx).at); - let shown = if cx.theme().reduced_motion { - total as f32 - } else { - advanced(shown, total, now.saturating_duration_since(at)) - }; + let (shown, at) = (state.read(cx).shown, state.read(cx).at); + let shown = next_shown( + shown, + total, + cx.theme().reduced_motion, + now.saturating_duration_since(at), + live, + ); state.update(cx, |reveal, _| *reveal = Reveal { shown, at: now }); if (shown as usize) < total { window.request_animation_frame(); @@ -190,6 +206,25 @@ mod tests { assert_eq!(advanced(12.0, 10, tick), 10.0, "shorter text clamps"); } + #[test] + fn a_finished_stream_shows_whole() { + let tick = Duration::from_millis(16); + assert_eq!( + next_shown(3.0, 400, false, tick, false), + 400.0, + "done: whole at once" + ); + assert!( + next_shown(3.0, 400, false, tick, true) < 400.0, + "live: it keeps revealing" + ); + assert_eq!( + next_shown(3.0, 400, true, tick, true), + 400.0, + "still motion: whole" + ); + } + #[test] fn a_count_of_graphemes_ends_on_a_boundary() { assert_eq!(grapheme_end("héllo", 2), 3); diff --git a/src/chat/tests.rs b/src/chat/tests.rs index 90eff66..7930567 100644 --- a/src/chat/tests.rs +++ b/src/chat/tests.rs @@ -1,28 +1,37 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ - AppContext as _, Context, Entity, Focusable, IntoElement, Modifiers, ParentElement, Render, - ScrollDelta, ScrollWheelEvent, Styled, TestAppContext, TouchPhase, VisualTestContext, Window, - div, point, px, + AppContext as _, Bounds, Context, Entity, Focusable, InteractiveElement, IntoElement, + KeyUpEvent, Keystroke, Modifiers, MouseButton, ParentElement, Pixels, Render, ScrollDelta, + ScrollWheelEvent, Styled, TestAppContext, TouchPhase, VisualTestContext, Window, canvas, div, + point, px, }; -use super::{MessageEditor, MessageList}; +use super::{ + ImageMessage, MessageAvatar, MessageBubble, MessageEditor, MessageList, Role, ThinkingBlock, +}; use crate::{forms, forms::TextInput, theme::Theme}; type Seen = Rc>>; -/// A conversation of tall messages, noting which ones each frame drew. +type Pressed = Rc>>; + +/// A conversation of tall messages, noting which ones each frame drew, and whether a press landed on a button. struct Talk { count: usize, seen: Seen, + pressed: Pressed, } impl Render for Talk { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - let seen = self.seen.clone(); + let (seen, pressed) = (self.seen.clone(), self.pressed.clone()); seen.borrow_mut().clear(); div() .size_full() + .on_mouse_down(MouseButton::Left, move |_, window, _| { + *pressed.borrow_mut() = Some(window.default_prevented()) + }) .child(MessageList::new("talk", self.count, move |ix, _, _| { seen.borrow_mut().push(ix); div().h(px(80.0)).w_full().into_any_element() @@ -48,6 +57,7 @@ fn the_list_follows_the_newest_until_the_reader_scrolls_away(cx: &mut TestAppCon let (talk, cx) = cx.add_window_view(move |_, _| Talk { count: 40, seen: noted, + pressed: Pressed::default(), }); settle(cx); assert_eq!(newest(&seen), 39, "it opens at the newest"); @@ -162,3 +172,187 @@ fn the_editor_takes_focus_as_it_opens(cx: &mut TestAppContext) { "Escape reaches the editor" ); } + +type Measured = Rc>>>; + +/// A long message of yours in a narrow column, its body's box measured. +struct Narrow { + body: Measured, +} + +impl Render for Narrow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let body = self.body.clone(); + div().size_full().child( + div().w(px(360.0)).child( + MessageBubble::new(Role::User) + .avatar(MessageAvatar::new("me", Role::User, "Ada Park")) + .child("How far does a lift move a saturated accent on charcoal compared with the same accent on paper, and why?") + .child( + canvas(move |bounds, _, _| *body.borrow_mut() = Some(bounds), |_, _, _, _| {}) + .absolute() + .top_0() + .left_0() + .size_full(), + ), + ), + ) + } +} + +#[gpui::test] +fn a_long_message_of_yours_wraps_within_its_column(cx: &mut TestAppContext) { + cx.update(Theme::init); + let body = Measured::default(); + let seen = body.clone(); + let (_, cx) = cx.add_window_view(move |_, _| Narrow { body: seen }); + settle(cx); + let bounds = body.borrow().expect("the body is laid out"); + assert!( + bounds.left() >= px(0.0), + "it starts inside the column: {bounds:?}" + ); + assert!( + bounds.right() <= px(360.0), + "it ends inside the column: {bounds:?}" + ); +} + +#[gpui::test] +fn a_list_that_fits_again_drops_the_way_down(cx: &mut TestAppContext) { + cx.update(Theme::init); + let pressed = Pressed::default(); + let noted = pressed.clone(); + let (talk, cx) = cx.add_window_view(move |_, _| Talk { + count: 40, + seen: Seen::default(), + pressed: noted, + }); + settle(cx); + let (middle, button) = cx.update(|window, _| { + let size = window.viewport_size(); + ( + point(size.width / 2.0, size.height / 2.0), + point(size.width / 2.0, size.height - px(28.0)), + ) + }); + cx.simulate_event(ScrollWheelEvent { + position: middle, + delta: ScrollDelta::Pixels(point(px(0.0), px(1200.0))), + modifiers: Modifiers::none(), + touch_phase: TouchPhase::Moved, + }); + settle(cx); + cx.simulate_click(button, Modifiers::none()); + assert_eq!(*pressed.borrow(), Some(true), "away, the way down shows"); + cx.simulate_event(ScrollWheelEvent { + position: middle, + delta: ScrollDelta::Pixels(point(px(0.0), px(1200.0))), + modifiers: Modifiers::none(), + touch_phase: TouchPhase::Moved, + }); + settle(cx); + talk.update(cx, |talk, cx| { + talk.count = 2; + cx.notify(); + }); + settle(cx); + settle(cx); + cx.simulate_click(button, Modifiers::none()); + assert_eq!(*pressed.borrow(), Some(false), "all in view, no way down"); +} + +/// A wide picture in a narrow column, its box measured. +struct Picture { + frame: Measured, +} + +impl Render for Picture { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let frame = self.frame.clone(); + div().size_full().child( + div() + .w(px(240.0)) + .relative() + .child(ImageMessage::new("wide", "wide.png", 960.0, 640.0)) + .child( + canvas( + move |bounds, _, _| *frame.borrow_mut() = Some(bounds), + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ), + ) + } +} + +#[gpui::test] +fn a_picture_fits_a_narrow_column_in_its_own_shape(cx: &mut TestAppContext) { + cx.update(Theme::init); + let frame = Measured::default(); + let seen = frame.clone(); + let (_, cx) = cx.add_window_view(move |_, _| Picture { frame: seen }); + settle(cx); + let bounds = frame.borrow().expect("the column is laid out"); + assert_eq!(bounds.size.height, px(160.0), "240 wide at 3:2 is 160 tall"); +} + +/// Reasoning folded under its time, the block's box measured. +struct Reasoning { + frame: Measured, +} + +impl Render for Reasoning { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let frame = self.frame.clone(); + div().size_full().child( + div() + .w(px(320.0)) + .relative() + .child(ThinkingBlock::new( + "thought", + "Start from the definition.", + false, + std::time::Duration::from_secs(3), + )) + .child( + canvas( + move |bounds, _, _| *frame.borrow_mut() = Some(bounds), + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ), + ) + } +} + +#[gpui::test] +fn the_reasoning_opens_from_the_keyboard(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + }); + let frame = Measured::default(); + let seen = frame.clone(); + let (_, cx) = cx.add_window_view(move |_, _| Reasoning { frame: seen }); + cx.update(|window, _| window.activate_window()); + settle(cx); + let closed = frame.borrow().expect("the block is laid out").size.height; + cx.update(|window, _| window.focus_next()); + cx.simulate_keystrokes("space"); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse("space").expect("space parses"), + }); + settle(cx); + let open = frame.borrow().expect("the block is laid out").size.height; + assert!( + open > closed, + "Tab and Space open it: {closed:?} to {open:?}" + ); +}