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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum AttachmentKind {
25 Image,
27 Document,
29 Spreadsheet,
31 Presentation,
33 Code,
35 Video,
37 #[default]
39 Other,
40}
41
42struct KindRow {
44 kind: AttachmentKind,
45 label: &'static str,
46 icon: IconName,
47 extensions: &'static [&'static str],
48}
49
50const 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 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 pub fn icon(self) -> IconName {
127 self.row().icon
128 }
129
130 pub fn label(self) -> &'static str {
132 self.row().label
133 }
134}
135
136#[derive(Clone, Copy, Debug, PartialEq)]
138#[non_exhaustive]
139pub enum AttachmentStatus {
140 Ready,
142 Queued,
144 Uploading(f32),
148}
149
150fn clamp_percent(value: f32) -> f32 {
153 if value.is_nan() {
154 0.
155 } else {
156 value.clamp(0., 100.)
157 }
158}
159
160#[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 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 pub fn kind(mut self, kind: AttachmentKind) -> Self {
200 self.kind = kind;
201 self
202 }
203
204 pub fn progress(mut self, progress: impl Into<Option<f32>>) -> Self {
206 self.progress = progress.into().map(clamp_percent);
207 self
208 }
209
210 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 pub fn id(&self) -> &SharedString {
220 &self.id
221 }
222
223 pub fn name(&self) -> &SharedString {
225 &self.name
226 }
227
228 pub fn kind_of(&self) -> AttachmentKind {
230 self.kind
231 }
232
233 pub fn progress_percent(&self) -> Option<f32> {
235 self.progress
236 }
237
238 pub fn set_progress(&mut self, progress: Option<f32>) {
240 self.progress = progress.map(clamp_percent);
241 }
242
243 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
253struct 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#[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 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 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#[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 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 pub fn attachments(mut self, attachments: impl IntoIterator<Item = Attachment>) -> Self {
519 self.attachments = attachments.into_iter().collect();
520 self
521 }
522
523 pub fn items(&self) -> &[Attachment] {
525 &self.attachments
526 }
527
528 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}