1use std::rc::Rc;
2
3use gpui::{
4 AnyElement, App, Axis, ClickEvent, ElementId, ImageSource, InteractiveElement as _,
5 IntoElement, MouseButton, ObjectFit, ParentElement, RenderOnce, SharedString,
6 StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage as _, Window, div, img,
7 prelude::FluentBuilder as _, relative, rems,
8};
9
10use crate::{
11 ActiveTheme as _, InteractiveElementExt as _, Sizable, Size, StyledExt as _, h_flex,
12 shimmer::{ShimmerStyle, ShimmerText},
13 v_flex,
14};
15
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18pub enum AttachmentStatus {
19 Pending,
21 Uploading,
23 Processing,
25 Failed,
27 #[default]
29 Complete,
30}
31
32impl AttachmentStatus {
33 pub fn is_pending(self) -> bool {
35 matches!(self, Self::Pending)
36 }
37
38 pub fn is_uploading(self) -> bool {
40 matches!(self, Self::Uploading)
41 }
42
43 pub fn is_processing(self) -> bool {
45 matches!(self, Self::Processing)
46 }
47
48 pub fn is_failed(self) -> bool {
50 matches!(self, Self::Failed)
51 }
52
53 pub fn is_complete(self) -> bool {
55 matches!(self, Self::Complete)
56 }
57
58 pub fn is_in_progress(self) -> bool {
60 matches!(self, Self::Uploading | Self::Processing)
61 }
62}
63
64#[derive(IntoElement)]
66pub struct Attachment {
67 id: Option<ElementId>,
68 style: StyleRefinement,
69 status: AttachmentStatus,
70 size: Size,
71 axis: Axis,
72 media: Option<AttachmentMedia>,
73 content: Option<AttachmentContent>,
74 actions: Option<AttachmentActions>,
75 on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
76}
77
78impl Attachment {
79 pub fn new() -> Self {
81 Self {
82 id: None,
83 style: StyleRefinement::default(),
84 status: AttachmentStatus::Complete,
85 size: Size::Medium,
86 axis: Axis::Horizontal,
87 media: None,
88 content: None,
89 actions: None,
90 on_click: None,
91 }
92 }
93
94 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
96 self.id = Some(id.into());
97 self
98 }
99
100 pub fn on_click(
106 mut self,
107 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
108 ) -> Self {
109 self.on_click = Some(Rc::new(handler));
110 self
111 }
112
113 pub fn status(mut self, status: AttachmentStatus) -> Self {
115 self.status = status;
116 self
117 }
118
119 pub fn axis(mut self, axis: Axis) -> Self {
121 self.axis = axis;
122 self
123 }
124
125 pub fn media(mut self, media: AttachmentMedia) -> Self {
127 self.media = Some(media);
128 self
129 }
130
131 pub fn content(mut self, content: AttachmentContent) -> Self {
133 self.content = Some(content);
134 self
135 }
136
137 pub fn actions(mut self, actions: AttachmentActions) -> Self {
139 self.actions = Some(actions);
140 self
141 }
142}
143
144impl Default for Attachment {
145 fn default() -> Self {
146 Self::new()
147 }
148}
149
150impl Sizable for Attachment {
151 fn with_size(mut self, size: impl Into<Size>) -> Self {
152 self.size = size.into();
153 self
154 }
155}
156
157impl Styled for Attachment {
158 fn style(&mut self) -> &mut StyleRefinement {
159 &mut self.style
160 }
161}
162
163impl Attachment {
164 fn layout_slots(&mut self) {
165 let size = self.size;
166 let axis = self.axis;
167 let status = self.status;
168
169 self.media = self
170 .media
171 .take()
172 .map(|media| media.layout(size, status, axis));
173 self.content = self
174 .content
175 .take()
176 .map(|content| content.layout(axis, status));
177 self.actions = self
178 .actions
179 .take()
180 .map(|actions| actions.layout_for_axis(axis));
181 }
182}
183
184impl RenderOnce for Attachment {
185 fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
186 let tokens = cx.theme().semantic_tokens();
187 let size = self.size;
188 let axis = self.axis;
189 let status = self.status;
190 let has_media = self.media.is_some();
191 let has_content = self.content.is_some();
192 let clickable = self.id.is_some() && self.on_click.is_some();
193
194 self.layout_slots();
195
196 div()
197 .relative()
198 .flex()
199 .flex_none()
200 .max_w_full()
201 .min_w_0()
202 .rounded(if size == Size::XSmall {
203 tokens.radius.xl
204 } else {
205 cx.theme().radius_2xl()
206 })
207 .border_1()
208 .border_color(if status.is_failed() {
209 tokens.colors.destructive.opacity(0.3)
210 } else {
211 tokens.colors.border
212 })
213 .when(status.is_pending(), |this| this.border_dashed())
214 .bg(tokens.colors.background)
215 .text_color(tokens.colors.foreground)
216 .hover(move |style| {
219 if clickable {
220 style.bg(tokens.colors.muted.opacity(0.5))
221 } else {
222 style
223 }
224 })
225 .line_height(relative(1.25))
226 .map(|this| attachment_size_style(this, size, has_media, has_content))
227 .map(|this| match axis {
228 Axis::Horizontal => this.min_w_40().items_center(),
229 Axis::Vertical => this
230 .when(has_content, |this| this.w(rems(7.5)))
231 .when(!has_content, |this| this.w_24())
232 .flex_col()
233 .items_start(),
234 })
235 .when_some(self.media, |this, media| this.child(media))
236 .when_some(self.content, |this, content| this.child(content))
237 .when_some(self.id.zip(self.on_click), |this, (id, on_click)| {
238 this.child(
241 div()
242 .id(id)
243 .absolute()
244 .inset_0()
245 .on_click(move |event, window, cx| on_click(event, window, cx)),
246 )
247 })
248 .when_some(self.actions, |this, actions| this.child(actions))
249 .refine_style(&self.style)
250 }
251}
252
253#[derive(IntoElement)]
258pub struct AttachmentMedia {
259 style: StyleRefinement,
260 size: Option<Size>,
261 status: AttachmentStatus,
262 axis: Axis,
263 source: Option<ImageSource>,
264 children: Vec<AnyElement>,
265}
266
267impl AttachmentMedia {
268 pub fn new() -> Self {
270 Self {
271 style: StyleRefinement::default(),
272 size: None,
273 status: AttachmentStatus::Complete,
274 axis: Axis::Horizontal,
275 source: None,
276 children: Vec::new(),
277 }
278 }
279
280 pub fn src(mut self, source: impl Into<ImageSource>) -> Self {
282 self.source = Some(source.into());
283 self
284 }
285
286 pub fn overlay(mut self, overlay: impl IntoElement) -> Self {
288 self.children.push(
289 div()
290 .absolute()
291 .inset_0()
292 .flex()
293 .items_center()
294 .justify_center()
295 .child(overlay)
296 .into_any_element(),
297 );
298 self
299 }
300
301 fn layout(mut self, size: Size, status: AttachmentStatus, axis: Axis) -> Self {
302 if self.size.is_none() {
303 self.size = Some(size);
304 }
305 self.status = status;
306 self.axis = axis;
307 self
308 }
309}
310
311impl Default for AttachmentMedia {
312 fn default() -> Self {
313 Self::new()
314 }
315}
316
317impl ParentElement for AttachmentMedia {
318 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
319 self.children.extend(elements);
320 }
321}
322
323impl Sizable for AttachmentMedia {
324 fn with_size(mut self, size: impl Into<Size>) -> Self {
325 self.size = Some(size.into());
326 self
327 }
328}
329
330impl Styled for AttachmentMedia {
331 fn style(&mut self) -> &mut StyleRefinement {
332 &mut self.style
333 }
334}
335
336impl RenderOnce for AttachmentMedia {
337 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
338 let tokens = cx.theme().semantic_tokens();
339 let resolved_size = self.size.unwrap_or_default();
340 let radius = match resolved_size {
341 Size::XSmall => tokens.radius.sm,
342 Size::Small | Size::Medium | Size::Large | Size::Size(_) => tokens.radius.md,
343 };
344 let source = self.source;
345 let has_source = source.is_some();
346 let failed_media = self.status.is_failed() && !has_source;
347 let dimmed_image = has_source
348 && !matches!(
349 self.status,
350 AttachmentStatus::Pending | AttachmentStatus::Complete
351 );
352 let children = self.children;
353
354 div()
355 .relative()
356 .flex()
357 .flex_shrink_0()
358 .items_center()
359 .justify_center()
360 .overflow_hidden()
361 .when(self.axis == Axis::Horizontal, |this| match resolved_size {
362 Size::XSmall => this.size_7(),
363 Size::Small => this.size_8(),
364 Size::Medium => this.size_10(),
365 Size::Large => this.size_12(),
366 Size::Size(size) => this.size(size),
367 })
368 .when(self.axis == Axis::Vertical, |this| {
369 this.w_full().aspect_ratio(1.)
370 })
371 .rounded(radius)
372 .bg(if failed_media {
373 tokens.colors.destructive.opacity(0.1)
374 } else {
375 tokens.colors.muted
376 })
377 .text_color(if failed_media {
378 tokens.colors.destructive
379 } else {
380 tokens.colors.foreground
381 })
382 .when_some(source, |this, source| {
383 this.child(
384 img(source)
385 .absolute()
386 .inset_0()
387 .size_full()
388 .object_fit(ObjectFit::Cover)
389 .when(dimmed_image, |this| this.opacity(0.6)),
390 )
391 })
392 .children(children)
393 .refine_style(&self.style)
394 }
395}
396
397#[derive(IntoElement)]
399pub struct AttachmentContent {
400 style: StyleRefinement,
401 vertical_layout: bool,
402 children: Vec<AttachmentContentChild>,
403}
404
405enum AttachmentContentChild {
406 Title(AttachmentTitle),
407 Description(AttachmentDescription),
408 Element(AnyElement),
409}
410
411impl AttachmentContent {
412 pub fn new() -> Self {
414 Self {
415 style: StyleRefinement::default(),
416 vertical_layout: false,
417 children: Vec::new(),
418 }
419 }
420
421 pub fn title(mut self, title: AttachmentTitle) -> Self {
423 self.children.push(AttachmentContentChild::Title(title));
424 self
425 }
426
427 pub fn description(mut self, description: AttachmentDescription) -> Self {
429 self.children
430 .push(AttachmentContentChild::Description(description));
431 self
432 }
433
434 fn layout(mut self, axis: Axis, status: AttachmentStatus) -> Self {
435 self.vertical_layout = axis == Axis::Vertical;
436
437 for child in &mut self.children {
438 match child {
439 AttachmentContentChild::Title(title) => {
440 if title.status.is_none() {
441 title.status = Some(status);
442 }
443 }
444 AttachmentContentChild::Description(description) => {
445 if description.status.is_none() {
446 description.status = Some(status);
447 }
448 }
449 AttachmentContentChild::Element(_) => {}
450 }
451 }
452
453 self
454 }
455}
456
457impl Default for AttachmentContent {
458 fn default() -> Self {
459 Self::new()
460 }
461}
462
463impl ParentElement for AttachmentContent {
464 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
465 self.children
466 .extend(elements.into_iter().map(AttachmentContentChild::Element));
467 }
468}
469
470impl Styled for AttachmentContent {
471 fn style(&mut self) -> &mut StyleRefinement {
472 &mut self.style
473 }
474}
475
476impl RenderOnce for AttachmentContent {
477 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
478 v_flex()
479 .max_w_full()
480 .min_w_0()
481 .flex_1()
482 .gap_0p5()
483 .line_height(relative(1.25))
484 .when(self.vertical_layout, |this| this.w_full().px_1())
485 .children(self.children.into_iter().map(|child| match child {
486 AttachmentContentChild::Title(title) => title.into_any_element(),
487 AttachmentContentChild::Description(description) => description.into_any_element(),
488 AttachmentContentChild::Element(element) => element,
489 }))
490 .refine_style(&self.style)
491 }
492}
493
494#[derive(IntoElement)]
496pub struct AttachmentTitle {
497 style: StyleRefinement,
498 text: SharedString,
499 status: Option<AttachmentStatus>,
500 shimmer_style: Option<ShimmerStyle>,
501}
502
503impl AttachmentTitle {
504 pub fn new(text: impl Into<SharedString>) -> Self {
506 Self {
507 style: StyleRefinement::default(),
508 text: text.into(),
509 status: None,
510 shimmer_style: None,
511 }
512 }
513
514 pub fn status(mut self, status: AttachmentStatus) -> Self {
516 self.status = Some(status);
517 self
518 }
519
520 pub fn with_shimmer_style(mut self, style: ShimmerStyle) -> Self {
522 self.shimmer_style = Some(style);
523 self
524 }
525}
526
527impl Styled for AttachmentTitle {
528 fn style(&mut self) -> &mut StyleRefinement {
529 &mut self.style
530 }
531}
532
533impl RenderOnce for AttachmentTitle {
534 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
535 let loading = self.status.is_some_and(AttachmentStatus::is_in_progress);
536
537 div()
538 .max_w_full()
539 .min_w_0()
540 .truncate()
541 .font_medium()
542 .map(|this| {
543 if loading {
544 this.child(
545 ShimmerText::new(self.text).when_some(self.shimmer_style, |this, style| {
546 this.with_shimmer_style(style)
547 }),
548 )
549 } else {
550 this.child(self.text)
551 }
552 })
553 .refine_style(&self.style)
554 }
555}
556
557#[derive(IntoElement)]
559pub struct AttachmentDescription {
560 style: StyleRefinement,
561 text: SharedString,
562 status: Option<AttachmentStatus>,
563}
564
565impl AttachmentDescription {
566 pub fn new(text: impl Into<SharedString>) -> Self {
568 Self {
569 style: StyleRefinement::default(),
570 text: text.into(),
571 status: None,
572 }
573 }
574
575 pub fn status(mut self, status: AttachmentStatus) -> Self {
577 self.status = Some(status);
578 self
579 }
580}
581
582impl Styled for AttachmentDescription {
583 fn style(&mut self) -> &mut StyleRefinement {
584 &mut self.style
585 }
586}
587
588impl RenderOnce for AttachmentDescription {
589 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
590 let tokens = cx.theme().semantic_tokens();
591 let color = self
592 .status
593 .is_some_and(AttachmentStatus::is_failed)
594 .then(|| tokens.colors.destructive.opacity(0.8))
595 .unwrap_or(tokens.colors.muted_foreground);
596
597 div()
598 .max_w_full()
599 .min_w_0()
600 .truncate()
601 .text_xs()
602 .line_height(relative(1.25))
603 .text_color(color)
604 .child(self.text)
605 .refine_style(&self.style)
606 }
607}
608
609#[derive(IntoElement)]
614pub struct AttachmentActions {
615 style: StyleRefinement,
616 vertical_layout: bool,
617 children: Vec<AnyElement>,
618}
619
620impl AttachmentActions {
621 pub fn new() -> Self {
623 Self {
624 style: StyleRefinement::default(),
625 vertical_layout: false,
626 children: Vec::new(),
627 }
628 }
629
630 fn layout_for_axis(mut self, axis: Axis) -> Self {
631 self.vertical_layout = axis == Axis::Vertical;
632 self
633 }
634}
635
636impl Default for AttachmentActions {
637 fn default() -> Self {
638 Self::new()
639 }
640}
641
642impl ParentElement for AttachmentActions {
643 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
644 self.children.extend(elements);
645 }
646}
647
648impl Styled for AttachmentActions {
649 fn style(&mut self) -> &mut StyleRefinement {
650 &mut self.style
651 }
652}
653
654impl RenderOnce for AttachmentActions {
655 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
656 div()
657 .relative()
658 .flex()
659 .flex_shrink_0()
660 .items_center()
661 .gap_1()
662 .when(self.vertical_layout, |this| {
663 this.absolute().top_3().right_3()
664 })
665 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
671 .children(self.children)
672 .refine_style(&self.style)
673 }
674}
675
676fn attachment_size_style<T: Styled + gpui::prelude::FluentBuilder>(
677 element: T,
678 size: Size,
679 has_media: bool,
680 has_content: bool,
681) -> T {
682 match size {
683 Size::XSmall => element
684 .gap_1p5()
685 .text_xs()
686 .when(has_content, |this| this.px_1p5().py_1())
687 .when(has_media, |this| this.p_1()),
688 Size::Small => element
689 .gap_2p5()
690 .text_xs()
691 .when(has_content, |this| this.px_2().py_1p5())
692 .when(has_media, |this| this.p_1p5()),
693 Size::Medium => element
694 .gap_2()
695 .text_sm()
696 .when(has_content, |this| this.px_2p5().py_2())
697 .when(has_media, |this| this.p_2()),
698 Size::Large => element
699 .gap_3()
700 .text_base()
701 .when(has_content, |this| this.px_4().py_3())
702 .when(has_media, |this| this.p_3()),
703 Size::Size(value) => element
704 .gap_1()
705 .text_size(value * 0.875)
706 .when(has_content || has_media, |this| this.p(value * 0.25)),
707 }
708}
709
710#[derive(IntoElement)]
712pub struct AttachmentGroup {
713 id: ElementId,
714 style: StyleRefinement,
715 children: Vec<AnyElement>,
716}
717
718impl AttachmentGroup {
719 pub fn new(id: impl Into<ElementId>) -> Self {
721 Self {
722 id: id.into(),
723 style: StyleRefinement::default(),
724 children: Vec::new(),
725 }
726 }
727}
728
729impl ParentElement for AttachmentGroup {
730 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
731 self.children.extend(elements);
732 }
733}
734
735impl Styled for AttachmentGroup {
736 fn style(&mut self) -> &mut StyleRefinement {
737 &mut self.style
738 }
739}
740
741impl RenderOnce for AttachmentGroup {
742 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
743 h_flex()
744 .id(self.id)
745 .w_full()
746 .min_w_0()
747 .gap_3()
748 .py_1()
749 .overflow_x_scroll()
750 .lock_scroll_axis()
751 .refine_style(&self.style)
752 .children(self.children)
753 }
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759
760 #[test]
761 fn test_attachment_builder() {
762 let mut attachment = Attachment::new()
763 .status(AttachmentStatus::Uploading)
764 .axis(Axis::Vertical)
765 .with_size(Size::Small)
766 .media(AttachmentMedia::new().src("preview.png"))
767 .content(
768 AttachmentContent::new()
769 .title(AttachmentTitle::new("report.pdf"))
770 .description(AttachmentDescription::new("Uploading")),
771 )
772 .actions(AttachmentActions::new().child("Cancel"));
773
774 assert_eq!(attachment.status, AttachmentStatus::Uploading);
775 assert_eq!(attachment.axis, Axis::Vertical);
776 assert_eq!(attachment.size, Size::Small);
777 assert!(attachment.media.is_some());
778 assert!(attachment.content.is_some());
779 assert!(attachment.actions.is_some());
780
781 attachment.layout_slots();
782 assert_eq!(attachment.media.as_ref().unwrap().size, Some(Size::Small));
783 assert_eq!(
784 attachment.media.as_ref().unwrap().status,
785 AttachmentStatus::Uploading
786 );
787 assert!(attachment.content.as_ref().unwrap().vertical_layout);
788 assert!(attachment.actions.as_ref().unwrap().vertical_layout);
789 }
790
791 #[test]
792 fn test_attachment_whole_card_click_builder() {
793 assert!(Attachment::new().id.is_none());
794 assert!(Attachment::new().on_click.is_none());
795
796 let clickable = Attachment::new()
797 .id("report-attachment")
798 .on_click(|_, _, _| {});
799 assert_eq!(clickable.id, Some("report-attachment".into()));
800 assert!(clickable.on_click.is_some());
801 }
802
803 #[test]
804 fn test_attachment_defaults_and_status_helpers() {
805 assert_eq!(Attachment::new().status, AttachmentStatus::Complete);
806 assert_eq!(AttachmentStatus::default(), AttachmentStatus::Complete);
807 assert!(AttachmentStatus::Pending.is_pending());
808 assert!(AttachmentStatus::Uploading.is_in_progress());
809 assert!(AttachmentStatus::Processing.is_processing());
810 assert!(AttachmentStatus::Failed.is_failed());
811 assert!(AttachmentStatus::Complete.is_complete());
812 assert!(!AttachmentStatus::Complete.is_in_progress());
813 }
814
815 #[test]
816 fn test_attachment_slots_are_composable() {
817 let media = AttachmentMedia::new().child("icon");
818 assert_eq!(media.children.len(), 1);
819
820 let content = AttachmentContent::new()
821 .title(AttachmentTitle::new("name"))
822 .description(AttachmentDescription::new("Details"))
823 .child("Custom progress");
824 assert_eq!(content.children.len(), 3);
825 assert!(matches!(
826 content.children[0],
827 AttachmentContentChild::Title(_)
828 ));
829 assert!(matches!(
830 content.children[1],
831 AttachmentContentChild::Description(_)
832 ));
833 assert!(matches!(
834 content.children[2],
835 AttachmentContentChild::Element(_)
836 ));
837
838 let legacy = AttachmentContent::new().child(AttachmentTitle::new("legacy"));
839 assert!(matches!(
840 legacy.children[0],
841 AttachmentContentChild::Element(_)
842 ));
843
844 let actions = AttachmentActions::new().child("remove");
845 assert_eq!(actions.children.len(), 1);
846 }
847
848 #[test]
849 fn test_attachment_typed_content_inherits_status() {
850 let mut attachment = Attachment::new()
851 .status(AttachmentStatus::Uploading)
852 .content(
853 AttachmentContent::new()
854 .title(AttachmentTitle::new("report.pdf"))
855 .description(AttachmentDescription::new("Uploading")),
856 );
857
858 attachment.layout_slots();
859
860 let content = attachment.content.unwrap();
861 let AttachmentContentChild::Title(title) = &content.children[0] else {
862 panic!("expected the typed title slot");
863 };
864 assert_eq!(title.status, Some(AttachmentStatus::Uploading));
865
866 let AttachmentContentChild::Description(description) = &content.children[1] else {
867 panic!("expected the typed description slot");
868 };
869 assert_eq!(description.status, Some(AttachmentStatus::Uploading));
870 }
871
872 #[test]
873 fn test_attachment_explicit_child_status_overrides_parent() {
874 let mut attachment = Attachment::new().status(AttachmentStatus::Failed).content(
875 AttachmentContent::new()
876 .title(AttachmentTitle::new("report.pdf").status(AttachmentStatus::Processing))
877 .description(
878 AttachmentDescription::new("Previous upload completed")
879 .status(AttachmentStatus::Complete),
880 ),
881 );
882
883 attachment.layout_slots();
884
885 let content = attachment.content.unwrap();
886 let AttachmentContentChild::Title(title) = &content.children[0] else {
887 panic!("expected the typed title slot");
888 };
889 assert_eq!(title.status, Some(AttachmentStatus::Processing));
890
891 let AttachmentContentChild::Description(description) = &content.children[1] else {
892 panic!("expected the typed description slot");
893 };
894 assert_eq!(description.status, Some(AttachmentStatus::Complete));
895 }
896
897 #[test]
898 fn test_attachment_title_keeps_custom_shimmer_style() {
899 let mut attachment = Attachment::new()
900 .status(AttachmentStatus::Processing)
901 .content(
902 AttachmentContent::new().title(
903 AttachmentTitle::new("report.pdf")
904 .with_shimmer_style(ShimmerStyle::new().spread(0.45).reverse(true)),
905 ),
906 );
907
908 attachment.layout_slots();
909
910 let content = attachment.content.unwrap();
911 let AttachmentContentChild::Title(title) = &content.children[0] else {
912 panic!("expected the typed title slot");
913 };
914 assert_eq!(title.status, Some(AttachmentStatus::Processing));
915 assert!(title.shimmer_style.is_some());
916 }
917
918 #[test]
919 fn test_attachment_media_preview_keeps_children_and_overlays() {
920 let media = AttachmentMedia::new()
921 .src("preview.png")
922 .child("Existing overlay")
923 .overlay("Centered overlay");
924
925 assert!(media.source.is_some());
926 assert_eq!(media.children.len(), 2);
927 }
928
929 #[test]
930 fn test_attachment_media_size_inherits_root_unless_explicit() {
931 let inherited =
932 AttachmentMedia::new().layout(Size::Small, AttachmentStatus::Complete, Axis::Vertical);
933 assert_eq!(inherited.size, Some(Size::Small));
934 assert_eq!(inherited.axis, Axis::Vertical);
935
936 let explicit = AttachmentMedia::new().with_size(Size::XSmall).layout(
937 Size::Large,
938 AttachmentStatus::Failed,
939 Axis::Horizontal,
940 );
941 assert_eq!(explicit.size, Some(Size::XSmall));
942 assert_eq!(explicit.status, AttachmentStatus::Failed);
943 }
944
945 #[test]
946 fn test_attachment_group_builder() {
947 let group = AttachmentGroup::new("attachments")
948 .child("First")
949 .child("Second");
950
951 assert_eq!(group.children.len(), 2);
952 }
953
954 mod click_dispatch {
955 use std::{cell::Cell, rc::Rc};
956
957 use gpui::{Context, Modifiers, Render, TestAppContext, point, px};
958
959 use super::super::*;
960 use crate::button::Button;
961
962 struct AttachmentClickHarness {
963 card_clicks: Rc<Cell<usize>>,
964 action_clicks: Rc<Cell<usize>>,
965 }
966
967 impl Render for AttachmentClickHarness {
968 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
969 let card_clicks = self.card_clicks.clone();
970 let action_clicks = self.action_clicks.clone();
971
972 Attachment::new()
973 .id("attachment")
974 .w(px(200.))
975 .h(px(60.))
976 .on_click(move |_, _, _| card_clicks.set(card_clicks.get() + 1))
977 .actions(
978 AttachmentActions::new().child(
979 Button::new("open")
980 .w(px(40.))
981 .h(px(40.))
982 .on_click(move |_, _, _| {
983 action_clicks.set(action_clicks.get() + 1)
984 }),
985 ),
986 )
987 }
988 }
989
990 #[gpui::test]
991 fn whole_card_click_stays_below_the_actions(cx: &mut TestAppContext) {
992 cx.update(crate::init);
993 let card_clicks = Rc::new(Cell::new(0));
994 let action_clicks = Rc::new(Cell::new(0));
995 let (_, cx) = cx.add_window_view({
996 let card_clicks = card_clicks.clone();
997 let action_clicks = action_clicks.clone();
998 move |_, _| AttachmentClickHarness {
999 card_clicks,
1000 action_clicks,
1001 }
1002 });
1003 cx.update(|window, cx| window.draw(cx).clear(cx));
1004
1005 cx.simulate_click(point(px(20.), px(30.)), Modifiers::default());
1007 assert_eq!(action_clicks.get(), 1);
1008 assert_eq!(card_clicks.get(), 0);
1009
1010 cx.simulate_click(point(px(150.), px(30.)), Modifiers::default());
1012 assert_eq!(action_clicks.get(), 1);
1013 assert_eq!(card_clicks.get(), 1);
1014 }
1015 }
1016}