1use std::{rc::Rc, time::Duration};
2
3use gpui::{
4 AbsoluteLength, AnyElement, App, Axis, Bounds, ClickEvent, ElementId, Hsla, ImageSource,
5 InteractiveElement as _, IntoElement, MouseButton, ObjectFit, ParentElement, Path, PathBuilder,
6 Pixels, Refineable as _, RenderOnce, ScrollHandle, SharedString,
7 StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage as _, Window, black,
8 canvas, div, img, linear_color_stop, linear_gradient, point, prelude::FluentBuilder as _, px,
9 relative, rems, white,
10};
11use gpui_base::{
12 is_mobile,
13 motion::{Transition, transition},
14};
15use rust_i18n::t;
16
17use crate::{
18 ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable, Size, StyledExt as _,
19 button::{Button, ButtonCustomVariant, ButtonVariants as _},
20 h_flex,
21 progress::ProgressCircle,
22 scroll::ScrollableMask,
23 shimmer::{ShimmerStyle, ShimmerText},
24 spinner::Spinner,
25 tooltip::Tooltip,
26 v_flex,
27};
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31pub enum AttachmentStatus {
32 Pending,
34 Uploading,
36 Processing,
38 Failed,
40 #[default]
42 Complete,
43}
44
45impl AttachmentStatus {
46 pub fn is_pending(self) -> bool {
48 matches!(self, Self::Pending)
49 }
50
51 pub fn is_uploading(self) -> bool {
53 matches!(self, Self::Uploading)
54 }
55
56 pub fn is_processing(self) -> bool {
58 matches!(self, Self::Processing)
59 }
60
61 pub fn is_failed(self) -> bool {
63 matches!(self, Self::Failed)
64 }
65
66 pub fn is_complete(self) -> bool {
68 matches!(self, Self::Complete)
69 }
70
71 pub fn is_in_progress(self) -> bool {
73 matches!(self, Self::Uploading | Self::Processing)
74 }
75}
76
77type ControlHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
79
80type Control = (ElementId, ControlHandler);
82
83#[derive(Clone)]
85struct SlotLayout {
86 size: Size,
87 status: AttachmentStatus,
88 axis: Axis,
89 flush: bool,
91 retry: Option<Control>,
93 progress: Option<f32>,
95 id: Option<ElementId>,
97}
98
99const REMOVE_OVERHANG: Pixels = px(6.);
101const REMOVE_BUTTON_SIZE: Pixels = px(20.);
103const RETRY_BUTTON_SIZE: Pixels = px(24.);
105const REMOVE_GROUP: &str = "attachment-remove";
108const PROGRESS_SCRIM: f32 = 0.35;
110const FAILED_SCRIM: f32 = 0.55;
112
113struct CardMetrics {
115 height: AbsoluteLength,
117 chip_width: AbsoluteLength,
119 media: AbsoluteLength,
121 media_glyph: AbsoluteLength,
124 padding_start: AbsoluteLength,
126 padding_end: AbsoluteLength,
128 card_padding: AbsoluteLength,
130 gap: AbsoluteLength,
131 text: AbsoluteLength,
132 description: AbsoluteLength,
133}
134
135fn card_metrics(size: Size) -> CardMetrics {
136 let r = |value: f32| AbsoluteLength::Rems(rems(value));
137 match size {
138 Size::XSmall => CardMetrics {
139 height: r(2.5),
140 chip_width: r(11.),
141 media: r(1.75),
142 media_glyph: r(0.875),
143 padding_start: r(0.25),
144 padding_end: r(0.375),
145 card_padding: r(0.25),
146 gap: r(0.375),
147 text: r(0.6875),
148 description: r(0.625),
149 },
150 Size::Small => CardMetrics {
151 height: r(3.),
152 chip_width: r(12.5),
153 media: r(2.),
154 media_glyph: r(1.),
155 padding_start: r(0.375),
156 padding_end: r(0.5),
157 card_padding: r(0.375),
158 gap: r(0.5),
159 text: r(0.75),
160 description: r(0.6875),
161 },
162 Size::Medium => CardMetrics {
163 height: r(3.5),
164 chip_width: r(14.5),
165 media: r(2.375),
166 media_glyph: r(1.25),
167 padding_start: r(0.5),
168 padding_end: r(0.75),
169 card_padding: r(0.5),
170 gap: r(0.625),
171 text: r(0.8125),
172 description: r(0.75),
173 },
174 Size::Large => CardMetrics {
175 height: r(4.),
176 chip_width: r(17.),
177 media: r(2.75),
178 media_glyph: r(1.5),
179 padding_start: r(0.625),
180 padding_end: r(1.),
181 card_padding: r(0.75),
182 gap: r(0.75),
183 text: r(0.875),
184 description: r(0.8125),
185 },
186 Size::Size(value) => CardMetrics {
188 height: (value * 3.5).into(),
189 chip_width: (value * 14.5).into(),
190 media: (value * 2.375).into(),
191 media_glyph: (value * 1.25).into(),
192 padding_start: (value * 0.5).into(),
193 padding_end: (value * 0.75).into(),
194 card_padding: (value * 0.5).into(),
195 gap: (value * 0.625).into(),
196 text: (value * 0.8125).into(),
197 description: (value * 0.75).into(),
198 },
199 }
200}
201
202fn card_radius(size: Size, cx: &App) -> Pixels {
204 let tokens = cx.theme().semantic_tokens();
205 if size == Size::XSmall {
206 tokens.radius.md
207 } else {
208 tokens.radius.lg
209 }
210}
211
212#[derive(IntoElement)]
214pub struct Attachment {
215 id: Option<ElementId>,
216 style: StyleRefinement,
217 status: AttachmentStatus,
218 size: Size,
219 axis: Axis,
220 media: Option<AttachmentMedia>,
221 content: Option<AttachmentContent>,
222 actions: Option<AttachmentActions>,
223 on_click: Option<ControlHandler>,
224 on_remove: Option<ControlHandler>,
225 on_retry: Option<ControlHandler>,
226 progress: Option<f32>,
227 tooltip: Option<SharedString>,
228}
229
230impl Attachment {
231 pub fn new() -> Self {
233 Self {
234 id: None,
235 style: StyleRefinement::default(),
236 status: AttachmentStatus::Complete,
237 size: Size::Medium,
238 axis: Axis::Horizontal,
239 media: None,
240 content: None,
241 actions: None,
242 on_click: None,
243 on_remove: None,
244 on_retry: None,
245 progress: None,
246 tooltip: None,
247 }
248 }
249
250 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
253 self.id = Some(id.into());
254 self
255 }
256
257 pub fn on_click(
263 mut self,
264 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
265 ) -> Self {
266 self.on_click = Some(Rc::new(handler));
267 self
268 }
269
270 pub fn on_remove(
276 mut self,
277 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
278 ) -> Self {
279 self.on_remove = Some(Rc::new(handler));
280 self
281 }
282
283 pub fn on_retry(
287 mut self,
288 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
289 ) -> Self {
290 self.on_retry = Some(Rc::new(handler));
291 self
292 }
293
294 pub fn progress(mut self, percent: f32) -> Self {
300 self.progress = Some(percent.clamp(0., 100.));
301 self
302 }
303
304 pub fn tooltip(mut self, text: impl Into<SharedString>) -> Self {
307 self.tooltip = Some(text.into());
308 self
309 }
310
311 pub fn status(mut self, status: AttachmentStatus) -> Self {
313 self.status = status;
314 self
315 }
316
317 pub fn axis(mut self, axis: Axis) -> Self {
319 self.axis = axis;
320 self
321 }
322
323 pub fn media(mut self, media: AttachmentMedia) -> Self {
325 self.media = Some(media);
326 self
327 }
328
329 pub fn content(mut self, content: AttachmentContent) -> Self {
331 self.content = Some(content);
332 self
333 }
334
335 pub fn actions(mut self, actions: AttachmentActions) -> Self {
337 self.actions = Some(actions);
338 self
339 }
340}
341
342impl Default for Attachment {
343 fn default() -> Self {
344 Self::new()
345 }
346}
347
348impl Sizable for Attachment {
349 fn with_size(mut self, size: impl Into<Size>) -> Self {
350 self.size = size.into();
351 self
352 }
353}
354
355impl Styled for Attachment {
356 fn style(&mut self) -> &mut StyleRefinement {
357 &mut self.style
358 }
359}
360
361impl Attachment {
362 fn retry_control(&self) -> Option<Control> {
365 if !self.status.is_failed() {
366 return None;
367 }
368 Some((self.id.clone()?, self.on_retry.clone()?))
369 }
370
371 fn layout_slots(&mut self) {
372 let layout = SlotLayout {
373 size: self.size,
374 status: self.status,
375 axis: self.axis,
376 flush: self.axis == Axis::Vertical && self.content.is_none(),
379 retry: self.retry_control(),
380 progress: self.progress.filter(|_| self.status.is_uploading()),
383 id: self.id.clone(),
384 };
385
386 self.media = self.media.take().map(|media| media.layout(layout.clone()));
387 self.content = self
388 .content
389 .take()
390 .map(|content| content.layout(layout.clone()));
391 self.actions = self
392 .actions
393 .take()
394 .map(|actions| actions.layout_for_axis(layout.axis));
395 }
396}
397
398impl RenderOnce for Attachment {
399 fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
400 let tokens = cx.theme().semantic_tokens();
401 let size = self.size;
402 let axis = self.axis;
403 let status = self.status;
404 let has_media = self.media.is_some();
405 let has_content = self.content.is_some();
406 let clickable = self.id.is_some() && self.on_click.is_some();
407 let remove = self.id.clone().zip(self.on_remove.take());
408 let tooltip = self.id.clone().zip(self.tooltip.take());
409 let progress_bar = self
410 .progress
411 .filter(|_| status.is_uploading() && axis == Axis::Horizontal);
412 let metrics = card_metrics(size);
413 let radius = card_radius(size, cx);
414 let flush = axis == Axis::Vertical && !has_content;
415
416 self.layout_slots();
417
418 let card = div()
419 .relative()
420 .flex()
421 .flex_none()
422 .max_w_full()
423 .min_w_0()
424 .rounded(radius)
425 .border_1()
426 .border_color(if status.is_failed() {
427 tokens.colors.destructive
428 } else {
429 tokens.colors.border
430 })
431 .when(status.is_pending(), |this| this.border_dashed())
432 .bg(tokens.colors.background)
433 .text_color(tokens.colors.foreground)
434 .hover(move |style| {
437 if clickable {
438 style.bg(tokens.colors.muted.opacity(0.5))
439 } else {
440 style
441 }
442 })
443 .line_height(relative(1.25))
444 .text_size(metrics.text)
445 .map(|this| match axis {
446 Axis::Horizontal => this
449 .items_center()
450 .h(metrics.height)
451 .gap(metrics.gap)
452 .pl(metrics.padding_start)
453 .pr(metrics.padding_end)
454 .when(has_content, |this| this.w(metrics.chip_width))
455 .when(!has_content && !has_media, |this| {
456 this.pl(metrics.padding_end)
457 }),
458 Axis::Vertical if flush => this.size(metrics.height),
460 Axis::Vertical => this
462 .w(rems(7.5))
463 .flex_col()
464 .items_start()
465 .gap(metrics.gap)
466 .p(metrics.card_padding),
467 })
468 .when_some(self.media, |this, media| this.child(media))
469 .when_some(self.content, |this, content| this.child(content))
470 .when_some(progress_bar, |this, percent| {
473 this.child(upload_bar(percent, radius, tokens.colors.primary))
474 })
475 .when_some(self.id.zip(self.on_click), |this, (id, on_click)| {
476 this.child(
479 div()
480 .id(id)
481 .absolute()
482 .inset_0()
483 .on_click(move |event, window, cx| on_click(event, window, cx)),
484 )
485 })
486 .when_some(self.actions, |this, actions| this.child(actions))
487 .refine_style(&self.style);
488 let card = match tooltip {
489 Some((id, text)) => card
490 .id((id, "card"))
491 .tooltip(move |window, cx| Tooltip::new(text.clone()).build(window, cx))
492 .into_any_element(),
493 None => card.into_any_element(),
494 };
495
496 let Some((id, on_remove)) = remove else {
497 return card;
498 };
499 div()
502 .relative()
503 .flex_none()
504 .max_w_full()
505 .min_w_0()
506 .group(REMOVE_GROUP)
507 .pt(REMOVE_OVERHANG)
508 .pr(REMOVE_OVERHANG)
509 .child(card)
510 .child(
511 div()
512 .absolute()
513 .top_0()
514 .right_0()
515 .when(!is_mobile(), |this| {
516 this.invisible()
517 .group_hover(REMOVE_GROUP, |this| this.visible())
518 })
519 .child(remove_button(id, on_remove, cx)),
520 )
521 .into_any_element()
522 }
523}
524
525const UPLOAD_BAR_THICKNESS: f32 = 2.;
527const CARD_BORDER: f32 = 1.;
529
530fn upload_bar(percent: f32, radius: Pixels, color: Hsla) -> impl IntoElement {
534 canvas(
535 |_: Bounds<Pixels>, _: &mut Window, _: &mut App| (),
536 move |bounds: Bounds<Pixels>, _: (), window: &mut Window, _: &mut App| {
537 if let Some(path) = upload_bar_path(bounds, percent, radius) {
538 window.paint_path(path, color);
539 }
540 },
541 )
542 .absolute()
543 .inset_0()
544}
545
546fn upload_bar_path(bounds: Bounds<Pixels>, percent: f32, radius: Pixels) -> Option<Path<Pixels>> {
551 const STEPS: usize = 6;
552 let width = bounds.size.width.as_f32();
553 let height = bounds.size.height.as_f32();
554 let radius = (radius.as_f32() - CARD_BORDER).clamp(0., width.min(height) / 2.);
555 let inner_left = 0.;
556 let inner_right = width;
557 let inner_bottom = height;
558 let top = inner_bottom - UPLOAD_BAR_THICKNESS;
559 let end = inner_left + (inner_right - inner_left) * (percent / 100.).clamp(0., 1.);
560 if end <= inner_left || top <= 0. {
561 return None;
562 }
563 let centre_y = inner_bottom - radius;
565 let arc_dx = |y: f32| {
566 if radius > 0. && y > centre_y {
567 (radius * radius - (y - centre_y) * (y - centre_y))
568 .max(0.)
569 .sqrt()
570 } else {
571 radius
572 }
573 };
574 let left_at = |y: f32| inner_left + radius - arc_dx(y);
575 let right_at = |y: f32| (inner_right - radius + arc_dx(y)).min(end);
576 let at = |x: f32, y: f32| point(bounds.origin.x + px(x), bounds.origin.y + px(y));
577 let rows = (0..=STEPS).map(|i| top + UPLOAD_BAR_THICKNESS * i as f32 / STEPS as f32);
578
579 let mut builder = PathBuilder::fill();
580 builder.move_to(at(left_at(top), top));
581 for y in rows.clone() {
583 builder.line_to(at(right_at(y), y));
584 }
585 for y in rows.rev() {
586 builder.line_to(at(left_at(y), y));
587 }
588 builder.build().ok()
589}
590
591fn remove_button(id: ElementId, on_remove: ControlHandler, cx: &App) -> Button {
597 let tokens = cx.theme().semantic_tokens();
598 Button::new((id, "remove"))
599 .custom(
602 ButtonCustomVariant::new(cx)
603 .hover(tokens.colors.muted)
604 .active(tokens.colors.muted)
605 .foreground(tokens.colors.foreground),
606 )
607 .accessibility_label(t!("Attachment.Remove"))
608 .child(
609 Icon::new(IconName::Close)
610 .size(px(10.))
611 .text_color(tokens.colors.foreground),
612 )
613 .size(REMOVE_BUTTON_SIZE)
614 .p_0()
615 .rounded(cx.theme().radius_full())
616 .bg(tokens.colors.background)
617 .border_1()
618 .border_color(tokens.colors.border)
619 .shadow_sm()
620 .on_click(move |event, window, cx| on_remove(event, window, cx))
621}
622
623fn retry_button(id: ElementId, on_retry: ControlHandler, cx: &App) -> Button {
626 let tokens = cx.theme().semantic_tokens();
627 Button::new((id, "retry"))
628 .ghost()
629 .accessibility_label(t!("Attachment.Retry"))
630 .child(
631 Icon::new(IconName::RefreshCw)
632 .size(px(12.))
633 .text_color(tokens.colors.destructive),
634 )
635 .size(RETRY_BUTTON_SIZE)
636 .p_0()
637 .rounded(cx.theme().radius_full())
638 .bg(tokens.colors.background)
639 .shadow_sm()
640 .on_click(move |event, window, cx| on_retry(event, window, cx))
641}
642
643#[derive(IntoElement)]
648pub struct AttachmentMedia {
649 style: StyleRefinement,
650 size: Option<Size>,
651 status: AttachmentStatus,
652 axis: Axis,
653 flush: bool,
655 retry: Option<Control>,
656 progress: Option<f32>,
658 id: Option<ElementId>,
660 source: Option<ImageSource>,
661 children: Vec<AnyElement>,
662 overlays: Vec<AnyElement>,
663}
664
665impl AttachmentMedia {
666 pub fn new() -> Self {
668 Self {
669 style: StyleRefinement::default(),
670 size: None,
671 status: AttachmentStatus::Complete,
672 axis: Axis::Horizontal,
673 flush: false,
674 retry: None,
675 progress: None,
676 id: None,
677 source: None,
678 children: Vec::new(),
679 overlays: Vec::new(),
680 }
681 }
682
683 pub fn src(mut self, source: impl Into<ImageSource>) -> Self {
685 self.source = Some(source.into());
686 self
687 }
688
689 pub fn overlay(mut self, overlay: impl IntoElement) -> Self {
691 self.overlays.push(
692 div()
693 .absolute()
694 .inset_0()
695 .flex()
696 .items_center()
697 .justify_center()
698 .child(overlay)
699 .into_any_element(),
700 );
701 self
702 }
703
704 fn layout(mut self, layout: SlotLayout) -> Self {
705 if self.size.is_none() {
706 self.size = Some(layout.size);
707 }
708 self.status = layout.status;
709 self.axis = layout.axis;
710 self.flush = layout.flush;
711 self.retry = layout.retry;
712 self.progress = layout.progress;
713 self.id = layout.id;
714 self
715 }
716}
717
718impl Default for AttachmentMedia {
719 fn default() -> Self {
720 Self::new()
721 }
722}
723
724impl ParentElement for AttachmentMedia {
725 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
726 self.children.extend(elements);
727 }
728}
729
730impl Sizable for AttachmentMedia {
731 fn with_size(mut self, size: impl Into<Size>) -> Self {
732 self.size = Some(size.into());
733 self
734 }
735}
736
737impl Styled for AttachmentMedia {
738 fn style(&mut self) -> &mut StyleRefinement {
739 &mut self.style
740 }
741}
742
743impl RenderOnce for AttachmentMedia {
744 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
745 let tokens = cx.theme().semantic_tokens();
746 let resolved_size = self.size.unwrap_or_default();
747 let metrics = card_metrics(resolved_size);
748 let radius = if self.flush {
751 (card_radius(resolved_size, cx) - px(1.)).max(px(0.))
752 } else if resolved_size == Size::XSmall {
753 tokens.radius.sm
754 } else {
755 tokens.radius.md
756 };
757 let glyph_size = metrics.media_glyph;
758 let spinner_size = if resolved_size == Size::XSmall {
759 Size::XSmall
760 } else {
761 Size::Small
762 };
763 let ring_size = if resolved_size == Size::XSmall {
764 px(14.)
765 } else {
766 px(20.)
767 };
768 let status = self.status;
769 let source = self.source;
770 let has_source = source.is_some();
771 let failed_media = status.is_failed() && !has_source;
772 let corner_radii = self.style.corner_radii.clone();
773 let ring_id: ElementId = match self.id {
774 Some(id) => (id, "progress").into(),
775 None => "attachment-progress".into(),
776 };
777 let busy = |color: Hsla| -> AnyElement {
780 match self.progress {
781 Some(percent) => ProgressCircle::new(ring_id.clone())
782 .value(percent)
783 .color(color)
784 .size(ring_size)
785 .into_any_element(),
786 None => Spinner::new()
787 .with_size(spinner_size)
788 .color(color)
789 .into_any_element(),
790 }
791 };
792 let glyph = if has_source {
795 None
796 } else if status.is_in_progress() {
797 Some(busy(tokens.colors.primary))
798 } else if status.is_failed() {
799 Some(match self.retry.clone() {
800 Some((id, on_retry)) => retry_button(id, on_retry, cx).into_any_element(),
801 None => Icon::new(IconName::Ban).size(glyph_size).into_any_element(),
802 })
803 } else {
804 None
805 };
806 let scrim = if !has_source {
809 None
810 } else if status.is_in_progress() {
811 Some((PROGRESS_SCRIM, busy(white())))
812 } else if status.is_failed() {
813 let control = match self.retry {
814 Some((id, on_retry)) => retry_button(id, on_retry, cx).into_any_element(),
815 None => Icon::new(IconName::Ban)
816 .size(glyph_size)
817 .text_color(white())
818 .into_any_element(),
819 };
820 Some((FAILED_SCRIM, control))
821 } else {
822 None
823 };
824
825 div()
826 .relative()
827 .flex()
828 .flex_shrink_0()
829 .items_center()
830 .justify_center()
831 .overflow_hidden()
832 .when(self.axis == Axis::Horizontal, |this| {
833 this.size(metrics.media)
834 })
835 .text_size(glyph_size)
837 .when(self.axis == Axis::Vertical, |this| {
838 this.w_full().aspect_ratio(1.)
839 })
840 .rounded(radius)
841 .bg(if failed_media {
842 tokens.colors.destructive.opacity(0.1)
843 } else {
844 tokens.colors.muted
845 })
846 .text_color(if failed_media {
847 tokens.colors.destructive
848 } else {
849 tokens.colors.foreground
850 })
851 .when_some(source, |this, source| {
852 let mut image = img(source)
856 .absolute()
857 .inset_0()
858 .size_full()
859 .rounded(radius)
860 .object_fit(ObjectFit::Cover);
861 image.style().corner_radii.refine(&corner_radii);
862 this.child(image)
863 })
864 .map(|this| match glyph {
865 Some(glyph) => this.child(glyph),
866 None => this.children(self.children),
867 })
868 .when_some(scrim, |this, (opacity, control)| {
869 let mut scrim = div()
872 .absolute()
873 .inset_0()
874 .flex()
875 .items_center()
876 .justify_center()
877 .rounded(radius)
878 .bg(black().opacity(opacity))
879 .child(control);
880 scrim.style().corner_radii.refine(&corner_radii);
881 this.child(scrim)
882 })
883 .children(self.overlays)
884 .refine_style(&self.style)
885 }
886}
887
888#[derive(IntoElement)]
890pub struct AttachmentContent {
891 style: StyleRefinement,
892 vertical_layout: bool,
893 status: AttachmentStatus,
894 retry: Option<Control>,
895 progress: Option<f32>,
896 children: Vec<AttachmentContentChild>,
897}
898
899enum AttachmentContentChild {
900 Title(AttachmentTitle),
901 Description(AttachmentDescription),
902 Element(AnyElement),
903}
904
905impl AttachmentContent {
906 pub fn new() -> Self {
908 Self {
909 style: StyleRefinement::default(),
910 vertical_layout: false,
911 status: AttachmentStatus::Complete,
912 retry: None,
913 progress: None,
914 children: Vec::new(),
915 }
916 }
917
918 pub fn title(mut self, title: AttachmentTitle) -> Self {
920 self.children.push(AttachmentContentChild::Title(title));
921 self
922 }
923
924 pub fn description(mut self, description: AttachmentDescription) -> Self {
926 self.children
927 .push(AttachmentContentChild::Description(description));
928 self
929 }
930
931 fn layout(mut self, layout: SlotLayout) -> Self {
932 let SlotLayout {
933 size,
934 status,
935 axis,
936 retry,
937 progress,
938 ..
939 } = layout;
940 self.vertical_layout = axis == Axis::Vertical;
941 self.status = status;
942 self.retry = retry;
943 self.progress = progress;
944
945 for child in &mut self.children {
946 match child {
947 AttachmentContentChild::Title(title) => {
948 if title.status.is_none() {
949 title.status = Some(status);
950 }
951 }
952 AttachmentContentChild::Description(description) => {
953 if description.status.is_none() {
954 description.status = Some(status);
955 }
956 if description.size.is_none() {
957 description.size = Some(size);
958 }
959 }
960 AttachmentContentChild::Element(_) => {}
961 }
962 }
963
964 self
965 }
966}
967
968impl Default for AttachmentContent {
969 fn default() -> Self {
970 Self::new()
971 }
972}
973
974impl ParentElement for AttachmentContent {
975 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
976 self.children
977 .extend(elements.into_iter().map(AttachmentContentChild::Element));
978 }
979}
980
981impl Styled for AttachmentContent {
982 fn style(&mut self) -> &mut StyleRefinement {
983 &mut self.style
984 }
985}
986
987impl RenderOnce for AttachmentContent {
988 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
989 let tokens = cx.theme().semantic_tokens();
990 let mut retry = self.retry.filter(|_| self.status.is_failed());
993 let progress = self.progress.filter(|_| self.status.is_uploading());
994 v_flex()
995 .max_w_full()
996 .min_w_0()
997 .flex_1()
998 .gap_0p5()
999 .line_height(relative(1.25))
1000 .when(self.vertical_layout, |this| this.w_full().px_1())
1001 .children(self.children.into_iter().map(|child| {
1002 match child {
1003 AttachmentContentChild::Title(title) => title.into_any_element(),
1004 AttachmentContentChild::Description(mut description) => match retry.take() {
1005 Some((id, on_retry)) => h_flex()
1006 .max_w_full()
1007 .min_w_0()
1008 .gap_1()
1009 .child(description)
1010 .child(
1011 div()
1012 .text_xs()
1013 .text_color(tokens.colors.muted_foreground)
1014 .child("·"),
1015 )
1016 .child(
1017 Button::new((id, "retry"))
1018 .link()
1019 .xsmall()
1020 .label(t!("Attachment.Retry"))
1021 .on_click(move |event, window, cx| on_retry(event, window, cx)),
1022 )
1023 .into_any_element(),
1024 None => {
1025 if let Some(percent) = progress {
1026 description.text =
1027 format!("{} · {}%", description.text, percent.round() as u32)
1028 .into();
1029 }
1030 description.into_any_element()
1031 }
1032 },
1033 AttachmentContentChild::Element(element) => element,
1034 }
1035 }))
1036 .refine_style(&self.style)
1037 }
1038}
1039
1040#[derive(IntoElement)]
1042pub struct AttachmentTitle {
1043 style: StyleRefinement,
1044 text: SharedString,
1045 status: Option<AttachmentStatus>,
1046 shimmer_style: Option<ShimmerStyle>,
1047}
1048
1049impl AttachmentTitle {
1050 pub fn new(text: impl Into<SharedString>) -> Self {
1052 Self {
1053 style: StyleRefinement::default(),
1054 text: text.into(),
1055 status: None,
1056 shimmer_style: None,
1057 }
1058 }
1059
1060 pub fn status(mut self, status: AttachmentStatus) -> Self {
1062 self.status = Some(status);
1063 self
1064 }
1065
1066 pub fn with_shimmer_style(mut self, style: ShimmerStyle) -> Self {
1068 self.shimmer_style = Some(style);
1069 self
1070 }
1071}
1072
1073impl Styled for AttachmentTitle {
1074 fn style(&mut self) -> &mut StyleRefinement {
1075 &mut self.style
1076 }
1077}
1078
1079impl RenderOnce for AttachmentTitle {
1080 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
1081 let loading = self.status.is_some_and(AttachmentStatus::is_in_progress);
1082
1083 div()
1084 .max_w_full()
1085 .min_w_0()
1086 .truncate()
1087 .font_medium()
1088 .map(|this| {
1089 if loading {
1090 this.child(
1091 ShimmerText::new(self.text).when_some(self.shimmer_style, |this, style| {
1092 this.with_shimmer_style(style)
1093 }),
1094 )
1095 } else {
1096 this.child(self.text)
1097 }
1098 })
1099 .refine_style(&self.style)
1100 }
1101}
1102
1103#[derive(IntoElement)]
1105pub struct AttachmentDescription {
1106 style: StyleRefinement,
1107 text: SharedString,
1108 status: Option<AttachmentStatus>,
1109 size: Option<Size>,
1111}
1112
1113impl AttachmentDescription {
1114 pub fn new(text: impl Into<SharedString>) -> Self {
1116 Self {
1117 style: StyleRefinement::default(),
1118 text: text.into(),
1119 status: None,
1120 size: None,
1121 }
1122 }
1123
1124 pub fn status(mut self, status: AttachmentStatus) -> Self {
1126 self.status = Some(status);
1127 self
1128 }
1129}
1130
1131impl Styled for AttachmentDescription {
1132 fn style(&mut self) -> &mut StyleRefinement {
1133 &mut self.style
1134 }
1135}
1136
1137impl RenderOnce for AttachmentDescription {
1138 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
1139 let tokens = cx.theme().semantic_tokens();
1140 let color = self
1141 .status
1142 .is_some_and(AttachmentStatus::is_failed)
1143 .then_some(tokens.colors.destructive)
1144 .unwrap_or(tokens.colors.muted_foreground);
1145
1146 div()
1147 .max_w_full()
1148 .min_w_0()
1149 .truncate()
1150 .text_size(card_metrics(self.size.unwrap_or_default()).description)
1151 .line_height(relative(1.25))
1152 .text_color(color)
1153 .child(self.text)
1154 .refine_style(&self.style)
1155 }
1156}
1157
1158#[derive(IntoElement)]
1163pub struct AttachmentActions {
1164 style: StyleRefinement,
1165 vertical_layout: bool,
1166 children: Vec<AnyElement>,
1167}
1168
1169impl AttachmentActions {
1170 pub fn new() -> Self {
1172 Self {
1173 style: StyleRefinement::default(),
1174 vertical_layout: false,
1175 children: Vec::new(),
1176 }
1177 }
1178
1179 fn layout_for_axis(mut self, axis: Axis) -> Self {
1180 self.vertical_layout = axis == Axis::Vertical;
1181 self
1182 }
1183}
1184
1185impl Default for AttachmentActions {
1186 fn default() -> Self {
1187 Self::new()
1188 }
1189}
1190
1191impl ParentElement for AttachmentActions {
1192 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
1193 self.children.extend(elements);
1194 }
1195}
1196
1197impl Styled for AttachmentActions {
1198 fn style(&mut self) -> &mut StyleRefinement {
1199 &mut self.style
1200 }
1201}
1202
1203impl RenderOnce for AttachmentActions {
1204 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
1205 div()
1206 .relative()
1207 .flex()
1208 .flex_shrink_0()
1209 .items_center()
1210 .gap_1()
1211 .when(self.vertical_layout, |this| {
1212 this.absolute().top_3().right_3()
1213 })
1214 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
1220 .children(self.children)
1221 .refine_style(&self.style)
1222 }
1223}
1224
1225const EDGE_FADE_TRANSITION: Duration = Duration::from_millis(200);
1227
1228struct AttachmentGroupScroll {
1230 handle: ScrollHandle,
1231 primed: bool,
1234}
1235
1236#[derive(IntoElement)]
1238pub struct AttachmentGroup {
1239 id: ElementId,
1240 style: StyleRefinement,
1241 scroll_handle: Option<ScrollHandle>,
1242 edge_fade: Option<Hsla>,
1243 children: Vec<AnyElement>,
1244}
1245
1246impl AttachmentGroup {
1247 pub fn new(id: impl Into<ElementId>) -> Self {
1249 Self {
1250 id: id.into(),
1251 style: StyleRefinement::default(),
1252 scroll_handle: None,
1253 edge_fade: None,
1254 children: Vec::new(),
1255 }
1256 }
1257
1258 pub fn track_scroll(mut self, handle: &ScrollHandle) -> Self {
1263 self.scroll_handle = Some(handle.clone());
1264 self
1265 }
1266
1267 pub fn with_edge_fade(mut self, color: impl Into<Hsla>) -> Self {
1273 self.edge_fade = Some(color.into());
1274 self
1275 }
1276}
1277
1278impl ParentElement for AttachmentGroup {
1279 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
1280 self.children.extend(elements);
1281 }
1282}
1283
1284impl Styled for AttachmentGroup {
1285 fn style(&mut self) -> &mut StyleRefinement {
1286 &mut self.style
1287 }
1288}
1289
1290impl RenderOnce for AttachmentGroup {
1291 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1292 let id = self.id;
1293 let scroll =
1297 window.use_keyed_state((id.clone(), "scroll"), cx, |_, _| AttachmentGroupScroll {
1298 handle: ScrollHandle::new(),
1299 primed: false,
1300 });
1301 let handle = self
1302 .scroll_handle
1303 .unwrap_or_else(|| scroll.read(cx).handle.clone());
1304 let view_id = window.current_view();
1305
1306 let fades = self.edge_fade.map(|color| {
1307 if !scroll.read(cx).primed {
1308 scroll.update(cx, |scroll, _| scroll.primed = true);
1309 window.on_next_frame(move |_, cx| cx.notify(view_id));
1310 }
1311 let max = handle.max_offset().x;
1312 let offset = handle.offset().x;
1314 let scrollable = max > px(1.);
1315 let hides_leading = scrollable && offset < px(-1.);
1316 let hides_trailing = scrollable && offset > px(1.) - max;
1317 let leading = transition(
1318 (id.clone(), "leading-fade"),
1319 if hides_leading { 1. } else { 0. },
1320 Transition::new(EDGE_FADE_TRANSITION),
1321 window,
1322 cx,
1323 );
1324 let trailing = transition(
1325 (id.clone(), "trailing-fade"),
1326 if hides_trailing { 1. } else { 0. },
1327 Transition::new(EDGE_FADE_TRANSITION),
1328 window,
1329 cx,
1330 );
1331 (color, leading, trailing)
1332 });
1333
1334 let row = h_flex()
1335 .id(id)
1336 .w_full()
1337 .min_w_0()
1338 .gap_3()
1339 .py_1()
1340 .overflow_x_scroll()
1341 .lock_scroll_axis()
1342 .track_scroll(&handle)
1343 .when(fades.is_some(), |this| {
1345 this.on_scroll_wheel(move |_, _, cx| cx.notify(view_id))
1346 })
1347 .refine_style(&self.style)
1348 .children(self.children);
1349
1350 let fade = |color: Hsla, opacity: f32, leading: bool| {
1353 let (from, to) = if leading {
1354 (color, color.opacity(0.))
1355 } else {
1356 (color.opacity(0.), color)
1357 };
1358 div()
1359 .absolute()
1360 .top_0()
1361 .bottom_0()
1362 .when(leading, |this| this.left_0())
1363 .when(!leading, |this| this.right_0())
1364 .w(rems(1.5))
1365 .opacity(opacity)
1366 .bg(linear_gradient(
1367 90.,
1368 linear_color_stop(from, 0.),
1369 linear_color_stop(to, 1.),
1370 ))
1371 };
1372
1373 div()
1374 .relative()
1375 .w_full()
1376 .min_w_0()
1377 .child(row)
1378 .child(ScrollableMask::vertical_wheel(&handle))
1379 .when_some(fades, |this, (color, leading, trailing)| {
1380 this.when(leading > 0., |this| this.child(fade(color, leading, true)))
1381 .when(trailing > 0., |this| {
1382 this.child(fade(color, trailing, false))
1383 })
1384 })
1385 }
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390 use super::*;
1391
1392 #[test]
1393 fn test_attachment_builder() {
1394 let mut attachment = Attachment::new()
1395 .status(AttachmentStatus::Uploading)
1396 .axis(Axis::Vertical)
1397 .with_size(Size::Small)
1398 .media(AttachmentMedia::new().src("preview.png"))
1399 .content(
1400 AttachmentContent::new()
1401 .title(AttachmentTitle::new("report.pdf"))
1402 .description(AttachmentDescription::new("Uploading")),
1403 )
1404 .actions(AttachmentActions::new().child("Cancel"));
1405
1406 assert_eq!(attachment.status, AttachmentStatus::Uploading);
1407 assert_eq!(attachment.axis, Axis::Vertical);
1408 assert_eq!(attachment.size, Size::Small);
1409 assert!(attachment.media.is_some());
1410 assert!(attachment.content.is_some());
1411 assert!(attachment.actions.is_some());
1412
1413 attachment.layout_slots();
1414 assert_eq!(attachment.media.as_ref().unwrap().size, Some(Size::Small));
1415 assert_eq!(
1416 attachment.media.as_ref().unwrap().status,
1417 AttachmentStatus::Uploading
1418 );
1419 assert!(attachment.content.as_ref().unwrap().vertical_layout);
1420 assert!(attachment.actions.as_ref().unwrap().vertical_layout);
1421
1422 let handle = ScrollHandle::new();
1423 let group = AttachmentGroup::new("group")
1424 .track_scroll(&handle)
1425 .with_edge_fade(gpui::black())
1426 .child("first")
1427 .child("second");
1428 assert!(group.scroll_handle.is_some());
1429 assert_eq!(group.edge_fade, Some(gpui::black()));
1430 assert_eq!(group.children.len(), 2);
1431 assert!(AttachmentGroup::new("plain").scroll_handle.is_none());
1432 assert!(AttachmentGroup::new("plain").edge_fade.is_none());
1433 }
1434
1435 #[test]
1436 fn test_attachment_whole_card_click_builder() {
1437 assert!(Attachment::new().id.is_none());
1438 assert!(Attachment::new().on_click.is_none());
1439
1440 let clickable = Attachment::new()
1441 .id("report-attachment")
1442 .on_click(|_, _, _| {});
1443 assert_eq!(clickable.id, Some("report-attachment".into()));
1444 assert!(clickable.on_click.is_some());
1445 }
1446
1447 #[test]
1448 fn test_attachment_corner_controls_builder() {
1449 assert!(Attachment::new().on_remove.is_none());
1450 assert!(Attachment::new().on_retry.is_none());
1451
1452 let attachment = Attachment::new()
1453 .id("upload")
1454 .on_remove(|_, _, _| {})
1455 .on_retry(|_, _, _| {});
1456 assert!(attachment.on_remove.is_some());
1457 assert!(attachment.on_retry.is_some());
1458 }
1459
1460 #[test]
1461 fn test_attachment_retry_control_reaches_slots_only_while_failed() {
1462 let mut failed = Attachment::new()
1463 .id("upload")
1464 .status(AttachmentStatus::Failed)
1465 .on_retry(|_, _, _| {})
1466 .media(AttachmentMedia::new().src("preview.png"))
1467 .content(AttachmentContent::new().description(AttachmentDescription::new("Failed")));
1468 failed.layout_slots();
1469 assert!(failed.media.as_ref().unwrap().retry.is_some());
1470 assert!(failed.content.as_ref().unwrap().retry.is_some());
1471
1472 let mut complete = Attachment::new()
1475 .id("upload")
1476 .on_retry(|_, _, _| {})
1477 .media(AttachmentMedia::new().src("preview.png"));
1478 complete.layout_slots();
1479 assert!(complete.media.as_ref().unwrap().retry.is_none());
1480
1481 let mut anonymous = Attachment::new()
1482 .status(AttachmentStatus::Failed)
1483 .on_retry(|_, _, _| {})
1484 .media(AttachmentMedia::new().src("preview.png"));
1485 anonymous.layout_slots();
1486 assert!(anonymous.media.as_ref().unwrap().retry.is_none());
1487 }
1488
1489 #[test]
1490 fn test_attachment_progress_and_tooltip_builder() {
1491 assert!(Attachment::new().progress.is_none());
1492 assert!(Attachment::new().tooltip.is_none());
1493 assert_eq!(Attachment::new().progress(130.).progress, Some(100.));
1494 assert_eq!(Attachment::new().progress(-5.).progress, Some(0.));
1495 assert_eq!(
1496 Attachment::new().tooltip("Network error").tooltip,
1497 Some("Network error".into())
1498 );
1499
1500 let mut uploading = Attachment::new()
1502 .id("upload")
1503 .status(AttachmentStatus::Uploading)
1504 .progress(62.)
1505 .media(AttachmentMedia::new().src("preview.png"))
1506 .content(AttachmentContent::new().description(AttachmentDescription::new("Uploading")));
1507 uploading.layout_slots();
1508 assert_eq!(uploading.media.as_ref().unwrap().progress, Some(62.));
1509 assert_eq!(uploading.content.as_ref().unwrap().progress, Some(62.));
1510
1511 let mut processing = Attachment::new()
1512 .status(AttachmentStatus::Processing)
1513 .progress(62.)
1514 .media(AttachmentMedia::new().src("preview.png"));
1515 processing.layout_slots();
1516 assert!(processing.media.as_ref().unwrap().progress.is_none());
1517 }
1518
1519 #[test]
1520 fn test_attachment_image_tile_media_is_flush() {
1521 let mut tile = Attachment::new()
1522 .axis(Axis::Vertical)
1523 .media(AttachmentMedia::new().src("preview.png"));
1524 tile.layout_slots();
1525 assert!(tile.media.as_ref().unwrap().flush);
1526
1527 let mut card = Attachment::new()
1528 .axis(Axis::Vertical)
1529 .media(AttachmentMedia::new().src("preview.png"))
1530 .content(AttachmentContent::new().title(AttachmentTitle::new("preview.png")));
1531 card.layout_slots();
1532 assert!(!card.media.as_ref().unwrap().flush);
1533 }
1534
1535 #[test]
1536 fn test_attachment_defaults_and_status_helpers() {
1537 assert_eq!(Attachment::new().status, AttachmentStatus::Complete);
1538 assert_eq!(AttachmentStatus::default(), AttachmentStatus::Complete);
1539 assert!(AttachmentStatus::Pending.is_pending());
1540 assert!(AttachmentStatus::Uploading.is_in_progress());
1541 assert!(AttachmentStatus::Processing.is_processing());
1542 assert!(AttachmentStatus::Failed.is_failed());
1543 assert!(AttachmentStatus::Complete.is_complete());
1544 assert!(!AttachmentStatus::Complete.is_in_progress());
1545 }
1546
1547 #[test]
1548 fn test_attachment_slots_are_composable() {
1549 let media = AttachmentMedia::new().child("icon");
1550 assert_eq!(media.children.len(), 1);
1551
1552 let content = AttachmentContent::new()
1553 .title(AttachmentTitle::new("name"))
1554 .description(AttachmentDescription::new("Details"))
1555 .child("Custom progress");
1556 assert_eq!(content.children.len(), 3);
1557 assert!(matches!(
1558 content.children[0],
1559 AttachmentContentChild::Title(_)
1560 ));
1561 assert!(matches!(
1562 content.children[1],
1563 AttachmentContentChild::Description(_)
1564 ));
1565 assert!(matches!(
1566 content.children[2],
1567 AttachmentContentChild::Element(_)
1568 ));
1569
1570 let legacy = AttachmentContent::new().child(AttachmentTitle::new("legacy"));
1571 assert!(matches!(
1572 legacy.children[0],
1573 AttachmentContentChild::Element(_)
1574 ));
1575
1576 let actions = AttachmentActions::new().child("remove");
1577 assert_eq!(actions.children.len(), 1);
1578 }
1579
1580 #[test]
1581 fn test_attachment_typed_content_inherits_status() {
1582 let mut attachment = Attachment::new()
1583 .status(AttachmentStatus::Uploading)
1584 .content(
1585 AttachmentContent::new()
1586 .title(AttachmentTitle::new("report.pdf"))
1587 .description(AttachmentDescription::new("Uploading")),
1588 );
1589
1590 attachment.layout_slots();
1591
1592 let content = attachment.content.unwrap();
1593 let AttachmentContentChild::Title(title) = &content.children[0] else {
1594 panic!("expected the typed title slot");
1595 };
1596 assert_eq!(title.status, Some(AttachmentStatus::Uploading));
1597
1598 let AttachmentContentChild::Description(description) = &content.children[1] else {
1599 panic!("expected the typed description slot");
1600 };
1601 assert_eq!(description.status, Some(AttachmentStatus::Uploading));
1602 }
1603
1604 #[test]
1605 fn test_attachment_explicit_child_status_overrides_parent() {
1606 let mut attachment = Attachment::new().status(AttachmentStatus::Failed).content(
1607 AttachmentContent::new()
1608 .title(AttachmentTitle::new("report.pdf").status(AttachmentStatus::Processing))
1609 .description(
1610 AttachmentDescription::new("Previous upload completed")
1611 .status(AttachmentStatus::Complete),
1612 ),
1613 );
1614
1615 attachment.layout_slots();
1616
1617 let content = attachment.content.unwrap();
1618 let AttachmentContentChild::Title(title) = &content.children[0] else {
1619 panic!("expected the typed title slot");
1620 };
1621 assert_eq!(title.status, Some(AttachmentStatus::Processing));
1622
1623 let AttachmentContentChild::Description(description) = &content.children[1] else {
1624 panic!("expected the typed description slot");
1625 };
1626 assert_eq!(description.status, Some(AttachmentStatus::Complete));
1627 }
1628
1629 #[test]
1630 fn test_attachment_title_keeps_custom_shimmer_style() {
1631 let mut attachment = Attachment::new()
1632 .status(AttachmentStatus::Processing)
1633 .content(
1634 AttachmentContent::new().title(
1635 AttachmentTitle::new("report.pdf")
1636 .with_shimmer_style(ShimmerStyle::new().spread(0.45).reverse(true)),
1637 ),
1638 );
1639
1640 attachment.layout_slots();
1641
1642 let content = attachment.content.unwrap();
1643 let AttachmentContentChild::Title(title) = &content.children[0] else {
1644 panic!("expected the typed title slot");
1645 };
1646 assert_eq!(title.status, Some(AttachmentStatus::Processing));
1647 assert!(title.shimmer_style.is_some());
1648 }
1649
1650 #[test]
1651 fn test_attachment_media_preview_keeps_children_and_overlays() {
1652 let media = AttachmentMedia::new()
1653 .src("preview.png")
1654 .child("Existing overlay")
1655 .overlay("Centered overlay");
1656
1657 assert!(media.source.is_some());
1658 assert_eq!(media.children.len(), 1);
1659 assert_eq!(media.overlays.len(), 1);
1660 }
1661
1662 #[test]
1663 fn test_attachment_media_size_inherits_root_unless_explicit() {
1664 let slot = |size, status, axis, flush| SlotLayout {
1665 size,
1666 status,
1667 axis,
1668 flush,
1669 retry: None,
1670 progress: None,
1671 id: None,
1672 };
1673 let inherited = AttachmentMedia::new().layout(slot(
1674 Size::Small,
1675 AttachmentStatus::Complete,
1676 Axis::Vertical,
1677 true,
1678 ));
1679 assert_eq!(inherited.size, Some(Size::Small));
1680 assert_eq!(inherited.axis, Axis::Vertical);
1681 assert!(inherited.flush);
1682
1683 let explicit = AttachmentMedia::new().with_size(Size::XSmall).layout(slot(
1684 Size::Large,
1685 AttachmentStatus::Failed,
1686 Axis::Horizontal,
1687 false,
1688 ));
1689 assert_eq!(explicit.size, Some(Size::XSmall));
1690 assert_eq!(explicit.status, AttachmentStatus::Failed);
1691 }
1692
1693 #[test]
1694 fn test_attachment_group_builder() {
1695 let group = AttachmentGroup::new("attachments")
1696 .child("First")
1697 .child("Second");
1698
1699 assert_eq!(group.children.len(), 2);
1700 }
1701
1702 mod click_dispatch {
1703 use std::{cell::Cell, rc::Rc};
1704
1705 use gpui::{Context, Modifiers, Render, TestAppContext, point, px};
1706
1707 use super::super::*;
1708 use crate::button::Button;
1709
1710 struct AttachmentClickHarness {
1711 card_clicks: Rc<Cell<usize>>,
1712 action_clicks: Rc<Cell<usize>>,
1713 }
1714
1715 impl Render for AttachmentClickHarness {
1716 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1717 let card_clicks = self.card_clicks.clone();
1718 let action_clicks = self.action_clicks.clone();
1719
1720 Attachment::new()
1721 .id("attachment")
1722 .w(px(200.))
1723 .h(px(60.))
1724 .on_click(move |_, _, _| card_clicks.set(card_clicks.get() + 1))
1725 .actions(
1726 AttachmentActions::new().child(
1727 Button::new("open")
1728 .w(px(40.))
1729 .h(px(40.))
1730 .on_click(move |_, _, _| {
1731 action_clicks.set(action_clicks.get() + 1)
1732 }),
1733 ),
1734 )
1735 }
1736 }
1737
1738 #[gpui::test]
1739 fn whole_card_click_stays_below_the_actions(cx: &mut TestAppContext) {
1740 cx.update(crate::init);
1741 let card_clicks = Rc::new(Cell::new(0));
1742 let action_clicks = Rc::new(Cell::new(0));
1743 let (_, cx) = cx.add_window_view({
1744 let card_clicks = card_clicks.clone();
1745 let action_clicks = action_clicks.clone();
1746 move |_, _| AttachmentClickHarness {
1747 card_clicks,
1748 action_clicks,
1749 }
1750 });
1751 cx.update(|window, cx| window.draw(cx).clear(cx));
1752
1753 cx.simulate_click(point(px(20.), px(30.)), Modifiers::default());
1755 assert_eq!(action_clicks.get(), 1);
1756 assert_eq!(card_clicks.get(), 0);
1757
1758 cx.simulate_click(point(px(150.), px(30.)), Modifiers::default());
1760 assert_eq!(action_clicks.get(), 1);
1761 assert_eq!(card_clicks.get(), 1);
1762 }
1763 }
1764
1765 mod retry_dispatch {
1766 use std::{cell::Cell, rc::Rc};
1767
1768 use gpui::{
1769 Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, TestAppContext,
1770 VisualTestContext, point, px,
1771 };
1772
1773 use super::super::*;
1774
1775 struct FailedMediaHarness {
1776 retry: bool,
1777 retries: Rc<Cell<usize>>,
1778 }
1779
1780 impl Render for FailedMediaHarness {
1781 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1782 let retries = self.retries.clone();
1783 Attachment::new()
1784 .id("failed-attachment")
1785 .status(AttachmentStatus::Failed)
1786 .media(AttachmentMedia::new())
1787 .when(self.retry, |attachment| {
1788 attachment.on_retry(move |_, _, _| retries.set(retries.get() + 1))
1789 })
1790 }
1791 }
1792
1793 fn harness(
1794 cx: &mut TestAppContext,
1795 retry: bool,
1796 ) -> (&mut VisualTestContext, Rc<Cell<usize>>) {
1797 cx.update(crate::init);
1798 let retries = Rc::new(Cell::new(0));
1799 let (_, cx) = cx.add_window_view({
1800 let retries = retries.clone();
1801 move |_, _| FailedMediaHarness { retry, retries }
1802 });
1803 cx.update(|window, cx| window.draw(cx).clear(cx));
1804 (cx, retries)
1805 }
1806
1807 #[gpui::test]
1808 fn failed_media_without_source_retries_by_pointer_and_keyboard(cx: &mut TestAppContext) {
1809 let (cx, retries) = harness(cx, true);
1810 cx.simulate_click(point(px(20.), px(20.)), Modifiers::default());
1811 assert_eq!(retries.get(), 1);
1812
1813 cx.update(|window, cx| window.focus_next(cx));
1814 cx.update(|window, cx| assert!(window.focused(cx).is_some()));
1815 let keystroke = Keystroke::parse("enter").unwrap();
1816 cx.simulate_event(KeyDownEvent {
1817 keystroke: keystroke.clone(),
1818 is_held: false,
1819 prefer_character_input: false,
1820 });
1821 cx.simulate_event(KeyUpEvent { keystroke });
1822 assert_eq!(retries.get(), 2);
1823 }
1824
1825 #[gpui::test]
1826 fn failed_media_without_retry_has_no_action(cx: &mut TestAppContext) {
1827 let (cx, retries) = harness(cx, false);
1828 cx.simulate_click(point(px(20.), px(20.)), Modifiers::default());
1829 assert_eq!(retries.get(), 0);
1830 cx.update(|window, cx| window.focus_next(cx));
1831 cx.update(|window, cx| assert!(window.focused(cx).is_none()));
1832 }
1833 }
1834}