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