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