Skip to main content

gpui_component/
attachment.rs

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/// The lifecycle status of an attachment.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18pub enum AttachmentStatus {
19    /// The attachment has been selected and is waiting to be uploaded.
20    Pending,
21    /// The attachment is currently being uploaded.
22    Uploading,
23    /// The upload has completed and the attachment is being processed.
24    Processing,
25    /// The attachment failed to upload or process.
26    Failed,
27    /// The attachment is ready.
28    #[default]
29    Complete,
30}
31
32impl AttachmentStatus {
33    /// Returns whether the attachment is waiting to start.
34    pub fn is_pending(self) -> bool {
35        matches!(self, Self::Pending)
36    }
37
38    /// Returns whether the attachment is being uploaded.
39    pub fn is_uploading(self) -> bool {
40        matches!(self, Self::Uploading)
41    }
42
43    /// Returns whether the attachment is being processed.
44    pub fn is_processing(self) -> bool {
45        matches!(self, Self::Processing)
46    }
47
48    /// Returns whether the attachment has failed.
49    pub fn is_failed(self) -> bool {
50        matches!(self, Self::Failed)
51    }
52
53    /// Returns whether the attachment is ready.
54    pub fn is_complete(self) -> bool {
55        matches!(self, Self::Complete)
56    }
57
58    /// Returns whether the attachment is in an in-progress state.
59    pub fn is_in_progress(self) -> bool {
60        matches!(self, Self::Uploading | Self::Processing)
61    }
62}
63
64/// A file or image attachment composed from media, content, and actions slots.
65#[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    /// Create an attachment in the [`AttachmentStatus::Complete`] state.
80    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    /// Set a stable identity for the whole-card click layer.
95    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
96        self.id = Some(id.into());
97        self
98    }
99
100    /// Make the whole card clickable, e.g. to open a preview.
101    ///
102    /// The click layer is painted below the actions slot, so action buttons
103    /// stay independently clickable. Click state needs a stable identity, so
104    /// the handler takes effect only together with [`Self::id`].
105    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    /// Set the attachment lifecycle status.
114    pub fn status(mut self, status: AttachmentStatus) -> Self {
115        self.status = status;
116        self
117    }
118
119    /// Set the attachment layout axis.
120    pub fn axis(mut self, axis: Axis) -> Self {
121        self.axis = axis;
122        self
123    }
124
125    /// Set the media slot.
126    pub fn media(mut self, media: AttachmentMedia) -> Self {
127        self.media = Some(media);
128        self
129    }
130
131    /// Set the metadata content slot.
132    pub fn content(mut self, content: AttachmentContent) -> Self {
133        self.content = Some(content);
134        self
135    }
136
137    /// Set the actions slot.
138    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            // Register `hover` unconditionally: a conditionally registered
217            // hover style stays cached when the condition later flips off.
218            .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                // The click layer is painted before the actions slot, so the
239                // actions' hitboxes stay on top and their buttons keep working.
240                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/// The media slot for an attachment.
254///
255/// Add an icon or another element as a child for an icon-style preview. Use
256/// [`Self::src`] when the attachment has an image preview.
257#[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    /// Create an empty media slot.
269    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    /// Set an image preview source.
281    pub fn src(mut self, source: impl Into<ImageSource>) -> Self {
282        self.source = Some(source.into());
283        self
284    }
285
286    /// Add centered content above the preview without dimming it during loading.
287    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/// The metadata slot for an attachment.
398#[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    /// Create an empty metadata slot.
413    pub fn new() -> Self {
414        Self {
415            style: StyleRefinement::default(),
416            vertical_layout: false,
417            children: Vec::new(),
418        }
419    }
420
421    /// Add a title that automatically inherits the attachment lifecycle status.
422    pub fn title(mut self, title: AttachmentTitle) -> Self {
423        self.children.push(AttachmentContentChild::Title(title));
424        self
425    }
426
427    /// Add a description that automatically inherits the attachment lifecycle status.
428    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/// A single-line attachment title.
495#[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    /// Create an attachment title.
505    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    /// Override the attachment lifecycle status used for the loading shimmer.
515    pub fn status(mut self, status: AttachmentStatus) -> Self {
516        self.status = Some(status);
517        self
518    }
519
520    /// Customize the shimmer used while this attachment is uploading or processing.
521    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/// A single-line attachment description or status message.
558#[derive(IntoElement)]
559pub struct AttachmentDescription {
560    style: StyleRefinement,
561    text: SharedString,
562    status: Option<AttachmentStatus>,
563}
564
565impl AttachmentDescription {
566    /// Create an attachment description.
567    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    /// Set the status used for the semantic description color.
576    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/// A composition slot for attachment actions.
610///
611/// Add existing [`crate::button::Button`] or other controls as children. A
612/// separate attachment-specific action wrapper is intentionally unnecessary.
613#[derive(IntoElement)]
614pub struct AttachmentActions {
615    style: StyleRefinement,
616    vertical_layout: bool,
617    children: Vec<AnyElement>,
618}
619
620impl AttachmentActions {
621    /// Create an empty actions slot.
622    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            // The actions cluster owns its presses: an action (or the gap
666            // between actions) must not also arm the whole-card click layer
667            // below, mirroring the shadcn stacking where actions sit above
668            // the trigger. Buttons run first in the bubble phase, so they
669            // are unaffected.
670            .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/// A horizontally scrollable row of attachments.
711#[derive(IntoElement)]
712pub struct AttachmentGroup {
713    id: ElementId,
714    style: StyleRefinement,
715    children: Vec<AnyElement>,
716}
717
718impl AttachmentGroup {
719    /// Create an empty attachment group with a stable scroll identifier.
720    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            // A click on an action must not also fire the whole-card handler.
1006            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            // A click elsewhere on the card fires the whole-card handler.
1011            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}