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);