Skip to main content

gpui_component/
attachment.rs

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