Skip to main content

gpui_cn/composer/
attachment.rs

1use std::rc::Rc;
2
3use gpui_kit::{
4    App, ElementId, Hsla, ImageSource, InteractiveElement as _, IntoElement, ObjectFit,
5    ParentElement as _, Pixels, RenderOnce, SharedString, StyleRefinement, Styled,
6    StyledImage as _, TestSupportExt as _, Window,
7    assets::IconName,
8    base::{StyledExt as _, transition},
9    div, img,
10    prelude::FluentBuilder as _,
11};
12
13use crate::{ActiveTheme as _, Button, ButtonSize, Icon, Progress, Theme};
14
15type DismissHandler = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
16
17/// What kind of file an attachment is, which decides its icon.
18///
19/// The kinds come from one table: each row holds the kind's
20/// icon, its label, and the file extensions that map to it. A
21/// new kind is a row, not a new `match` arm.
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum AttachmentKind {
25    /// A picture, shown as its own thumbnail.
26    Image,
27    /// Text and documents.
28    Document,
29    /// Tables and sheets.
30    Spreadsheet,
31    /// Slide decks.
32    Presentation,
33    /// Source code.
34    Code,
35    /// Moving pictures.
36    Video,
37    /// Anything else. The default.
38    #[default]
39    Other,
40}
41
42/// One row of the kind table.
43struct KindRow {
44    kind: AttachmentKind,
45    label: &'static str,
46    icon: IconName,
47    extensions: &'static [&'static str],
48}
49
50/// The kind table. `Other` is last and has no extensions: it is what no
51/// other row claims.
52const KINDS: [KindRow; 7] = [
53    KindRow {
54        kind: AttachmentKind::Image,
55        label: "Image",
56        icon: IconName::Image,
57        extensions: &[
58            "png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "heic", "avif",
59        ],
60    },
61    KindRow {
62        kind: AttachmentKind::Document,
63        label: "Document",
64        icon: IconName::FileText,
65        extensions: &["txt", "md", "pdf", "doc", "docx", "rtf", "odt", "pages"],
66    },
67    KindRow {
68        kind: AttachmentKind::Spreadsheet,
69        label: "Spreadsheet",
70        icon: IconName::FileSpreadsheet,
71        extensions: &["csv", "tsv", "xls", "xlsx", "ods", "numbers"],
72    },
73    KindRow {
74        kind: AttachmentKind::Presentation,
75        label: "Presentation",
76        icon: IconName::Presentation,
77        extensions: &["ppt", "pptx", "odp", "key"],
78    },
79    KindRow {
80        kind: AttachmentKind::Code,
81        label: "Code",
82        icon: IconName::FileCode,
83        extensions: &[
84            "rs", "py", "js", "ts", "tsx", "jsx", "go", "java", "c", "h", "cpp", "swift", "rb",
85            "sh", "json", "toml", "yaml", "yml", "html", "css",
86        ],
87    },
88    KindRow {
89        kind: AttachmentKind::Video,
90        label: "Video",
91        icon: IconName::Video,
92        extensions: &["mp4", "mov", "mkv", "webm", "avi", "m4v"],
93    },
94    KindRow {
95        kind: AttachmentKind::Other,
96        label: "File",
97        icon: IconName::File,
98        extensions: &[],
99    },
100];
101
102impl AttachmentKind {
103    fn row(self) -> &'static KindRow {
104        KINDS
105            .iter()
106            .find(|row| row.kind == self)
107            .unwrap_or(&KINDS[KINDS.len() - 1])
108    }
109
110    /// The kind a file name belongs to, by its extension, compared without
111    /// regard to case. A name with no known extension is `Other`.
112    pub fn of_file_name(name: &str) -> Self {
113        let extension = name.rsplit_once('.').map(|(_, extension)| extension);
114        extension
115            .and_then(|extension| {
116                KINDS.iter().find(|row| {
117                    row.extensions
118                        .iter()
119                        .any(|known| known.eq_ignore_ascii_case(extension))
120                })
121            })
122            .map_or(Self::Other, |row| row.kind)
123    }
124
125    /// The icon of the kind.
126    pub fn icon(self) -> IconName {
127        self.row().icon
128    }
129
130    /// A short name for the kind, such as "Spreadsheet".
131    pub fn label(self) -> &'static str {
132        self.row().label
133    }
134}
135
136/// Where an attachment is in its life.
137#[derive(Clone, Copy, Debug, PartialEq)]
138#[non_exhaustive]
139pub enum AttachmentStatus {
140    /// Done, or never needed uploading.
141    Ready,
142    /// Waiting for its upload to start: progress is zero.
143    Queued,
144    /// Uploading, with the percentage done, above zero and up to 100. A
145    /// file at 100 is still uploading until the application sets its
146    /// progress to `None`, which is what lets Send wait for the last byte.
147    Uploading(f32),
148}
149
150/// A progress percentage held to `0..=100`, with a value that is not a
151/// number counting as zero, which is queued.
152fn clamp_percent(value: f32) -> f32 {
153    if value.is_nan() {
154        0.
155    } else {
156        value.clamp(0., 100.)
157    }
158}
159
160/// A file in a composer: what the strip shows for it.
161///
162/// The id comes from the domain and is unique in the composer. The
163/// progress is a percentage: `None` for a file that is ready, zero for one
164/// that is queued, and above zero while it uploads. Setting it back to
165/// `None` finishes the upload. A queued tile shows its ring at zero and
166/// can be removed, an uploading tile shows the counter and cannot, and a
167/// ready tile can be removed. Send waits for every attachment to be ready.
168///
169/// ```
170/// use gpui_cn::{Attachment, AttachmentKind};
171///
172/// let report = Attachment::new("a1", "report.xlsx").progress(55.);
173/// assert_eq!(report.kind_of(), AttachmentKind::Spreadsheet);
174/// ```
175#[derive(Clone)]
176#[non_exhaustive]
177pub struct Attachment {
178    id: SharedString,
179    name: SharedString,
180    kind: AttachmentKind,
181    progress: Option<f32>,
182    image: Option<ImageSource>,
183}
184
185impl Attachment {
186    /// A ready attachment. The kind comes from the name's extension.
187    pub fn new(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
188        let name = name.into();
189        Self {
190            id: id.into(),
191            kind: AttachmentKind::of_file_name(&name),
192            name,
193            progress: None,
194            image: None,
195        }
196    }
197
198    /// The kind, in place of the one the name's extension gives.
199    pub fn kind(mut self, kind: AttachmentKind) -> Self {
200        self.kind = kind;
201        self
202    }
203
204    /// The upload progress as a percentage, clamped to `0..=100`.
205    pub fn progress(mut self, progress: impl Into<Option<f32>>) -> Self {
206        self.progress = progress.into().map(clamp_percent);
207        self
208    }
209
210    /// The picture an image attachment shows as its thumbnail. It makes the
211    /// attachment an image.
212    pub fn image(mut self, source: impl Into<ImageSource>) -> Self {
213        self.image = Some(source.into());
214        self.kind = AttachmentKind::Image;
215        self
216    }
217
218    /// The id.
219    pub fn id(&self) -> &SharedString {
220        &self.id
221    }
222
223    /// The file name.
224    pub fn name(&self) -> &SharedString {
225        &self.name
226    }
227
228    /// The kind.
229    pub fn kind_of(&self) -> AttachmentKind {
230        self.kind
231    }
232
233    /// The upload progress, if the file is not ready.
234    pub fn progress_percent(&self) -> Option<f32> {
235        self.progress
236    }
237
238    /// Replaces the upload progress, as [`progress`](Self::progress).
239    pub fn set_progress(&mut self, progress: Option<f32>) {
240        self.progress = progress.map(clamp_percent);
241    }
242
243    /// Where the attachment is in its life.
244    pub fn status(&self) -> AttachmentStatus {
245        match self.progress {
246            None => AttachmentStatus::Ready,
247            Some(value) if value <= 0. => AttachmentStatus::Queued,
248            Some(value) => AttachmentStatus::Uploading(value),
249        }
250    }
251}
252
253/// The look of a tile, read from the theme in one borrow.
254struct Look {
255    side: Pixels,
256    text: gpui_kit::base::TextStyleToken,
257    radius: Pixels,
258    radius_full: Pixels,
259    busy_opacity: f32,
260    fill: Hsla,
261    border: Hsla,
262    tint: Hsla,
263    name: Hsla,
264    percent: Hsla,
265    frost: Hsla,
266    frost_glyph: Hsla,
267}
268
269/// One tile of the attachment strip: an image thumbnail, or a bordered
270/// file tile with a tinted kind icon and the name. A tile that uploads
271/// draws a circular [`Progress`](crate::Progress) at its top left and a
272/// percent counter at its top right; when the upload ends, the counter and
273/// the ring fade out as the dismiss button fades in.
274///
275/// ```
276/// use gpui_cn::{Attachment, AttachmentTile};
277///
278/// let _ = AttachmentTile::new("tile", Attachment::new("a1", "notes.md"))
279///     .on_dismiss(|id, _, _| println!("remove {id}"));
280/// ```
281///
282/// The id keys motion and the dismiss button, so it must be stable across
283/// frames.
284///
285/// The default icons (the mic, the shield, the file kinds, the git branch) are
286/// not in the default `gpui_kit::assets::Assets`: register
287/// [`ComposerAssets`](crate::ComposerAssets).
288#[derive(IntoElement)]
289#[non_exhaustive]
290pub struct AttachmentTile {
291    id: ElementId,
292    attachment: Attachment,
293    style: StyleRefinement,
294    on_dismiss: Option<DismissHandler>,
295}
296
297impl AttachmentTile {
298    /// A tile for `attachment` with a stable id.
299    pub fn new(id: impl Into<ElementId>, attachment: Attachment) -> Self {
300        Self {
301            id: id.into(),
302            attachment,
303            style: StyleRefinement::default(),
304            on_dismiss: None,
305        }
306    }
307
308    /// Called with the attachment's id when its dismiss button is pressed.
309    /// Without a handler there is no button.
310    pub fn on_dismiss(
311        mut self,
312        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
313    ) -> Self {
314        self.on_dismiss = Some(Rc::new(handler));
315        self
316    }
317}
318
319impl Styled for AttachmentTile {
320    fn style(&mut self) -> &mut StyleRefinement {
321        &mut self.style
322    }
323}
324
325impl RenderOnce for AttachmentTile {
326    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
327        let slide = Theme::global(cx).motion.slide_transition();
328        let attachment = self.attachment;
329        let status = attachment.status();
330        let kind = attachment.kind;
331        let look = {
332            let theme = cx.theme();
333            let metrics = &theme.metrics;
334            Look {
335                side: metrics.attachment_tile,
336                text: theme.base.typography.xs,
337                radius: theme.radius_lg(),
338                radius_full: theme.radius_full(),
339                busy_opacity: theme.disabled_opacity,
340                fill: theme.field,
341                border: theme.field_border,
342                tint: theme.muted_foreground(),
343                name: theme.muted_foreground(),
344                percent: theme.foreground(),
345                frost: theme.scrim,
346                frost_glyph: theme.solid_foreground,
347            }
348        };
349        let child_id =
350            |name: &'static str| ElementId::NamedChild(self.id.clone().into(), name.into());
351        let uploading = matches!(status, AttachmentStatus::Uploading(_));
352        let busy = status != AttachmentStatus::Ready;
353        let shown = transition(
354            child_id("uploading"),
355            if uploading { 1. } else { 0. },
356            slide.clone(),
357            window,
358            cx,
359        );
360        let ring = transition(
361            child_id("ring-shown"),
362            if busy { 1. } else { 0. },
363            slide.clone(),
364            window,
365            cx,
366        );
367        let image = attachment.image.clone();
368        let has_image = image.is_some();
369        let percent = match status {
370            AttachmentStatus::Uploading(value) => value,
371            AttachmentStatus::Queued => 0.,
372            AttachmentStatus::Ready => 100.,
373        };
374        let dismiss = self.on_dismiss.filter(|_| !uploading).map(|on_dismiss| {
375            let id = attachment.id.clone();
376            let (fill, glyph) = if has_image {
377                (look.frost, look.frost_glyph)
378            } else {
379                (look.border, look.name)
380            };
381            Button::new(child_id("dismiss"))
382                .ghost()
383                .size(ButtonSize::Xs)
384                .icon(Icon::from(IconName::Close).size_3())
385                .accessibility_label(format!("Remove {}", attachment.name))
386                .absolute()
387                .top_1()
388                .right_1()
389                .p_0()
390                .size_4()
391                .rounded(look.radius_full)
392                .bg(fill)
393                .text_color(glyph)
394                .opacity(1. - shown)
395                .on_click(move |_, window, cx| on_dismiss(&id, window, cx))
396        });
397
398        div()
399            .id(self.id.clone())
400            .test_support()
401            .relative()
402            .flex_shrink_0()
403            .size(look.side)
404            .text_size(look.text.size)
405            .line_height(look.text.line_height)
406            .refine_style(&self.style)
407            .child(
408                div()
409                    .size_full()
410                    .rounded(look.radius)
411                    .overflow_hidden()
412                    .bg(look.fill)
413                    .border_1()
414                    .border_color(look.border)
415                    .map(|this| match image {
416                        Some(source) => this.child(
417                            img(source)
418                                .size_full()
419                                .rounded(look.radius)
420                                .object_fit(ObjectFit::Cover)
421                                .when(busy, |this| this.opacity(look.busy_opacity)),
422                        ),
423                        None => {
424                            this.child(
425                                div()
426                                    .size_full()
427                                    .flex()
428                                    .flex_col()
429                                    .items_center()
430                                    .justify_between()
431                                    .py_1()
432                                    .child(div().flex().flex_1().items_center().child(
433                                        Icon::from(kind.icon()).size_5().text_color(look.tint),
434                                    ))
435                                    .child(
436                                        div()
437                                            .w_full()
438                                            .px_1()
439                                            .truncate()
440                                            .text_center()
441                                            .text_color(look.name)
442                                            .when(busy, |this| this.opacity(look.busy_opacity))
443                                            .child(attachment.name.clone()),
444                                    ),
445                            )
446                        }
447                    }),
448            )
449            .when(ring > 0.001, |this| {
450                this.child(
451                    div()
452                        .id(child_id("arc"))
453                        .test_support()
454                        .absolute()
455                        .top_1()
456                        .left_1()
457                        .opacity(ring)
458                        .text_color(look.percent)
459                        .child(
460                            Progress::new(child_id("ring"))
461                                .circular()
462                                .value(percent)
463                                .accessibility_label("Upload progress"),
464                        ),
465                )
466                .when(shown > 0.001, |this| {
467                    this.child(
468                        div()
469                            .id(child_id("percent"))
470                            .test_support()
471                            .absolute()
472                            .top_1()
473                            .right_1()
474                            .opacity(shown)
475                            .text_color(look.percent)
476                            .child(format!("{percent:.0}%")),
477                    )
478                })
479            })
480            .children(dismiss)
481    }
482}
483
484/// The row of tiles above a composer's text: the attachments in order,
485/// wrapping onto further lines when they do not fit.
486///
487/// ```
488/// use gpui_cn::{Attachment, AttachmentStrip};
489///
490/// let _ = AttachmentStrip::new("files")
491///     .attachments([Attachment::new("a1", "notes.md")])
492///     .on_dismiss(|id, _, _| println!("remove {id}"));
493/// ```
494///
495/// A tile's id is derived from the strip's id and the attachment's own,
496/// so the tiles keep their motion when the list changes.
497#[derive(IntoElement)]
498#[non_exhaustive]
499pub struct AttachmentStrip {
500    id: ElementId,
501    attachments: Vec<Attachment>,
502    style: StyleRefinement,
503    on_dismiss: Option<DismissHandler>,
504}
505
506impl AttachmentStrip {
507    /// An empty strip with a stable id.
508    pub fn new(id: impl Into<ElementId>) -> Self {
509        Self {
510            id: id.into(),
511            attachments: Vec::new(),
512            style: StyleRefinement::default(),
513            on_dismiss: None,
514        }
515    }
516
517    /// The attachments to show, in order.
518    pub fn attachments(mut self, attachments: impl IntoIterator<Item = Attachment>) -> Self {
519        self.attachments = attachments.into_iter().collect();
520        self
521    }
522
523    /// The attachments the strip shows.
524    pub fn items(&self) -> &[Attachment] {
525        &self.attachments
526    }
527
528    /// Called with an attachment's id when its dismiss button is pressed.
529    pub fn on_dismiss(
530        mut self,
531        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
532    ) -> Self {
533        self.on_dismiss = Some(Rc::new(handler));
534        self
535    }
536}
537
538impl Styled for AttachmentStrip {
539    fn style(&mut self) -> &mut StyleRefinement {
540        &mut self.style
541    }
542}
543
544impl RenderOnce for AttachmentStrip {
545    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
546        let gap = cx.theme().base.spacing.sm;
547        let strip_id = self.id.clone();
548        let on_dismiss = self.on_dismiss;
549        div()
550            .id(self.id)
551            .test_support()
552            .flex()
553            .flex_wrap()
554            .gap(gap)
555            .refine_style(&self.style)
556            .children(self.attachments.into_iter().map(move |attachment| {
557                let id = ElementId::NamedChild(strip_id.clone().into(), attachment.id.clone());
558                let tile = AttachmentTile::new(id, attachment);
559                match &on_dismiss {
560                    Some(handler) => {
561                        let handler = handler.clone();
562                        tile.on_dismiss(move |id, window, cx| handler(id, window, cx))
563                    }
564                    None => tile,
565                }
566            }))
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn extensions_choose_the_kind_without_regard_to_case() {
576        assert_eq!(
577            AttachmentKind::of_file_name("shot.PNG"),
578            AttachmentKind::Image
579        );
580        assert_eq!(
581            AttachmentKind::of_file_name("notes.md"),
582            AttachmentKind::Document
583        );
584        assert_eq!(
585            AttachmentKind::of_file_name("q3.xlsx"),
586            AttachmentKind::Spreadsheet
587        );
588        assert_eq!(
589            AttachmentKind::of_file_name("deck.pptx"),
590            AttachmentKind::Presentation
591        );
592        assert_eq!(
593            AttachmentKind::of_file_name("main.rs"),
594            AttachmentKind::Code
595        );
596        assert_eq!(
597            AttachmentKind::of_file_name("clip.mov"),
598            AttachmentKind::Video
599        );
600        assert_eq!(
601            AttachmentKind::of_file_name("blob.xyz"),
602            AttachmentKind::Other
603        );
604        assert_eq!(
605            AttachmentKind::of_file_name("README"),
606            AttachmentKind::Other
607        );
608    }
609
610    #[test]
611    fn every_kind_has_one_row_and_the_table_ends_with_other() {
612        for row in &KINDS {
613            assert_eq!(
614                KINDS.iter().filter(|other| other.kind == row.kind).count(),
615                1
616            );
617        }
618        assert_eq!(KINDS[KINDS.len() - 1].kind, AttachmentKind::Other);
619        assert_eq!(AttachmentKind::Spreadsheet.label(), "Spreadsheet");
620    }
621
622    #[test]
623    fn progress_decides_the_status() {
624        let file = Attachment::new("a", "a.txt");
625        assert_eq!(file.status(), AttachmentStatus::Ready);
626        assert_eq!(file.clone().progress(0.).status(), AttachmentStatus::Queued);
627        assert_eq!(
628            file.clone().progress(55.).status(),
629            AttachmentStatus::Uploading(55.)
630        );
631        assert_eq!(
632            file.clone().progress(180.).progress_percent(),
633            Some(100.),
634            "clamped"
635        );
636        let mut file = file.progress(40.);
637        file.set_progress(None);
638        assert_eq!(file.status(), AttachmentStatus::Ready);
639    }
640
641    #[test]
642    fn a_progress_that_is_not_a_number_is_queued_and_100_is_still_uploading() {
643        let file = Attachment::new("a", "a.txt");
644        for bad in [f32::NAN, f32::NEG_INFINITY, -5.] {
645            assert_eq!(
646                file.clone().progress(bad).status(),
647                AttachmentStatus::Queued
648            );
649        }
650        assert_eq!(
651            file.clone().progress(f32::INFINITY).status(),
652            AttachmentStatus::Uploading(100.)
653        );
654        let mut file = file;
655        file.set_progress(Some(f32::NAN));
656        assert_eq!(file.status(), AttachmentStatus::Queued);
657        file.set_progress(Some(100.));
658        assert_eq!(
659            file.status(),
660            AttachmentStatus::Uploading(100.),
661            "ready only when None"
662        );
663    }
664
665    #[test]
666    fn an_image_source_makes_an_image_attachment() {
667        let image = Attachment::new("a", "a.bin").image(ImageSource::from("x.png"));
668        assert_eq!(image.kind_of(), AttachmentKind::Image);
669    }
670}