Skip to main content

herogpui_components/
avatar.rs

1//! Avatar — port of `@heroui/avatar` (v3.2.6, Radix Avatar 1.2.6 semantics).
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use gpui::prelude::FluentBuilder;
7use gpui::{
8    px, App, ElementId, ImageCacheError, ImageSource, ImgResourceLoader, InteractiveElement,
9    IntoElement, ParentElement, Pixels, RenderImage, RenderOnce, Resource, SharedString, Styled,
10    Window,
11};
12use herogpui_core::{element_id, Color};
13use herogpui_theme::ActiveTheme;
14
15/// `Avatar.Image.onError` — v3's `(event) => void`, with no event payload to
16/// hand over, exactly the shape `Table.onLoadMore` and `Input.onClear` use.
17type OnImageError = Arc<dyn Fn(&mut Window, &mut App) + 'static>;
18
19/// `Avatar.Image.onLoad` — same ported shape as [`OnImageError`].
20type OnImageLoad = Arc<dyn Fn(&mut Window, &mut App) + 'static>;
21
22/// Per-instance image state, keyed in the window under the avatar's instance
23/// id alone. Radix Avatar tracks pending/loaded/errored per component
24/// instance: two avatars pointing at the same source fire their own
25/// callbacks and run their own `delay_ms` windows, even though gpui
26/// deduplicates the underlying asset load per source. The image latches reset
27/// on a source change; the fallback timer does not, because `Avatar.Fallback`
28/// remains mounted while `Avatar.Image` changes.
29#[derive(Clone, Default)]
30struct AvatarImageState {
31    /// The source identity whose image latches this slot tracks. A different
32    /// identity resets those latches and bumps [`AvatarImageState::generation`].
33    source: Option<SourceIdentity>,
34    /// Bumped on every source change. Tasks spawned for one image identity
35    /// must not fire a load latch after the identity changed under them.
36    generation: u32,
37    /// The mounted fallback's `delay_ms` task is armed (running or done).
38    armed: bool,
39    /// The fallback's `delay_ms` window (if any) has elapsed since mount.
40    delay_elapsed: bool,
41    /// The load has failed; `on_error` has been fired exactly once.
42    errored: bool,
43    /// The load has succeeded; `on_load` has been fired exactly once.
44    loaded: bool,
45}
46
47/// Which of `size`/`color`/`variant` the caller set on this avatar. An
48/// [`crate::avatar_group::AvatarGroup`] fills only the omitted ones on its
49/// direct children (`size ?? group.size`); a direct prop always wins.
50#[derive(Clone, Copy, Default)]
51struct Explicit {
52    size: bool,
53    color: bool,
54    variant: bool,
55}
56
57/// The stacked-layout decoration an [`crate::avatar_group::AvatarGroup`]
58/// applies to one direct child (`.avatar-group > .avatar + .avatar` and the
59/// overlap modifiers). Crate-internal: it is not a v3 Avatar prop.
60#[derive(Clone, Copy, Default)]
61pub(crate) struct GroupDecor {
62    /// `margin-inline-start: calc(-1 * var(--avatar-group-overlap))`.
63    pub(crate) margin_start: Option<Pixels>,
64    /// A `0 0 0 <seam>` box-shadow ring in this colour.
65    pub(crate) seam: Option<(Pixels, gpui::Hsla)>,
66    /// `.avatar__fallback { padding-inline-end }` — the clip mode's optical
67    /// nudge on every avatar except the last.
68    pub(crate) fallback_pad_end: Option<Pixels>,
69}
70
71/// Fill of an avatar fallback (`variant`).
72#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
73pub enum AvatarVariant {
74    /// The pinned base fill (`bg-default`); the color recolours the initials.
75    #[default]
76    Default,
77    /// The color's soft wash with `text-{role}-soft-foreground` initials.
78    Soft,
79}
80
81impl AvatarVariant {
82    /// Every avatar variant, in declaration order.
83    pub const ALL: [AvatarVariant; 2] = [AvatarVariant::Default, AvatarVariant::Soft];
84
85    /// A human-readable label for this variant.
86    pub fn label(self) -> &'static str {
87        match self {
88            AvatarVariant::Default => "Default",
89            AvatarVariant::Soft => "Soft",
90        }
91    }
92}
93
94/// HeroUI Avatar: image or name-initials fallback.
95#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
96#[derive(IntoElement)]
97pub struct Avatar {
98    /// The instance's element id, required at construction. The image
99    /// lifecycle state (`delay_ms` window, `on_error`/`on_load` latches) is
100    /// keyed under it, so two avatars rendered on one page must carry
101    /// distinct ids here or they share one state slot.
102    id: ElementId,
103    name: SharedString,
104    source: Option<ImageSource>,
105    custom_source_key: Option<SharedString>,
106    on_error: Option<OnImageError>,
107    on_load: Option<OnImageLoad>,
108    /// `Avatar.Fallback.delayMs`, in milliseconds.
109    fallback_delay_ms: Option<u64>,
110    /// `Avatar.Fallback` children — an icon, a `+N` counter, any element.
111    fallback: Option<gpui::AnyElement>,
112    /// `Avatar.Fallback.color` — overrides the parent color for everything
113    /// the fallback paints.
114    fallback_color: Option<Color>,
115    /// Edge length, set by [`Avatar::size`]. v3 has no custom-pixel prop.
116    size_px: Pixels,
117    /// Whether [`Avatar::size`] was `Sm`, which rounds one step tighter.
118    small: bool,
119    /// Whether [`Avatar::size`] was `Lg`, whose fallback text steps up.
120    large: bool,
121    color: Color,
122    variant: AvatarVariant,
123    /// The corner radius, in place of the size's `rounded-3xl`
124    /// (`rounded-2xl` on `Sm`).
125    radius: Option<Pixels>,
126    /// The `sx` slot, refined over the root style at the end of render.
127    sx: Option<Box<gpui::StyleRefinement>>,
128    /// Which of `size`/`color`/`variant` the caller set.
129    explicit: Explicit,
130    /// Group layout decoration, set only by `AvatarGroup`.
131    group: GroupDecor,
132}
133
134impl Avatar {
135    /// The instance's element id is a constructor argument, not an optional
136    /// builder: image lifecycle state is keyed under it, and a defaulted
137    /// literal would silently merge same-source siblings into one instance.
138    pub fn new(id: impl Into<ElementId>) -> Self {
139        Self {
140            id: id.into(),
141            name: "".into(),
142            source: None,
143            custom_source_key: None,
144            on_error: None,
145            on_load: None,
146            fallback_delay_ms: None,
147            fallback: None,
148            fallback_color: None,
149            size_px: px(40.),
150            small: false,
151            large: false,
152            color: Color::Default,
153            variant: AvatarVariant::Default,
154            radius: None,
155            sx: None,
156            explicit: Explicit::default(),
157            group: GroupDecor::default(),
158        }
159    }
160
161    /// `AvatarGroupContext`: fills the props this avatar omitted with the
162    /// group's (`size ?? group.size`, `color ?? group.color`,
163    /// `variant ?? group.variant`). A prop set directly always wins.
164    pub(crate) fn inherit(
165        mut self,
166        size: Option<herogpui_core::Size>,
167        color: Option<Color>,
168        variant: Option<AvatarVariant>,
169    ) -> Self {
170        let explicit = self.explicit;
171        if let (false, Some(size)) = (explicit.size, size) {
172            self = self.size(size);
173        }
174        if let (false, Some(color)) = (explicit.color, color) {
175            self = self.color(color);
176        }
177        if let (false, Some(variant)) = (explicit.variant, variant) {
178            self = self.variant(variant);
179        }
180        // Inheriting is not the caller setting the prop.
181        self.explicit = explicit;
182        self
183    }
184
185    /// Applies the group's stacked-layout decoration.
186    pub(crate) fn group_decor(mut self, decor: GroupDecor) -> Self {
187        self.group = decor;
188        self
189    }
190
191    /// Sets the name the avatar represents.
192    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
193        self.name = name.into();
194        self
195    }
196
197    /// Sets the avatar variant.
198    pub fn variant(mut self, variant: AvatarVariant) -> Self {
199        self.variant = variant;
200        self.explicit.variant = true;
201        self
202    }
203
204    /// `Avatar.Image.src` — a plain string or path loads through gpui's asset
205    /// system (a parseable URI is fetched, anything else is an embedded
206    /// resource); a [`gpui::ImageSource::Image`], `Render` or `Custom` is the
207    /// explicit gpui part for images the app already holds or loads itself.
208    pub fn src(mut self, src: impl Into<ImageSource>) -> Self {
209        self.source = Some(src.into());
210        self
211    }
212
213    /// Sets the stable logical identity for a custom [`ImageSource`] loader.
214    /// Rebuilding an equivalent loader per frame keeps the same lifecycle when
215    /// this is omitted; change the key when the loader starts serving a new
216    /// logical image so its load and error latches reset.
217    pub fn custom_source_key(mut self, key: impl Into<SharedString>) -> Self {
218        self.custom_source_key = Some(key.into());
219        self
220    }
221
222    /// `Avatar.Image.onError` — callback when the image fails to load. The
223    /// fallback initials replace the image on that same failure.
224    pub fn on_error(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
225        self.on_error = Some(Arc::new(f));
226        self
227    }
228
229    /// `Avatar.Image.onLoad` — callback when the image has loaded and
230    /// replaces the fallback.
231    pub fn on_load(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
232        self.on_load = Some(Arc::new(f));
233        self
234    }
235
236    /// `Avatar.Fallback.delayMs` — hold the fallback back this many
237    /// milliseconds **from fallback mount**, so a slow load does not flash the
238    /// initials behind it (v3: "Delay before showing fallback (prevents
239    /// flash)"). A success renders before the window ends; a failure or source
240    /// change inside it waits for the same window — it does not restart it.
241    pub fn delay_ms(mut self, ms: u64) -> Self {
242        self.fallback_delay_ms = Some(ms);
243        self
244    }
245
246    /// `Avatar.Fallback` children — replaces the name initials with any
247    /// element (an icon, a `+N` counter, custom text).
248    pub fn fallback(mut self, content: impl IntoElement) -> Self {
249        self.fallback = Some(content.into_any_element());
250        self
251    }
252
253    /// `Avatar.Fallback.color` — overrides the parent [`Avatar::color`] for
254    /// the fallback's soft-foreground text and soft fill. It is its own
255    /// documented prop, not an alias of the parent's color.
256    pub fn fallback_color(mut self, c: Color) -> Self {
257        self.fallback_color = Some(c);
258        self
259    }
260
261    /// Sets the avatar size.
262    pub fn size(mut self, size: herogpui_core::Size) -> Self {
263        self.size_px = match size {
264            herogpui_core::Size::Sm => px(32.),
265            herogpui_core::Size::Md => px(40.),
266            herogpui_core::Size::Lg => px(48.),
267        };
268        // `.avatar--sm` is `rounded-2xl` where the other two are `rounded-3xl`:
269        // at 32px a 24px radius would be all but a circle, so v3 steps it down.
270        self.small = size == herogpui_core::Size::Sm;
271        self.large = size == herogpui_core::Size::Lg;
272        self.explicit.size = true;
273        self
274    }
275
276    /// Sets the color role.
277    pub fn color(mut self, c: Color) -> Self {
278        self.color = c;
279        self.explicit.color = true;
280        self
281    }
282
283    /// The corner radius, in place of the size's `rounded-3xl`
284    /// (`rounded-2xl` on `Sm`). Not a v3 prop; the removed v2 `radius` prop is
285    /// prohibited and this is a per-component repository extension.
286    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
287        self.radius = Some(radius.into());
288        self
289    }
290
291    /// The one slot for caller-owned low-level styling: GPUI's styling methods
292    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
293    /// applied to the avatar's root element after every value the variant, the
294    /// color and the active theme chose, so they win.
295    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
296        crate::util::refine_sx(&mut self.sx, style);
297        self
298    }
299
300    /// The initials `name` renders when no [`Avatar::fallback`] children are
301    /// set: the uppercase first characters of the first two words, or `?`
302    /// when there is nothing to take a character from.
303    pub fn initials(name: &str) -> String {
304        let words: Vec<&str> = name.split_whitespace().collect();
305        let mut out = String::new();
306        for w in words.iter().take(2) {
307            if let Some(c) = w.chars().next() {
308                out.extend(c.to_uppercase());
309            }
310        }
311        if out.is_empty() {
312            "?".to_owned()
313        } else {
314            out
315        }
316    }
317}
318
319/// The stable identity of one avatar's image source. Every resource and
320/// image variant compares by value: the location, the image content id, or
321/// the render image's allocation id. A `Custom` loader has no stable value
322/// identity — a closure rebuilt inline each frame gets a fresh allocation
323/// every time — so it shares the instance's single lifecycle slot unless the
324/// caller supplies an explicit logical source key.
325#[derive(Clone, PartialEq)]
326enum SourceIdentity {
327    Resource(Resource),
328    Image(u64),
329    Render(usize),
330    Custom(Option<SharedString>),
331}
332
333/// What the avatar keys its lifecycle state against this frame.
334fn source_identity(
335    source: &ImageSource,
336    custom_source_key: Option<&SharedString>,
337) -> SourceIdentity {
338    match source {
339        ImageSource::Resource(resource) => SourceIdentity::Resource(resource.clone()),
340        ImageSource::Image(image) => SourceIdentity::Image(image.id()),
341        ImageSource::Render(image) => SourceIdentity::Render(image.id.0),
342        ImageSource::Custom(_) => SourceIdentity::Custom(custom_source_key.cloned()),
343    }
344}
345
346/// What the source reports this frame: `Some(Ok)` loaded, `Some(Err)` failed,
347/// `None` still pending. `use_asset` (not `get_asset`) is the call that also
348/// arranges a redraw once a resource load settles.
349/// Whether an observed load/error completion may report.
350///
351/// A completion observed while its source is mounted must still lose when the
352/// source changed before the reporting task runs: switching sources bumps
353/// [`AvatarImageState::generation`], so a task carrying the older generation
354/// is stale. The `already` half keeps `on_load`/`on_error` firing exactly
355/// once. Pure so the stale-callback contract has deterministic unit coverage
356/// independent of executor timing (a synchronously-decoding source reports
357/// while still mounted; a pending one must not report after unmount).
358fn first_completion(already: bool, state_generation: u32, observed_generation: u32) -> bool {
359    !already && state_generation == observed_generation
360}
361
362fn observe_load(
363    source: &ImageSource,
364    window: &mut Window,
365    cx: &mut App,
366) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
367    match source {
368        ImageSource::Resource(resource) => window.use_asset::<ImgResourceLoader>(resource, cx),
369        ImageSource::Image(image) => image.clone().use_render_image(window, cx).map(Ok),
370        ImageSource::Render(image) => Some(Ok(image.clone())),
371        ImageSource::Custom(loader) => loader(window, cx),
372    }
373}
374
375impl RenderOnce for Avatar {
376    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
377        let colors = cx.colors();
378        // `Avatar.Fallback.color` overrides the parent color for everything
379        // the fallback paints; the wiring is explicit so the parent's
380        // same-named `color` never silently stands in for it.
381        let fb_role = cx.role(self.fallback_color.unwrap_or(self.color));
382        // Both variants paint the fallback text `text-{role}-soft-foreground`;
383        // for `--default` that resolves to `--default-foreground`.
384        let soft_fg = fb_role.soft_foreground(colors.foreground);
385        let (bg, fallback_bg) = match self.variant {
386            // `.avatar` fills `bg-default` for every color: the color recolours
387            // the initials, never the fill.
388            AvatarVariant::Default => (colors.default.color, colors.default.color),
389            // `.avatar--soft` clears the base fill and the fallback paints
390            // `bg-{role}-soft` with the same soft foreground.
391            AvatarVariant::Soft => (gpui::transparent_black(), fb_role.soft()),
392        };
393        // `.avatar__fallback` is `text-sm`; `.avatar--sm .avatar__fallback`
394        // steps the fallback text down to `text-xs` and
395        // `.avatar--lg .avatar__fallback` steps it up to `text-base`.
396        let font = if self.large {
397            px(16.)
398        } else if self.small {
399            px(12.)
400        } else {
401            px(14.)
402        };
403        let leading = if self.large {
404            px(24.)
405        } else if self.small {
406            px(16.)
407        } else {
408            px(20.)
409        };
410        let radius = self.radius.unwrap_or_else(|| {
411            if self.small {
412                crate::util::soft_radius(cx)
413            } else {
414                crate::util::control_radius(cx)
415            }
416        });
417        let sx_corners = crate::util::sx_radius(&self.sx);
418
419        let el = gpui::div()
420            .relative()
421            .flex()
422            .items_center()
423            .justify_center()
424            .size(self.size_px)
425            .rounded(radius)
426            .bg(bg)
427            .text_color(soft_fg)
428            .text_size(font)
429            .line_height(leading)
430            .font_weight(gpui::FontWeight::MEDIUM)
431            .overflow_hidden()
432            .flex_shrink_0();
433        // Test-only lookup keys mirroring the upstream BEM modifiers
434        // (`avatar--sm`, `avatar--soft`, `avatar__fallback--accent`); a no-op
435        // outside gpui's `test-support` builds.
436        let size_key = if self.small {
437            "sm"
438        } else if self.large {
439            "lg"
440        } else {
441            "md"
442        };
443        let variant_key = match self.variant {
444            AvatarVariant::Default => "default",
445            AvatarVariant::Soft => "soft",
446        };
447        let debug_id = self.id.clone();
448        let el = el.debug_selector(|| format!("avatar[{debug_id}]--{size_key}--{variant_key}"));
449        // `AvatarGroup` stacked layout: the sibling overlap margin and the
450        // `--background` seam ring (see `avatar_group` for the clip caveat).
451        let group = self.group;
452        let el = el
453            .when_some(group.margin_start, |el, m| el.ml(-m))
454            .when_some(group.seam, |el, (seam, color)| {
455                el.shadow(vec![gpui::BoxShadow {
456                    color,
457                    offset: gpui::point(px(0.), px(0.)),
458                    blur_radius: px(0.),
459                    spread_radius: seam,
460                    inset: false,
461                }])
462            });
463
464        let fallback_content: gpui::AnyElement = match self.fallback {
465            Some(content) => content,
466            None => Avatar::initials(&self.name).into_any_element(),
467        };
468        // `.avatar__fallback` is `size-full bg-default` and relies on the
469        // avatar's `overflow-hidden` to clip its fill to the rounded corner.
470        // gpui clips a child to the parent's *rect*, not to its radius, so a
471        // square fill here squared off every avatar — the same reason the
472        // loaded image below carries the radius.
473        let fallback = gpui::div()
474            .flex()
475            .items_center()
476            .justify_center()
477            .size_full()
478            .rounded(radius)
479            .bg(fallback_bg)
480            .text_color(soft_fg)
481            .text_size(font)
482            .line_height(leading)
483            .font_weight(gpui::FontWeight::MEDIUM)
484            .when_some(group.fallback_pad_end, |el, pad| el.pr(pad))
485            .debug_selector(|| {
486                format!(
487                    "avatar__fallback[{}]--{}",
488                    self.id,
489                    self.fallback_color
490                        .unwrap_or(self.color)
491                        .label()
492                        .to_lowercase()
493                )
494            })
495            .child(fallback_content);
496        let fallback = crate::util::round_sx_corners(fallback, &sx_corners);
497
498        // Keep the image lifecycle slot alive even when the source is absent:
499        // image latches must reset when an image is removed and later re-added
500        // with the same source, while the mounted fallback timer survives.
501        let key = element_id::scoped(&self.id, "image");
502        let state = window.use_keyed_state(key, cx, |_, _| AvatarImageState::default());
503        let source = self.source;
504        let identity = source
505            .as_ref()
506            .map(|source| source_identity(source, self.custom_source_key.as_ref()));
507        state.update(cx, |state, _| {
508            if state.source.as_ref() != identity.as_ref() {
509                let armed = state.armed;
510                let delay_elapsed = state.delay_elapsed;
511                *state = AvatarImageState {
512                    source: identity,
513                    generation: state.generation + 1,
514                    armed,
515                    delay_elapsed,
516                    ..AvatarImageState::default()
517                };
518            }
519        });
520        let snapshot = state.read(cx).clone();
521
522        // `Avatar.Fallback` stays mounted for the life of this Avatar, so its
523        // delay starts once per instance, including when the image is added
524        // after the first render.
525        if self.fallback_delay_ms.is_some() && !snapshot.armed {
526            let weak = state.downgrade();
527            let delay_ms = self.fallback_delay_ms.unwrap_or_default();
528            window
529                .spawn(cx, async move |cx| {
530                    // The latch makes a double spawn (two frames before the
531                    // task first runs) wait-free.
532                    let start = weak
533                        .update(cx, |s, _| {
534                            if s.armed {
535                                None
536                            } else {
537                                s.armed = true;
538                                Some(())
539                            }
540                        })
541                        .unwrap_or(None);
542                    if start.is_some() {
543                        cx.background_executor()
544                            .timer(Duration::from_millis(delay_ms))
545                            .await;
546                        weak.update(cx, |s, cx| {
547                            s.delay_elapsed = true;
548                            cx.notify();
549                        })
550                        .ok();
551                    }
552                })
553                .detach();
554        }
555        let fallback_visible = self.fallback_delay_ms.is_none() || snapshot.delay_elapsed;
556
557        let el = match source {
558            None => {
559                if fallback_visible {
560                    el.child(fallback)
561                } else {
562                    el
563                }
564            }
565            Some(source) => {
566                // Radix Avatar 1.2.6 `getImageLoadingStatus`: a complete
567                // image whose `naturalWidth` is 0 is `error`, not `loaded`
568                // (1.1.11 left it `loading`), so a decoded zero-width image
569                // takes the failure path: fallback plus `on_error`.
570                let got = match observe_load(&source, window, cx) {
571                    Some(Ok(data)) if data.size(0).width.0 <= 0 => {
572                        Some(Err(ImageCacheError::Asset("zero-size avatar image".into())))
573                    }
574                    got => got,
575                };
576                match &got {
577                    // `Avatar.Image.onLoad` fires once, on the first observed
578                    // success, outside the layout phase.
579                    Some(Ok(_)) if !snapshot.loaded => {
580                        let weak = state.downgrade();
581                        let on_load = self.on_load.clone();
582                        let generation = snapshot.generation;
583                        window
584                            .spawn(cx, async move |cx| {
585                                let first = weak
586                                    .update(cx, |s, _| {
587                                        if first_completion(s.loaded, s.generation, generation) {
588                                            s.loaded = true;
589                                            true
590                                        } else {
591                                            false
592                                        }
593                                    })
594                                    .unwrap_or(false);
595                                if first {
596                                    let _ = cx.update(|window, cx| {
597                                        if let Some(on_load) = &on_load {
598                                            on_load(window, cx);
599                                        }
600                                    });
601                                }
602                            })
603                            .detach();
604                    }
605                    // `Avatar.Image.onError` fires once, on the first observed
606                    // failure, outside the layout phase.
607                    Some(Err(_)) if !snapshot.errored => {
608                        let weak = state.downgrade();
609                        let on_error = self.on_error.clone();
610                        let generation = snapshot.generation;
611                        window
612                            .spawn(cx, async move |cx| {
613                                let first = weak
614                                    .update(cx, |s, _| {
615                                        if first_completion(s.errored, s.generation, generation) {
616                                            s.errored = true;
617                                            true
618                                        } else {
619                                            false
620                                        }
621                                    })
622                                    .unwrap_or(false);
623                                if first {
624                                    let _ = cx.update(|window, cx| {
625                                        if let Some(on_error) = &on_error {
626                                            on_error(window, cx);
627                                        }
628                                    });
629                                }
630                            })
631                            .detach();
632                    }
633                    _ => {}
634                }
635
636                match got {
637                    // Success: the image replaces the fallback inside the
638                    // `.avatar__image` box.
639                    Some(Ok(data)) => el.child(crate::util::round_sx_corners(
640                        gpui::img(data)
641                            .absolute()
642                            .inset_0()
643                            .size_full()
644                            .rounded(radius),
645                        &sx_corners,
646                    )),
647                    // Pending and error both keep the fallback box; with a
648                    // `delay_ms` window still running the box stays empty.
649                    Some(Err(_)) | None => {
650                        if fallback_visible {
651                            el.child(fallback)
652                        } else {
653                            el
654                        }
655                    }
656                }
657            }
658        };
659        crate::util::apply_sx(el, &self.sx)
660    }
661}
662
663// The pinned `.avatar` fills `bg-default` for every color and paints the
664// initials `text-{role}-soft-foreground`; a solid role fill or a muted
665// fallback looks plausible on screen, so the check is mechanical.
666#[cfg(test)]
667mod fill_tokens {
668    #[test]
669    fn fallback_and_image_keep_the_roots_explicit_corners() {
670        use gpui::{px, AbsoluteLength, Styled};
671
672        let sx = Some(crate::util::capture_sx(|el| {
673            el.rounded_tl(px(0.)).rounded_br(px(3.))
674        }));
675        let corners = crate::util::sx_radius(&sx);
676        let mut image =
677            crate::util::round_sx_corners(gpui::img("avatar.png").rounded(px(8.)), &corners);
678        let radii = image.style().corner_radii.clone();
679        for (actual, expected) in [
680            (radii.top_left, 0.),
681            (radii.top_right, 8.),
682            (radii.bottom_left, 8.),
683            (radii.bottom_right, 3.),
684        ] {
685            assert_eq!(actual, Some(AbsoluteLength::Pixels(px(expected))));
686        }
687
688        let source = include_str!("avatar.rs")
689            .split("#[cfg(test)]")
690            .next()
691            .unwrap();
692        let compact: String = source.split_whitespace().collect();
693        for consumer in [
694            "letsx_corners=crate::util::sx_radius(&self.sx);",
695            "letfallback=crate::util::round_sx_corners(fallback,&sx_corners);",
696            "crate::util::round_sx_corners(gpui::img(data).absolute().inset_0().size_full().rounded(radius),&sx_corners,)",
697            "crate::util::apply_sx(el,&self.sx)",
698        ] {
699            assert!(compact.contains(consumer), "missing corner consumer: {consumer}");
700        }
701    }
702
703    #[test]
704    fn the_fills_and_foregrounds_follow_the_pinned_css() {
705        // Scan the implementation only; this test's own text names the
706        // forbidden accessors.
707        let source = include_str!("avatar.rs")
708            .split("#[cfg(test)]")
709            .next()
710            .expect("the implementation section is always present");
711        assert!(
712            source
713                .contains("AvatarVariant::Default => (colors.default.color, colors.default.color)"),
714            "the base avatar and its fallback slot must fill `bg-default` \
715             (pinned `.avatar` + `.avatar__fallback`)"
716        );
717        assert!(
718            source.contains("AvatarVariant::Soft => (gpui::transparent_black(), fb_role.soft())"),
719            "the soft avatar root must be transparent and its fallback slot \
720             must fill `bg-{{role}}-soft` (pinned `.avatar--soft`)"
721        );
722        assert!(
723            source.contains("cx.role(self.fallback_color.unwrap_or(self.color))"),
724            "`Avatar.Fallback.color` must override the parent color for the \
725             fallback painting, not alias it"
726        );
727        assert!(
728            source
729                .split_whitespace()
730                .collect::<String>()
731                .contains("letfont=ifself.large{px(16.)}elseifself.small{px(12.)}else{px(14.)};"),
732            "`.avatar__fallback` is `text-sm`, `.avatar--sm .avatar__fallback` \
733             steps it down to `text-xs` (12px) and `.avatar--lg \
734             .avatar__fallback` up to `text-base` (16px)"
735        );
736        assert!(
737            source.contains(".size_full()") && source.contains(".bg(fallback_bg)"),
738            "the fallback must be the pinned full-size fallback slot, not a \
739             raw child of the root"
740        );
741        let compact: String = source.split_whitespace().collect();
742        assert!(
743            compact.contains("gpui::img(data).absolute().inset_0().size_full().rounded(radius)"),
744            "the loaded image must carry the avatar radius because not every \
745             renderer clips image content at a rounded parent"
746        );
747        assert!(
748            !source.contains("colors().muted"),
749            "the soft default initials are `text-default-soft-foreground`, \
750             not the muted tone"
751        );
752        assert!(
753            !source.contains("surface_tertiary"),
754            "the base avatar fills `bg-default`, not a surface level"
755        );
756        assert!(
757            !source.contains("(fb_role.color, fb_role.foreground)"),
758            "the base avatar never paints a solid role fill"
759        );
760    }
761}
762
763// The stale-callback guard, pinned without executor timing: a completion
764// observed while its source is mounted reports exactly once, and a source
765// switch before the reporting task runs suppresses it.
766#[cfg(test)]
767mod outcome_guard {
768    use super::first_completion;
769
770    #[test]
771    fn fresh_completion_reports() {
772        assert!(first_completion(false, 0, 0));
773    }
774
775    #[test]
776    fn source_switch_before_report_suppresses() {
777        assert!(!first_completion(false, 1, 0));
778    }
779
780    #[test]
781    fn second_completion_never_reports() {
782        assert!(!first_completion(true, 1, 1));
783    }
784}
785
786crate::util::impl_component_styled!(Avatar);