Skip to main content

teksilo_widgets/
avatar.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Avatar` — circular (or rounded-square / square) user-identity widget.
5//!
6//! Displays either a person's image (clipped to the configured shape via
7//! a CPU-side anti-aliased alpha mask applied at construction time) or
8//! their initials over a hash-derived background colour. Optional
9//! presence indicator (Online / Offline / Away / Busy) and outer ring.
10//! Can be made activable to serve as a user-menu trigger.
11//!
12//! ```rust
13//! # use teksilo_widgets::{Avatar, AvatarPresence, AvatarSize};
14//! # use teksilo_canvas::raster::RasterIcon;
15//! # use teksilo_i18n::lit;
16//! # use teksilo_core::Intent;
17//! # let face = RasterIcon::from_raw(vec![0u8; 4 * 4 * 4], 4, 4);
18//! // Image with a presence dot.
19//! let _w = Avatar::with_image(&face)
20//!     .alt(lit!("Jane Doe"))
21//!     .presence(AvatarPresence::Online)
22//!     .size(AvatarSize::Medium);
23//!
24//! // Hash-tinted initials, auto-derived from a name.
25//! let _w = Avatar::with_name(lit!("Jane Doe")).size(AvatarSize::Large);
26//!
27//! // Click target — opens a user menu via an intent.
28//! let _w = Avatar::with_image(&face)
29//!     .label(lit!("Open user menu"))
30//!     .alt(lit!("Jane Doe"))
31//!     .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.open-user-menu")));
32//! ```
33//!
34//! The widget reuses `ImageWidget` for the image path and hands bg /
35//! border / focus ring / presence to the active `AvatarStyle`, whose
36//! default (`RecipeAvatarStyle`) draws them via `Canvas`. Hash-derived
37//! background tints come from `theme.colors.chart_palette` (Okabe-Ito),
38//! so they track the active theme automatically.
39//!
40//! ## Touch and pen
41//!
42//! A tappable avatar activates on the release, like every other tap target.
43//! The four shipped sizes (24 / 32 / 48 / 64 dp) already clear the 24 dp target
44//! floor, but [`AvatarSize::Custom`] need not, so a clickable avatar declares a
45//! `Widget::hit_outset` that lifts an undersized one to the density's target —
46//! zero for the shipped sizes, and zero for a decorative avatar, which takes no
47//! press and must not punch a hole in what it sits on.
48
49use std::rc::Rc;
50
51use teksilo_canvas::raster::RasterIcon;
52use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
53use teksilo_core::accessibility::AccessNodeBuilder;
54use teksilo_core::accessibility::text_runs::{TextRunSource, push_text_runs};
55use teksilo_core::build_context::BuildContext;
56use teksilo_core::color_prop::ColorProp;
57use teksilo_core::signal::{Prop, Signal};
58use teksilo_core::styles::{AvatarStyleConfig, SharedAvatarStyle};
59use teksilo_core::widget::{
60    CursorIcon, EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement,
61};
62use teksilo_core::widget_builder::HandlerSet;
63use teksilo_core::widget_id::WidgetId;
64use teksilo_tokens::{Color, FontWeight, TextStyle};
65
66pub use teksilo_core::styles::{AvatarCorner, AvatarPresence, AvatarShape, AvatarSize};
67
68use crate::primitives::ImageWidget;
69use crate::primitives::image_mask::ImageMaskShape;
70use crate::primitives::image_widget::ImageFit;
71use crate::primitives::image_widget::{CommittedImage, masked};
72use crate::styles::recipe_avatar_style::{
73    AVATAR_FONT_RATIO_1CHAR, AVATAR_FONT_RATIO_2CHAR, AVATAR_ROUNDED_RADIUS_RATIO,
74    auto_contrast_text, avatar_pixel_size, hash_pick_palette_color,
75};
76use teksilo_i18n::LocalizedString;
77
78// ─── The widget ────────────────────────────────────────────────────────────
79
80type ActionFn = Rc<dyn Fn(&mut EventContext)>;
81
82/// Circular (or rounded-square / square) user-identity widget showing
83/// either a photo or hash-tinted initials, with an optional presence dot.
84///
85/// Static and reactive content fields coexist: each knob (`name`, `image`,
86/// `alt`, `label`, `presence`) has a static constructor or setter *and* a
87/// `bind_*` counterpart that takes a `Signal`. When a signal is bound it
88/// wins; the static value acts as a fallback. Signal-bound rebuilds fire at
89/// `BindingLevel::Rebuild` so inner children are recreated with fresh values —
90/// the canonical pattern for a "logged-out → logged-in" transition.
91pub struct Avatar {
92    /// Initials shown when no image is present. Static fallback;
93    /// overridden when `name_signal` is bound. Always non-empty
94    /// (`"?"` when input was empty).
95    initials: String,
96    /// Optional override of the a11y name. Static fallback for
97    /// `label_signal`.
98    label: Option<String>,
99    /// Image alt text. Static fallback for `alt_signal`.
100    alt: Option<String>,
101    /// Static image source bytes. `None` = no image at construction.
102    /// Coexists with `image_signal`: signal wins when bound.
103    image_source: Option<RawImage>,
104
105    size: AvatarSize,
106    shape: AvatarShape,
107
108    background: Option<ColorProp>,
109    foreground: Option<ColorProp>,
110    border_color: Option<ColorProp>,
111    border_width: Option<f32>,
112
113    presence: Option<AvatarPresence>,
114    presence_corner: AvatarCorner,
115
116    seed: Option<String>,
117
118    a11y_hidden: bool,
119
120    image_visible: Prop<bool>,
121
122    // ── Dynamic signal overrides (each None ⇒ use the static field) ─
123    /// Reactive name. Drives derived initials and the hash seed when
124    /// bound. Bound at `BindingLevel::Rebuild` so the inner children
125    /// are recreated on flip.
126    name_signal: Option<Signal<String>>,
127    /// Reactive image source. `None` value ⇒ initials fallback path.
128    /// `Rc<RasterIcon>` so swap is cheap. Bound at `Rebuild`.
129    image_signal: Option<Signal<Option<Rc<RasterIcon>>>>,
130    /// Reactive alt text. Bound at `AccessibilityOnly` since it only
131    /// affects screen-reader output.
132    alt_signal: Option<Signal<Option<String>>>,
133    /// Reactive label. Bound at `AccessibilityOnly`.
134    label_signal: Option<Signal<Option<String>>>,
135    /// Reactive presence. Bound at `Rebuild` — the dot's colour and
136    /// the a11y description both depend on the presence variant.
137    presence_signal: Option<Signal<Option<AvatarPresence>>>,
138
139    /// Optional `has_popup` ARIA hint. Surfaces via `set_has_popup` in
140    /// `accessibility()` for the disclosure pattern (e.g. an Avatar
141    /// that opens a user-menu Popover declares `HasPopup::Menu`).
142    has_popup: Option<teksilo_core::accesskit::HasPopup>,
143    /// Optional signal reporting whether the linked popup is currently
144    /// visible. Surfaces via `set_expanded` in `accessibility()`. Only
145    /// meaningful alongside `has_popup`.
146    expanded_signal: Option<Prop<bool>>,
147
148    /// Activation handler. Stored as `Rc<dyn Fn>` so it survives
149    /// rebuilds (theme/locale switches re-run `build()` and would
150    /// otherwise drop a `Box<dyn Fn>` after the first take).
151    action: Option<ActionFn>,
152
153    /// Focus state — set in `build()` and threaded into the
154    /// `AvatarStyle` config so the chrome can paint the keyboard
155    /// focus ring. `None` until `build()` runs.
156    focused: Option<Signal<bool>>,
157    /// Per-call override for the chrome (shape fill, border, focus ring,
158    /// presence dot).
159    style_override: Option<SharedAvatarStyle>,
160    /// The masked picture last committed, and what it was made from. A
161    /// rebuild that leaves the image and the shape as they were (a name,
162    /// a presence or a locale change) shows the same source, so the window
163    /// keeps its texture.
164    image_cache: Option<(ImageKey, Option<Rc<CommittedImage>>)>,
165    /// Build-time `AvatarStyle::make_body` root.
166    root_child_id: Option<WidgetId>,
167
168    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
169    /// with the rich / composite slots — every setter clears the other two so
170    /// the last call wins.
171    tooltip_text: Option<LocalizedString>,
172    /// Optional rich tooltip source (registry key or inline content).
173    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
174    /// Optional composite tooltip body (arbitrary widget tree).
175    composite_tooltip_content: Option<Box<dyn Widget>>,
176}
177
178#[derive(Clone)]
179struct RawImage {
180    /// `Rc` so rebuilds (theme switch, locale switch, signal flip)
181    /// don't reclone the byte buffer; its identity is the image's, for
182    /// [`ImageKey`].
183    pixels: Rc<Vec<u8>>,
184    width: u32,
185    height: u32,
186}
187
188/// What a committed avatar picture was made from: the image's identity
189/// and the mask applied to it.
190#[derive(Clone, Copy, PartialEq)]
191struct ImageKey {
192    image: ImageIdentity,
193    mask: ImageMaskShape,
194}
195
196#[derive(Clone, Copy, PartialEq, Eq)]
197enum ImageIdentity {
198    /// The avatar's own pixels, by the address of their `Rc`, which the
199    /// avatar holds for its life.
200    Raw(usize),
201    /// An icon from the image Signal, by its texture key: equal keys mean
202    /// equal pixels.
203    Icon(u64),
204}
205
206/// The current image: its identity, and how to get its pixels when they
207/// have to be committed.
208enum CurrentImage {
209    Raw(RawImage),
210    Icon(Rc<RasterIcon>),
211}
212
213impl CurrentImage {
214    fn identity(&self) -> ImageIdentity {
215        match self {
216            CurrentImage::Raw(raw) => ImageIdentity::Raw(Rc::as_ptr(&raw.pixels) as usize),
217            CurrentImage::Icon(icon) => ImageIdentity::Icon(icon.texture_key()),
218        }
219    }
220
221    fn pixels(&self) -> (Vec<u8>, u32, u32) {
222        match self {
223            CurrentImage::Raw(raw) => ((*raw.pixels).clone(), raw.width, raw.height),
224            CurrentImage::Icon(icon) => (icon.pixels().to_vec(), icon.width(), icon.height()),
225        }
226    }
227}
228
229// ─── Constructors ──────────────────────────────────────────────────────────
230
231impl Avatar {
232    /// Create an avatar from an explicit initials string. Uppercases and
233    /// truncates to at most 2 chars; empty input yields `"?"`.
234    pub fn with_initials(initials: impl Into<LocalizedString>) -> Self {
235        let ls: LocalizedString = initials.into();
236        let raw = ls.resolve_now();
237        Self::from_initials(normalize_initials(&raw))
238    }
239
240    /// Create an avatar from a display name; initials are derived
241    /// automatically (`"Jane Doe" → "JD"`, `"jane.doe@x.com" → "JD"`,
242    /// `"Cher" → "C"`, `"" → "?"`), and the full name is used as the
243    /// hash seed for the background tint so users with identical initials
244    /// still get distinct colours.
245    pub fn with_name(name: impl Into<LocalizedString>) -> Self {
246        let ls: LocalizedString = name.into();
247        let raw = ls.resolve_now();
248        let initials = derive_initials(&raw);
249        let mut a = Self::from_initials(initials);
250        a.seed = Some(raw); // hash from the full name, not from the abbreviated initials
251        a
252    }
253
254    /// Create an avatar from a decoded [`RasterIcon`]. The pixels are
255    /// centre-cropped to a square and CPU-masked to the configured shape
256    /// at the first `build()`. Call [`.alt(...)`](Self::alt) to provide a
257    /// screen-reader name for the image.
258    pub fn with_image(icon: &RasterIcon) -> Self {
259        Self::from_raw_image(icon.pixels().to_vec(), icon.width(), icon.height())
260    }
261
262    /// Create an avatar from raw RGBA pixels (`width × height × 4` bytes).
263    /// Same pixel-layout convention as `ImageWidget::from_raw`.
264    pub fn from_raw_image(pixels: Vec<u8>, width: u32, height: u32) -> Self {
265        let mut a = Self::from_initials("?".to_string());
266        a.image_source = Some(RawImage {
267            pixels: Rc::new(pixels),
268            width,
269            height,
270        });
271        a
272    }
273
274    fn from_initials(initials: String) -> Self {
275        Self {
276            initials,
277            label: None,
278            alt: None,
279            image_source: None,
280            size: AvatarSize::Medium,
281            shape: AvatarShape::Circle,
282            background: None,
283            foreground: None,
284            border_color: None,
285            border_width: None,
286            presence: None,
287            presence_corner: AvatarCorner::BottomTrailing,
288            seed: None,
289            a11y_hidden: false,
290            image_visible: Prop::Static(true),
291            name_signal: None,
292            image_signal: None,
293            alt_signal: None,
294            label_signal: None,
295            presence_signal: None,
296            has_popup: None,
297            expanded_signal: None,
298            action: None,
299            focused: None,
300            style_override: None,
301            image_cache: None,
302            root_child_id: None,
303            tooltip_text: None,
304            rich_tooltip_source: None,
305            composite_tooltip_content: None,
306        }
307    }
308
309    /// Per-call style override for the avatar chrome.
310    pub fn style(mut self, style: impl teksilo_core::styles::AvatarStyle) -> Self {
311        self.style_override = Some(Rc::new(style));
312        self
313    }
314}
315
316// ─── Builder methods ───────────────────────────────────────────────────────
317
318impl Avatar {
319    /// Set the avatar's discrete size. Default: `AvatarSize::Medium` (32 dp).
320    pub fn size(mut self, size: AvatarSize) -> Self {
321        self.size = size;
322        self
323    }
324
325    /// Set the avatar's clip shape. Default: `AvatarShape::Circle`.
326    pub fn shape(mut self, shape: AvatarShape) -> Self {
327        // No cache to invalidate — masking now lives on the inner
328        // `ImageWidget`, which is recreated each `build()` with the
329        // current shape.
330        self.shape = shape;
331        self
332    }
333
334    /// Override the initials shown when the image is hidden via
335    /// `image_visible(false)` or fails to register. Defaults to the
336    /// derived initials if `with_image` was paired with `with_name`,
337    /// otherwise `"?"`.
338    pub fn fallback_initials(mut self, initials: impl Into<LocalizedString>) -> Self {
339        let ls: LocalizedString = initials.into();
340        let raw = ls.resolve_now();
341        self.initials = normalize_initials(&raw);
342        self
343    }
344
345    /// Reactive image visibility. When unbound it's `true`. When bound
346    /// to a `Signal<bool>` and the value is `false`, the initials
347    /// fallback paints in place of the image — same logical bounds, no
348    /// layout shift.
349    pub fn image_visible(mut self, visible: impl Into<Prop<bool>>) -> Self {
350        self.image_visible = visible.into();
351        self
352    }
353
354    /// Override the auto hash-derived background. Accepts a [`Color`],
355    /// a role, or a `Signal<Color>`.
356    pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
357        self.background = Some(color.into());
358        self
359    }
360
361    /// Override the auto-contrast text colour for the initials. Auto
362    /// (unset) picks white over dark backgrounds and near-black over
363    /// light ones, computed at paint time from the resolved bg's
364    /// luminance.
365    pub fn foreground(mut self, color: impl Into<ColorProp>) -> Self {
366        self.foreground = Some(color.into());
367        self
368    }
369
370    /// Override the seed string used to pick a hash-derived background
371    /// from the theme's chart palette. Defaults to the resolved name
372    /// (when constructed via `with_name`) or the initials.
373    pub fn seed(mut self, seed: impl Into<String>) -> Self {
374        self.seed = Some(seed.into());
375        self
376    }
377
378    /// Outer ring thickness. A non-zero value enables the ring (drawn
379    /// in `BorderRole::Default` unless [`Self::border_color`] overrides
380    /// it). `0.0` disables the ring.
381    pub fn border(mut self, width: f32) -> Self {
382        self.border_width = Some(width.max(0.0));
383        self
384    }
385
386    /// Override the outer ring colour. Accepts a [`Color`], a theme role,
387    /// or a `Signal<Color>`. Has no effect unless [`Self::border`] is also
388    /// set to a positive width.
389    pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self {
390        self.border_color = Some(color.into());
391        self
392    }
393
394    /// Show a presence indicator dot. Pass `AvatarPresence::Online`,
395    /// `Offline`, `Away`, or `Busy`.
396    pub fn presence(mut self, presence: AvatarPresence) -> Self {
397        self.presence = Some(presence);
398        self
399    }
400
401    /// Choose which corner the presence dot occupies. Default:
402    /// `AvatarCorner::BottomTrailing`.
403    pub fn presence_corner(mut self, corner: AvatarCorner) -> Self {
404        self.presence_corner = corner;
405        self
406    }
407
408    /// Override the accessible name. When unset:
409    /// * image-mode → `alt` if set, else the initials (`"?"` when none)
410    /// * initials-mode → the initials.
411    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
412        let ls: LocalizedString = label.into();
413        self.label = Some(ls.resolve_now());
414        self
415    }
416
417    /// Image alt text — distinct from `label` so a clickable avatar
418    /// can have a button label like "Open user menu" while still
419    /// describing the image as "Jane Doe".
420    pub fn alt(mut self, alt: impl Into<LocalizedString>) -> Self {
421        let ls: LocalizedString = alt.into();
422        self.alt = Some(ls.resolve_now());
423        self
424    }
425
426    /// Hide from the a11y tree entirely. Use only when an adjacent
427    /// label conveys the avatar's meaning.
428    pub fn a11y_hidden(mut self) -> Self {
429        self.a11y_hidden = true;
430        self
431    }
432
433    /// Make the avatar activable. Promotes the a11y role to
434    /// `Role::Button` and adds `Action::Click` / `Action::Focus`. Tap,
435    /// Enter, and Space all fire the closure. Cursor changes to
436    /// `Pointer` on hover.
437    pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
438        self.action = Some(Rc::new(f));
439        self
440    }
441
442    /// Declare that this avatar is a disclosure trigger for a popup
443    /// (typically `HasPopup::Menu` for a user-menu trigger). Surfaces
444    /// via `set_has_popup` in the a11y node so screen readers
445    /// announce the avatar as "menu button" / "has popup". Only takes
446    /// effect when paired with `.on_activate_fn(...)` — without an
447    /// activation handler the avatar isn't a trigger.
448    pub fn has_popup(mut self, kind: teksilo_core::accesskit::HasPopup) -> Self {
449        self.has_popup = Some(kind);
450        self
451    }
452
453    /// Bind a signal reporting whether this avatar's popup is
454    /// currently visible. The wrapping Popover / overlay manager owns
455    /// the signal and flips it on show / dismiss; Avatar reads it in
456    /// `accessibility()` to publish `set_expanded`. Only meaningful
457    /// alongside `.has_popup(...)`.
458    pub fn expanded_when(mut self, signal: impl Into<Prop<bool>>) -> Self {
459        self.expanded_signal = Some(signal.into());
460        self
461    }
462
463    // ── Tooltip ───────────────────────────────────────────────────────
464
465    /// Attach a plain single-line tooltip shown after the hover delay.
466    /// Mutually exclusive with [`Self::rich_tooltip`],
467    /// [`Self::rich_tooltip_content`], and [`Self::composite_tooltip`] —
468    /// this call clears the other three slots.
469    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
470        self.tooltip_text = Some(text.into());
471        self.rich_tooltip_source = None;
472        self.composite_tooltip_content = None;
473        self
474    }
475
476    /// Attach a rich tooltip identified by a registry key. The tooltip
477    /// content is resolved from the application's `TooltipRegistry` at
478    /// hover time. Mutually exclusive with the other tooltip setters —
479    /// this call clears the other three slots.
480    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
481        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
482        self.tooltip_text = None;
483        self.composite_tooltip_content = None;
484        self
485    }
486
487    /// Attach a rich tooltip from inline [`crate::tooltip::TooltipContent`]
488    /// without a registry key. Mutually exclusive with the other tooltip
489    /// setters — this call clears the other three slots.
490    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
491        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
492        self.tooltip_text = None;
493        self.composite_tooltip_content = None;
494        self
495    }
496
497    /// Attach a composite tooltip whose body is an arbitrary widget tree.
498    /// Shown after the longer `tooltip_delay_heavy` delay. Mutually
499    /// exclusive with the other tooltip setters — this call clears the
500    /// other three slots.
501    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
502        self.composite_tooltip_content = Some(Box::new(content));
503        self.tooltip_text = None;
504        self.rich_tooltip_source = None;
505        self
506    }
507
508    // ── Reactive content (bind_*) ─────────────────────────────────────
509
510    /// Bind the user's display name to a signal. The displayed
511    /// initials are auto-derived from the current value
512    /// (`derive_initials`), and the same value is used as the hash
513    /// seed for the background tint. Bound at
514    /// `BindingLevel::Rebuild` so the inner children regenerate on
515    /// flip — the canonical login-flow pattern:
516    ///
517    /// ```ignore
518    /// let user_name: Signal<String> = ctx.signal(String::new());
519    /// Avatar::with_initials(lit!("?"))        // logged-out fallback
520    ///     .name_signal(user_name.clone())
521    ///     .image_signal(user_avatar_signal)
522    /// ```
523    pub fn name_signal(mut self, signal: Signal<String>) -> Self {
524        self.name_signal = Some(signal);
525        self
526    }
527
528    /// Bind the image source. `None` ⇒ initials fallback. Each
529    /// non-`None` value is masked to the configured `AvatarShape` by
530    /// the inner [`ImageWidget`]. Bound at `BindingLevel::Rebuild`.
531    pub fn image_signal(mut self, signal: Signal<Option<Rc<RasterIcon>>>) -> Self {
532        self.image_signal = Some(signal);
533        self
534    }
535
536    /// Bind the image alt text. Bound at `BindingLevel::AccessibilityOnly`
537    /// — only the screen-reader projection is affected.
538    pub fn alt_signal(mut self, signal: Signal<Option<String>>) -> Self {
539        self.alt_signal = Some(signal);
540        self
541    }
542
543    /// Bind the accessible label. Bound at
544    /// `BindingLevel::AccessibilityOnly`.
545    pub fn label_signal(mut self, signal: Signal<Option<String>>) -> Self {
546        self.label_signal = Some(signal);
547        self
548    }
549
550    /// Bind the presence indicator. `None` hides the dot. Bound at
551    /// `BindingLevel::Rebuild` — the dot's colour and the a11y
552    /// `description` flip together so a rebuild keeps both layers in
553    /// sync.
554    pub fn presence_signal(mut self, signal: Signal<Option<AvatarPresence>>) -> Self {
555        self.presence_signal = Some(signal);
556        self
557    }
558}
559
560impl std::fmt::Debug for Avatar {
561    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
562        f.debug_struct("Avatar")
563            .field("initials", &self.initials)
564            .field("size", &self.size)
565            .field("shape", &self.shape)
566            .field(
567                "has_image",
568                &(self.image_source.is_some() || self.image_signal.is_some()),
569            )
570            .field("clickable", &self.action.is_some())
571            .finish()
572    }
573}
574
575// ─── Helpers ───────────────────────────────────────────────────────────────
576
577/// Inline FNV-1a 64-bit. Stable across Rust versions and process runs
578/// (unlike `DefaultHasher`). Same idiom as `teksilo_core::accessibility`.
579/// Truncate to ≤ 2 chars and uppercase. Returns `"?"` when the input
580/// trims to empty. Operates on `char`s (Unicode scalars), not extended
581/// graphemes — this is sufficient for real-world names where accented
582/// letters are stored pre-composed.
583fn normalize_initials(s: &str) -> String {
584    let mut out = String::new();
585    let mut count = 0;
586    for c in s.trim().chars() {
587        if count >= 2 {
588            break;
589        }
590        for upper in c.to_uppercase() {
591            out.push(upper);
592        }
593        count += 1;
594    }
595    if out.is_empty() { "?".to_string() } else { out }
596}
597
598/// Auto-derive initials from a free-form name.
599fn derive_initials(name: &str) -> String {
600    let trimmed = name.trim();
601    if trimmed.is_empty() {
602        return "?".to_string();
603    }
604    // For email-like strings only the local part (before `@`) matters.
605    let source = trimmed.split('@').next().unwrap_or(trimmed);
606    let parts: Vec<&str> = source
607        .split(|c: char| c.is_whitespace() || c == '.' || c == '_' || c == '-')
608        .filter(|s| !s.is_empty())
609        .collect();
610
611    let mut out = String::new();
612    for part in parts.iter().take(2) {
613        if let Some(c) = part.chars().next() {
614            for upper in c.to_uppercase() {
615                out.push(upper);
616            }
617        }
618    }
619    if out.is_empty() { "?".to_string() } else { out }
620}
621
622fn shape_to_image_mask(shape: AvatarShape) -> ImageMaskShape {
623    match shape {
624        AvatarShape::Circle => ImageMaskShape::Circle,
625        AvatarShape::RoundedSquare => ImageMaskShape::RoundedSquare(AVATAR_ROUNDED_RADIUS_RATIO),
626        AvatarShape::Square => ImageMaskShape::None,
627    }
628}
629
630// ─── Reactive accessors — used by paint / accessibility ──────────────────
631
632impl Avatar {
633    /// The displayed initials, taking any bound name signal into
634    /// account. Cheap (a few string ops per call); paint / a11y
635    /// invoke this directly rather than caching.
636    fn current_initials(&self) -> String {
637        match &self.name_signal {
638            Some(sig) => derive_initials(&sig.get()),
639            None => self.initials.clone(),
640        }
641    }
642
643    /// The hash seed for the background tint. When `name_signal` is
644    /// active the seed *is* the name (so two users named "JD" but
645    /// "Jane Doe" vs "Jules Dupont" hash differently). Otherwise it
646    /// falls back to the user-supplied seed or the static initials.
647    fn current_seed(&self) -> String {
648        match &self.name_signal {
649            Some(sig) => sig.get(),
650            None => self.seed.clone().unwrap_or_else(|| self.initials.clone()),
651        }
652    }
653
654    fn current_alt(&self) -> Option<String> {
655        match &self.alt_signal {
656            Some(sig) => sig.get(),
657            None => self.alt.clone(),
658        }
659    }
660
661    fn current_label(&self) -> Option<String> {
662        match &self.label_signal {
663            Some(sig) => sig.get(),
664            None => self.label.clone(),
665        }
666    }
667
668    fn current_presence(&self) -> Option<AvatarPresence> {
669        match &self.presence_signal {
670            Some(sig) => sig.get(),
671            None => self.presence.clone(),
672        }
673    }
674
675    /// The image of the current state: `Some` ⇒ image mode (an
676    /// `ImageWidget` child), `None` ⇒ initials only.
677    fn current_image(&self) -> Option<CurrentImage> {
678        if let Some(sig) = &self.image_signal {
679            return sig.get().map(CurrentImage::Icon);
680        }
681        self.image_source.clone().map(CurrentImage::Raw)
682    }
683
684    /// The masked picture of `image`, committed once per image and shape:
685    /// `None` when its pixels were refused (they draw nothing, so the
686    /// initials show instead).
687    fn committed(
688        &mut self,
689        image: &CurrentImage,
690        mask: ImageMaskShape,
691    ) -> Option<Rc<CommittedImage>> {
692        let key = ImageKey {
693            image: image.identity(),
694            mask,
695        };
696        if let Some((cached, committed)) = &self.image_cache
697            && *cached == key
698        {
699            return committed.clone();
700        }
701        let (pixels, width, height) = image.pixels();
702        let (pixels, width, height) = masked(pixels, width, height, mask);
703        let committed = match CommittedImage::commit(pixels, width, height) {
704            Ok(committed) => Some(committed),
705            Err(error) => {
706                eprintln!(
707                    "teksilo: an avatar image of {width}x{height} was refused ({error}): \
708                     its initials show instead"
709                );
710                None
711            }
712        };
713        self.image_cache = Some((key, committed.clone()));
714        committed
715    }
716
717    /// Whether the avatar should expose a11y-image-role semantics: it has
718    /// an image, and the last build did not find its pixels refused (the
719    /// initials show then).
720    fn has_image_now(&self) -> bool {
721        let has_image = self
722            .image_signal
723            .as_ref()
724            .is_some_and(|sig| sig.get().is_some())
725            || self.image_source.is_some();
726        has_image && !matches!(&self.image_cache, Some((_, None)))
727    }
728}
729
730// ─── Widget impl ───────────────────────────────────────────────────────────
731
732impl Widget for Avatar {
733    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
734        let self_id = ctx.self_id();
735        let mask_shape = shape_to_image_mask(self.shape);
736
737        // 1. Resolve current content state. Each `current_*` reads
738        //    the signal if bound, falling back to the static field.
739        let initials = self.current_initials();
740        let seed = self.current_seed();
741        let alt = self.current_alt();
742        let image = match self.current_image() {
743            Some(image) => self.committed(&image, mask_shape),
744            None => None,
745        };
746
747        // 2. Build inner content (`InitialsLeaf` / `ImageWidget`).
748        //    The masking lives on `ImageWidget` so Avatar doesn't
749        //    manage pixel buffers itself.
750        let make_initials_leaf = || InitialsLeaf {
751            initials: initials.clone(),
752            seed: seed.clone(),
753            background: self.background.clone(),
754            foreground: self.foreground.clone(),
755        };
756        let make_image_widget = |image: Rc<CommittedImage>, alt: Option<String>| {
757            let mut img = ImageWidget::from_committed(image).fit(ImageFit::Cover);
758            if let Some(a) = alt {
759                img = img.alt(a);
760            } else {
761                // Inner ImageWidget is silenced — the parent Avatar
762                // owns the Role::Image / Role::Button + name.
763                img = img.a11y_hidden();
764            }
765            img
766        };
767
768        // Assemble inner content as a single `WidgetId`. For the
769        // bound-visibility case the image and initials sit as siblings
770        // inside a `ZStack` with `visible_when` bindings; either is
771        // mounted alone otherwise.
772        let content_id = match (image, &self.image_visible) {
773            (Some(image), Prop::Static(true)) => ctx.add(make_image_widget(image, alt.clone())),
774            (Some(_), Prop::Static(false)) => ctx.add(make_initials_leaf()),
775            (Some(image), Prop::Bound(visible_signal)) => {
776                let img_id = ctx.add(make_image_widget(image, alt.clone()));
777                let init_id = ctx.add(make_initials_leaf());
778                let v_clone = visible_signal.clone();
779                ctx.visible_when(img_id, v_clone.clone());
780                ctx.visible_when(init_id, v_clone.map(|v| !*v));
781                ctx.add(
782                    crate::primitives::ZStack::new()
783                        .child(img_id)
784                        .child(init_id),
785                )
786            }
787            (None, _) => ctx.add(make_initials_leaf()),
788        };
789
790        // 3. Wire reactive content signals so flips re-run build().
791        let registry = ctx.binding_registry();
792        if let Some(sig) = &self.name_signal {
793            sig.bind_to(
794                self_id,
795                registry,
796                teksilo_core::binding::BindingLevel::Rebuild,
797            );
798        }
799        if let Some(sig) = &self.image_signal {
800            sig.bind_to(
801                self_id,
802                registry,
803                teksilo_core::binding::BindingLevel::Rebuild,
804            );
805        }
806        if let Some(sig) = &self.presence_signal {
807            sig.bind_to(
808                self_id,
809                registry,
810                teksilo_core::binding::BindingLevel::Rebuild,
811            );
812        }
813        if let Some(sig) = &self.alt_signal {
814            sig.bind_to(
815                self_id,
816                registry,
817                teksilo_core::binding::BindingLevel::AccessibilityOnly,
818            );
819        }
820        if let Some(sig) = &self.label_signal {
821            sig.bind_to(
822                self_id,
823                registry,
824                teksilo_core::binding::BindingLevel::AccessibilityOnly,
825            );
826        }
827
828        // 4. If clickable, install attached handlers — including the
829        //    `on_focus` that drives the focus-ring repaint via the
830        //    chrome's `is_focused` signal.
831        let focused = ctx.signal(false);
832        self.focused = Some(focused.clone());
833        if let Some(action) = self.action.clone() {
834            let focus_for_handler = focused.clone();
835
836            let action_for_tap = action.clone();
837            let action_for_key = action.clone();
838            let action_for_access = action;
839            let handlers = HandlerSet::new()
840                .on_tap(move |_pos, ctx| action_for_tap(ctx))
841                .focusable(true)
842                .cursor(CursorIcon::Pointer)
843                .on_focus(move |gained, _ctx| focus_for_handler.set(gained))
844                .on_key(move |event, ctx| {
845                    use teksilo_core::event::{EventResponse, Key, WidgetEvent};
846                    match event {
847                        WidgetEvent::KeyDown {
848                            key: Key::Enter | Key::Space,
849                            ..
850                        } => {
851                            action_for_key(ctx);
852                            EventResponse::Handled
853                        }
854                        _ => EventResponse::Ignored,
855                    }
856                })
857                .on_access_action(move |action_kind, ctx| {
858                    use teksilo_core::event::EventResponse;
859                    if action_kind == teksilo_core::accesskit::Action::Click {
860                        action_for_access(ctx);
861                        EventResponse::Handled
862                    } else {
863                        EventResponse::Ignored
864                    }
865                });
866            ctx.apply_self_handlers(handlers);
867        }
868
869        // 5. Wire `expanded_signal` for a11y refresh on flip.
870        if let Some(ref expanded_signal) = self.expanded_signal {
871            let self_id = ctx.self_id();
872            let registry = ctx.binding_registry();
873            expanded_signal.register_if_bound(
874                self_id,
875                registry,
876                teksilo_core::binding::BindingLevel::RepaintOnly,
877            );
878        }
879
880        // 6. The shape-aware chrome (background fill, border, focus
881        //    ring, presence dot) is owned by the active `AvatarStyle`;
882        //    this widget keeps its Role::Image / Role::Button / Role::Label
883        //    semantics and the initials-derivation logic.
884        let style: SharedAvatarStyle = self
885            .style_override
886            .clone()
887            .or_else(|| ctx.theme().style_slots.avatar.clone())
888            .unwrap_or_else(|| {
889                Rc::new(crate::styles::RecipeAvatarStyle::for_tokens(
890                    &ctx.theme().input,
891                ))
892            });
893        let root = style.make_body(
894            &AvatarStyleConfig {
895                shape: self.shape,
896                size: self.size,
897                content: content_id,
898                presence: self.current_presence(),
899                presence_corner: self.presence_corner,
900                // `:focus-visible`: keyboard-only focus ring (gate raw focus
901                // on the input-modality signal).
902                is_focused: focused.and(&ctx.focus_visible()),
903                background_override: self.background.clone(),
904                border_color_override: self.border_color.clone(),
905                border_width_override: self.border_width,
906                seed,
907            },
908            ctx,
909        );
910        self.root_child_id = Some(root);
911
912        // Tooltip attachment — anchored on the style root (the trigger).
913        if let Some(content) = self.composite_tooltip_content.take() {
914            let delay = ctx.theme().motion.tooltip_delay_heavy;
915            crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
916        } else if let Some(source) = self.rich_tooltip_source.clone() {
917            let delay = ctx.theme().motion.tooltip_delay;
918            crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
919        } else if let Some(text) = self.tooltip_text.clone() {
920            let delay = ctx.theme().motion.tooltip_delay;
921            crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
922        }
923
924        vec![root]
925    }
926
927    fn layout_response(
928        &self,
929        _proposal: SizeProposal,
930        _ctx: &LayoutContext,
931    ) -> teksilo_core::widget::LayoutResponse {
932        let side = avatar_pixel_size(self.size);
933        Size::new(side, side).into()
934    }
935
936    fn place_children(
937        &self,
938        bounds: Rect,
939        _proposal: SizeProposal,
940        children: &mut [WidgetPlacement],
941        _ctx: &LayoutContext,
942    ) {
943        for child in children.iter_mut() {
944            child.origin = bounds.origin();
945            child.size = bounds.size();
946        }
947    }
948
949    /// A tappable avatar is a target like any other, and the four shipped sizes
950    /// (24 / 32 / 48 / 64 dp) all clear the floor — but
951    /// [`AvatarSize::Custom`] does not have to, and a 20 dp avatar in a dense
952    /// comment list is the case this covers. The outset returns `EdgeInsets::ZERO`
953    /// for any size already at or above the density's target, so the shipped
954    /// sizes pay nothing.
955    ///
956    /// Zero for a decorative avatar: without an `on_tap` there is nothing to
957    /// widen the target of, and widening it anyway would punch a hole in
958    /// whatever the avatar sits on.
959    fn hit_outset(
960        &self,
961        kind: teksilo_tokens::PointerKind,
962        tokens: &teksilo_tokens::InputTokens,
963    ) -> teksilo_canvas::EdgeInsets {
964        if self.action.is_none() {
965            return teksilo_canvas::EdgeInsets::ZERO;
966        }
967        let side = avatar_pixel_size(self.size);
968        crate::button::target_outset(Size::new(side, side), kind, tokens)
969    }
970
971    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
972        if self.a11y_hidden {
973            builder.set_hidden();
974            return;
975        }
976
977        let clickable = self.action.is_some();
978        // `current_image()` is the source of truth — a bound image
979        // signal whose value is `None` means "no image right now",
980        // even if a static `with_image` source was also supplied
981        // (signal wins). For role-selection we ask: does the live
982        // state put us in image-mode?
983        let has_image = self.has_image_now();
984        let alt = self.current_alt();
985        let label = self.current_label();
986        let initials = self.current_initials();
987
988        let name = if clickable {
989            builder.set_role(teksilo_core::accesskit::Role::Button);
990            // A clickable avatar without an explicit label is missing
991            // its activation hint. Catch this in dev to prevent silent
992            // a11y regressions.
993            debug_assert!(
994                label.is_some() || alt.is_some(),
995                "Avatar::on_activate_fn requires a `.label(\"...\")` (preferred) or `.alt(\"...\")` (or a `.label(...)` / `.alt_signal(...)`) for screen readers"
996            );
997            builder.add_action(teksilo_core::accesskit::Action::Click);
998            builder.add_action(teksilo_core::accesskit::Action::Focus);
999            label.or(alt).unwrap_or_else(|| initials.clone())
1000        } else if has_image {
1001            builder.set_role(teksilo_core::accesskit::Role::Image);
1002            // A pure-image avatar without alt text is missing its
1003            // semantic label — catch in dev (matches `ImageWidget`).
1004            debug_assert!(
1005                alt.is_some() || label.is_some(),
1006                "Avatar::with_image requires a `.alt(\"...\")` (or `.alt_signal(...)`) for meaningful images, or call `.a11y_hidden()` if decorative"
1007            );
1008            alt.or(label).unwrap_or_else(|| initials.clone())
1009        } else {
1010            builder.set_role(teksilo_core::accesskit::Role::Label);
1011            label.unwrap_or_else(|| initials.clone())
1012        };
1013
1014        // No donor label to borrow runs from: the announced name is the
1015        // alt text or the initials, and the initials leaf paints something
1016        // else entirely. So the avatar emits one degenerate run over its
1017        // own box — enough for `supports_text_ranges`, and enough to keep
1018        // `bounding_boxes()` answering, which is what a magnifier tracks.
1019        // The rect is widget-local; the builder translates it once the
1020        // walker has written this node's window-space box. Under `Button`
1021        // or `Image` the run supports no ranges on any platform, and
1022        // `AccessNodeBuilder::build` drops it.
1023        let side = avatar_pixel_size(self.size);
1024        let source =
1025            TextRunSource::flat(&name, 0).with_fallback_rect(Rect::new(0.0, 0.0, side, side));
1026        let emission = push_text_runs(builder, None, &source);
1027        // The name must be byte-identical to the runs' concatenation: the
1028        // consumer derives the document text from the runs, and every
1029        // divergence is a place where what a reader reviews and what it
1030        // announces disagree.
1031        builder.set_name(&emission.value);
1032
1033        if let Some(presence) = self.current_presence() {
1034            builder.set_description(presence.label());
1035        }
1036
1037        // Disclosure-pattern hints. Only meaningful for clickable
1038        // avatars — but harmless to surface unconditionally for the
1039        // image / label paths in case a wrapper widget is supplying
1040        // them (e.g. an external state machine that drives a popup
1041        // alongside an Avatar that isn't itself the trigger).
1042        if let Some(kind) = self.has_popup {
1043            builder.set_has_popup(kind);
1044        }
1045        if let Some(ref signal) = self.expanded_signal {
1046            builder.set_expanded(signal.get());
1047        }
1048    }
1049
1050    fn children(&self) -> Vec<WidgetId> {
1051        self.root_child_id.into_iter().collect()
1052    }
1053}
1054
1055// ─── Initials sub-widget ───────────────────────────────────────────────────
1056
1057/// Crate-private leaf that draws the centred initials. The active
1058/// `AvatarStyle`'s chrome handles the background fill; this widget only
1059/// emits glyphs so paint order is chrome-bg → child-text.
1060///
1061/// The leaf is constructed in [`Avatar::build`] with all the inputs
1062/// it needs to resolve a correctly contrasted foreground at paint
1063/// time:
1064/// * `initials` — the glyphs to draw.
1065/// * `seed` — the same string the parent will hash to pick a bg tint.
1066/// * `background` / `foreground` — clones of the parent's overrides
1067///   (`None` ⇒ default path). Stored as `ColorProp` so role / signal
1068///   variants resolve against the active theme each frame, matching
1069///   what the parent paints.
1070#[derive(Debug)]
1071struct InitialsLeaf {
1072    initials: String,
1073    seed: String,
1074    background: Option<ColorProp>,
1075    foreground: Option<ColorProp>,
1076}
1077
1078impl InitialsLeaf {
1079    /// Recompute the bg colour the avatar chrome will paint. Must
1080    /// stay in lock-step with `AvatarChromeFrame::paint`'s bg branch
1081    /// (`styles/recipe_avatar_style.rs`).
1082    fn resolve_bg(&self, theme: &teksilo_core::Theme, enabled: bool) -> Color {
1083        match &self.background {
1084            Some(prop) => prop.resolve(theme, enabled),
1085            None => hash_pick_palette_color(&self.seed, theme),
1086        }
1087    }
1088}
1089
1090impl Widget for InitialsLeaf {
1091    fn layout_response(
1092        &self,
1093        proposal: SizeProposal,
1094        _ctx: &LayoutContext,
1095    ) -> teksilo_core::widget::LayoutResponse {
1096        // Always fill the proposal — the parent Avatar drives sizing.
1097        proposal.resolve(0.0, 0.0).into()
1098    }
1099
1100    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1101        let theme = ctx.theme;
1102
1103        let font_size = bounds.width.min(bounds.height)
1104            * if self.initials.chars().count() <= 1 {
1105                AVATAR_FONT_RATIO_1CHAR
1106            } else {
1107                AVATAR_FONT_RATIO_2CHAR
1108            };
1109
1110        let text_style = TextStyle {
1111            family: theme.typography.body_bold.family.clone(),
1112            size: font_size,
1113            weight: FontWeight::SEMI_BOLD,
1114            line_height: 1.0,
1115            letter_spacing: 0.0,
1116        };
1117
1118        // Foreground: explicit override wins. Otherwise auto-contrast
1119        // against the same bg the parent painted.
1120        let fg = match &self.foreground {
1121            Some(prop) => prop.resolve(theme, ctx.effective_enabled),
1122            None => auto_contrast_text(self.resolve_bg(theme, ctx.effective_enabled)),
1123        };
1124
1125        // Measure the text to centre it. Without a backend, we can't
1126        // measure or draw glyphs at all — silently no-op.
1127        let Some(backend) = canvas.text_backend().cloned() else {
1128            return;
1129        };
1130        let layout = {
1131            let mut b = backend.borrow_mut();
1132            b.layout_single_line(&self.initials, &text_style, None)
1133        };
1134        let text_w = layout.width;
1135        let text_h = layout.height;
1136
1137        let cx = bounds.x + (bounds.width - text_w) / 2.0;
1138        let cy = bounds.y + (bounds.height - text_h) / 2.0;
1139        let position = Rect::new(cx, cy, text_w, text_h);
1140        canvas.draw_text(&self.initials, position, &text_style, fg);
1141    }
1142
1143    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1144        // The parent Avatar owns the user-facing semantics (role, name,
1145        // click action). The text node would otherwise duplicate that
1146        // information to ATs.
1147        builder.set_hidden();
1148    }
1149}
1150
1151// ─── Tests ─────────────────────────────────────────────────────────────────
1152
1153#[cfg(test)]
1154mod tests {
1155    use super::*;
1156    use crate::styles::recipe_avatar_style::fnv1a_64;
1157    use teksilo_core::widget::LayoutContext;
1158    use teksilo_core::widget_tree::WidgetTree;
1159    use teksilo_i18n::lit;
1160
1161    // ── helpers ────────────────────────────────────────────────────────
1162
1163    fn rgba_solid(side: u32, rgba: [u8; 4]) -> RasterIcon {
1164        let mut p = Vec::with_capacity((side * side * 4) as usize);
1165        for _ in 0..(side * side) {
1166            p.extend_from_slice(&rgba);
1167        }
1168        RasterIcon::from_raw(p, side, side)
1169    }
1170
1171    // ── derive_initials / normalize_initials ──────────────────────────
1172
1173    #[test]
1174    fn normalize_uppercase_truncate() {
1175        assert_eq!(normalize_initials("jdq"), "JD");
1176        assert_eq!(normalize_initials("jd"), "JD");
1177        assert_eq!(normalize_initials("j"), "J");
1178        assert_eq!(normalize_initials("  "), "?");
1179        assert_eq!(normalize_initials(""), "?");
1180    }
1181
1182    #[test]
1183    fn derive_full_name() {
1184        assert_eq!(derive_initials("Jane Doe"), "JD");
1185    }
1186
1187    #[test]
1188    fn derive_single_word() {
1189        assert_eq!(derive_initials("Cher"), "C");
1190    }
1191
1192    #[test]
1193    fn derive_email() {
1194        assert_eq!(derive_initials("jane.doe@x.com"), "JD");
1195        assert_eq!(derive_initials("jane_doe@x.com"), "JD");
1196    }
1197
1198    #[test]
1199    fn derive_unicode_name() {
1200        assert_eq!(derive_initials("María José"), "MJ");
1201    }
1202
1203    #[test]
1204    fn derive_empty_yields_question_mark() {
1205        assert_eq!(derive_initials(""), "?");
1206        assert_eq!(derive_initials("   "), "?");
1207    }
1208
1209    #[test]
1210    fn derive_three_words_takes_first_two() {
1211        assert_eq!(derive_initials("Anna María José"), "AM");
1212    }
1213
1214    #[test]
1215    fn derive_hyphenated_name() {
1216        assert_eq!(derive_initials("Jean-Luc Picard"), "JL");
1217    }
1218
1219    // ── hashing ────────────────────────────────────────────────────────
1220
1221    #[test]
1222    fn fnv1a_is_stable() {
1223        let h1 = fnv1a_64(b"jane.doe");
1224        let h2 = fnv1a_64(b"jane.doe");
1225        assert_eq!(h1, h2);
1226        assert_ne!(fnv1a_64(b"jane.doe"), fnv1a_64(b"john.smith"));
1227    }
1228
1229    #[test]
1230    fn hash_distributes_over_palette() {
1231        let theme = teksilo_core::presets::intui::light();
1232        let mut buckets = [0_u32; 8];
1233        for i in 0..200 {
1234            let seed = format!("user_{i}");
1235            let color = hash_pick_palette_color(&seed, &theme);
1236            // Find which palette index it picked.
1237            let idx = theme
1238                .colors
1239                .chart_palette
1240                .iter()
1241                .position(|c| c == &color)
1242                .expect("color must be a palette member");
1243            buckets[idx] += 1;
1244        }
1245        let nonzero = buckets.iter().filter(|n| **n > 0).count();
1246        assert!(
1247            nonzero >= 6,
1248            "expected hash to cover at least 6 of 8 buckets, got {nonzero} (buckets: {:?})",
1249            buckets
1250        );
1251    }
1252
1253    // ── sizing ─────────────────────────────────────────────────────────
1254
1255    #[test]
1256    fn size_default_is_medium_32px() {
1257        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1258        let id = tree.add(Avatar::with_initials(lit!("JD")));
1259        tree.layout(SizeProposal {
1260            width: None,
1261            height: None,
1262        });
1263        let b = tree.bounds(id);
1264        assert!((b.width - 32.0).abs() < 0.01);
1265        assert!((b.height - 32.0).abs() < 0.01);
1266    }
1267
1268    #[test]
1269    fn size_custom_passes_through() {
1270        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1271        let id = tree.add(Avatar::with_initials(lit!("JD")).size(AvatarSize::Custom(40.0)));
1272        tree.layout(SizeProposal {
1273            width: None,
1274            height: None,
1275        });
1276        let b = tree.bounds(id);
1277        assert!((b.width - 40.0).abs() < 0.01);
1278        assert!((b.height - 40.0).abs() < 0.01);
1279    }
1280
1281    #[test]
1282    fn size_that_fits_ignores_proposal() {
1283        // Even a hugely oversized proposal must not enlarge the
1284        // avatar's intrinsic size — it always reports the discrete
1285        // size variant. (`tree.layout(exact(...))` would clamp the
1286        // root's bounds to the proposal regardless, so we exercise
1287        // `size_that_fits` directly.)
1288        let widget = Avatar::with_initials(lit!("JD"));
1289        let theme = teksilo_core::presets::intui::light();
1290        let ctx = LayoutContext::for_testing(&theme);
1291        let s = widget
1292            .layout_response(SizeProposal::exact(400.0, 400.0), &ctx)
1293            .size;
1294        assert!((s.width - 32.0).abs() < 0.01);
1295        assert!((s.height - 32.0).abs() < 0.01);
1296    }
1297
1298    #[test]
1299    fn small_medium_large_xlarge_sizes() {
1300        let theme = teksilo_core::presets::intui::light();
1301        use crate::styles::recipe_avatar_style as av;
1302        let cases = [
1303            (AvatarSize::Small, av::AVATAR_SIZE_SMALL),
1304            (AvatarSize::Medium, av::AVATAR_SIZE_MEDIUM),
1305            (AvatarSize::Large, av::AVATAR_SIZE_LARGE),
1306            (AvatarSize::XLarge, av::AVATAR_SIZE_X_LARGE),
1307        ];
1308        for (variant, expected) in cases {
1309            let mut tree = WidgetTree::new().with_theme(theme.clone());
1310            let id = tree.add(Avatar::with_initials(lit!("X")).size(variant));
1311            tree.layout(SizeProposal {
1312                width: None,
1313                height: None,
1314            });
1315            let b = tree.bounds(id);
1316            assert!(
1317                (b.width - expected).abs() < 0.01,
1318                "size {variant:?}: expected {expected}, got {}",
1319                b.width
1320            );
1321        }
1322    }
1323
1324    // ── paint output ──────────────────────────────────────────────────
1325
1326    fn render_avatar(avatar: Avatar) -> std::rc::Rc<teksilo_canvas::RenderFrame> {
1327        use std::cell::RefCell;
1328        use std::rc::Rc;
1329        use teksilo_canvas::MockTextBackend;
1330        let mut tree = WidgetTree::new()
1331            .with_theme(teksilo_core::presets::intui::light())
1332            .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
1333        tree.add(avatar);
1334        tree.layout(SizeProposal::exact(64.0, 64.0));
1335        tree.render()
1336    }
1337
1338    fn count_shapes(frame: &teksilo_canvas::RenderFrame) -> usize {
1339        // Both `fill_circle` and `fill_rounded_rect` emit `ShapeQuad`
1340        // entries — the SDF pipeline. Stroke-based circle border also
1341        // lands here.
1342        frame.shapes.len()
1343    }
1344
1345    #[test]
1346    fn paint_initials_emits_a_shape_quad() {
1347        let frame = render_avatar(Avatar::with_initials(lit!("JD")));
1348        assert!(
1349            count_shapes(&frame) >= 1,
1350            "expected at least one ShapeQuad (the bg circle)"
1351        );
1352    }
1353
1354    #[test]
1355    fn paint_with_border_adds_extra_shape() {
1356        let plain = render_avatar(Avatar::with_initials(lit!("JD")));
1357        let bordered = render_avatar(Avatar::with_initials(lit!("JD")).border(2.0));
1358        assert!(
1359            count_shapes(&bordered) > count_shapes(&plain),
1360            "border path should add at least one extra Shape (the stroked ring)"
1361        );
1362    }
1363
1364    #[test]
1365    fn paint_presence_adds_two_shapes() {
1366        let plain = render_avatar(Avatar::with_initials(lit!("JD")));
1367        let with_dot =
1368            render_avatar(Avatar::with_initials(lit!("JD")).presence(AvatarPresence::Online));
1369        // Outline + dot.
1370        assert_eq!(count_shapes(&with_dot), count_shapes(&plain) + 2);
1371    }
1372
1373    #[test]
1374    fn paint_rounded_square_emits_shape() {
1375        // `fill_rounded_rect` lands on the SDF Shape pipeline same
1376        // as `fill_circle`. Both shapes paint via Shape quads.
1377        let frame =
1378            render_avatar(Avatar::with_initials(lit!("JD")).shape(AvatarShape::RoundedSquare));
1379        assert!(count_shapes(&frame) >= 1);
1380    }
1381
1382    #[test]
1383    fn paint_square_emits_shape() {
1384        let frame = render_avatar(Avatar::with_initials(lit!("JD")).shape(AvatarShape::Square));
1385        assert!(count_shapes(&frame) >= 1);
1386    }
1387
1388    #[test]
1389    fn paint_image_uses_image_pipeline() {
1390        let icon = rgba_solid(8, [50, 100, 200, 255]);
1391        let frame = render_avatar(Avatar::with_image(&icon).alt(lit!("avatar")));
1392        assert!(
1393            !frame.live_images.is_empty(),
1394            "image avatar should render an image"
1395        );
1396    }
1397
1398    #[test]
1399    fn auto_contrast_dark_bg_chooses_white() {
1400        let dark = Color::from_rgb(0.05, 0.05, 0.05);
1401        let fg = auto_contrast_text(dark);
1402        assert!(fg.r() > 0.9 && fg.g() > 0.9 && fg.b() > 0.9);
1403    }
1404
1405    #[test]
1406    fn auto_contrast_light_bg_chooses_dark() {
1407        let light = Color::from_rgb(0.95, 0.95, 0.95);
1408        let fg = auto_contrast_text(light);
1409        assert!(fg.r() < 0.3 && fg.g() < 0.3 && fg.b() < 0.3);
1410    }
1411
1412    // ── accessibility ─────────────────────────────────────────────────
1413
1414    #[test]
1415    fn accessibility_initials_default_role_is_label() {
1416        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1417        let id = tree.add(Avatar::with_initials(lit!("JD")));
1418        tree.layout(SizeProposal::exact(32.0, 32.0));
1419        let info = tree.accessibility_node(id);
1420        assert_eq!(info.role(), teksilo_core::accesskit::Role::Label);
1421        assert_eq!(info.name(), Some("JD"));
1422    }
1423
1424    #[test]
1425    fn accessibility_image_default_role_is_image() {
1426        let icon = rgba_solid(8, [10, 20, 30, 255]);
1427        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1428        let id = tree.add(Avatar::with_image(&icon).alt(lit!("Jane Doe")));
1429        tree.layout(SizeProposal::exact(32.0, 32.0));
1430        let info = tree.accessibility_node(id);
1431        assert_eq!(info.role(), teksilo_core::accesskit::Role::Image);
1432        assert_eq!(info.name(), Some("Jane Doe"));
1433    }
1434
1435    #[test]
1436    fn accessibility_clickable_becomes_button() {
1437        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1438        let id = tree.add(
1439            Avatar::with_initials(lit!("JD"))
1440                .label(lit!("Open user menu"))
1441                .on_activate_fn(|_ctx| {}),
1442        );
1443        tree.layout(SizeProposal::exact(32.0, 32.0));
1444        let info = tree.accessibility_node(id);
1445        assert_eq!(info.role(), teksilo_core::accesskit::Role::Button);
1446        assert!(
1447            info.actions()
1448                .contains(&teksilo_core::accesskit::Action::Click)
1449        );
1450        assert!(
1451            info.actions()
1452                .contains(&teksilo_core::accesskit::Action::Focus)
1453        );
1454        assert_eq!(info.name(), Some("Open user menu"));
1455    }
1456
1457    #[test]
1458    fn accessibility_a11y_hidden_does_not_set_role() {
1459        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1460        let id = tree.add(Avatar::with_initials(lit!("JD")).a11y_hidden());
1461        tree.layout(SizeProposal::exact(32.0, 32.0));
1462        let info = tree.accessibility_node(id);
1463        // Hidden nodes carry no name (the leaf-hidden path returned
1464        // early before set_role/set_name fired).
1465        assert_eq!(info.name(), None);
1466    }
1467
1468    #[test]
1469    fn accessibility_label_overrides_initials() {
1470        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1471        let id = tree.add(Avatar::with_initials(lit!("JD")).label(lit!("Jane Doe (offline)")));
1472        tree.layout(SizeProposal::exact(32.0, 32.0));
1473        let info = tree.accessibility_node(id);
1474        assert_eq!(info.name(), Some("Jane Doe (offline)"));
1475    }
1476
1477    #[test]
1478    fn accessibility_presence_appears_in_description() {
1479        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1480        let id = tree.add(Avatar::with_initials(lit!("JD")).presence(AvatarPresence::Online));
1481        tree.layout(SizeProposal::exact(32.0, 32.0));
1482        // Just verify it builds — `description` isn't surfaced by the
1483        // test introspection helper, but that the avatar accepts the
1484        // presence and renders without panicking is the key contract.
1485        assert_eq!(
1486            tree.accessibility_node(id).role(),
1487            teksilo_core::accesskit::Role::Label
1488        );
1489    }
1490
1491    #[test]
1492    fn an_initials_avatar_is_reviewable_by_character() {
1493        // A `Role::Label` with no `Role::TextRun` children supports no text
1494        // ranges at all, so the avatar's name would be announceable and
1495        // nothing more. `supports_text_ranges` and `bounding_boxes` are
1496        // consumer methods; neither can be asserted off the emitted node.
1497        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1498        let id = tree.add(Avatar::with_initials(lit!("JD")));
1499        tree.layout(SizeProposal::exact(32.0, 32.0));
1500        let update = tree.sync_accessibility();
1501        let node_id = teksilo_core::accessibility::widget_id_to_node_id(id);
1502
1503        let consumer = accesskit_consumer::Tree::new(update, false);
1504        let state = consumer.state();
1505        let mut stack = vec![state.root()];
1506        let node = loop {
1507            let node = stack
1508                .pop()
1509                .expect("the avatar is absent from the emitted tree");
1510            if node.locate().0 == node_id {
1511                break node;
1512            }
1513            stack.extend(node.children());
1514        };
1515        assert!(node.supports_text_ranges());
1516        assert_eq!(node.document_range().text(), "JD");
1517        assert!(
1518            !node.document_range().bounding_boxes().is_empty(),
1519            "the avatar reports no geometry, so a magnifier cannot follow it"
1520        );
1521    }
1522
1523    #[test]
1524    fn a_clickable_avatar_keeps_its_button_role_and_drops_the_runs() {
1525        // Runs under a role no platform reads them for are inert, and
1526        // `AccessNodeBuilder::build` drops them rather than paying a node
1527        // per update for something nobody can reach.
1528        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1529        let id = tree.add(
1530            Avatar::with_initials(lit!("JD"))
1531                .label(lit!("Open user menu"))
1532                .on_activate_fn(|_| {}),
1533        );
1534        tree.layout(SizeProposal::exact(32.0, 32.0));
1535        let update = tree.sync_accessibility();
1536        let node_id = teksilo_core::accessibility::widget_id_to_node_id(id);
1537
1538        let consumer = accesskit_consumer::Tree::new(update, false);
1539        let state = consumer.state();
1540        let mut stack = vec![state.root()];
1541        let node = loop {
1542            let node = stack
1543                .pop()
1544                .expect("the avatar is absent from the emitted tree");
1545            if node.locate().0 == node_id {
1546                break node;
1547            }
1548            stack.extend(node.children());
1549        };
1550        assert_eq!(node.role(), teksilo_core::accesskit::Role::Button);
1551        assert!(!node.supports_text_ranges());
1552        assert!(
1553            node.children()
1554                .all(|child| child.role() != teksilo_core::accesskit::Role::TextRun),
1555            "the inert runs must be gone"
1556        );
1557    }
1558
1559    // ── visibility binding ────────────────────────────────────────────
1560
1561    #[test]
1562    fn image_visible_false_hides_image_child() {
1563        use teksilo_core::signal::Signal;
1564
1565        let icon = rgba_solid(8, [10, 20, 30, 255]);
1566        let visible = Signal::new(true);
1567        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1568        let id = tree.add(
1569            Avatar::with_image(&icon)
1570                .alt(lit!("Jane"))
1571                .fallback_initials(lit!("JD"))
1572                .image_visible(visible.clone()),
1573        );
1574        tree.layout(SizeProposal::exact(32.0, 32.0));
1575        // With visibility = true, an image quad is emitted.
1576        assert!(!tree.render().live_images.is_empty());
1577
1578        // Flip visibility — the image child becomes dormant; on the
1579        // next render frame, no image is drawn.
1580        visible.set(false);
1581        tree.layout(SizeProposal::exact(32.0, 32.0));
1582        let frame_after = tree.render();
1583        assert!(
1584            frame_after.live_images.is_empty(),
1585            "image should be hidden when image_visible == false"
1586        );
1587        // Sanity: avatar itself is still visible.
1588        assert!(tree.is_visible(id));
1589    }
1590
1591    // ── shape interaction with image masking ──────────────────────────
1592
1593    // ── foreground / background overrides ─────────────────────────────
1594
1595    fn glyph_colors(frame: &teksilo_canvas::RenderFrame) -> Vec<[f32; 4]> {
1596        frame.glyphs.iter().map(|g| g.color).collect()
1597    }
1598
1599    fn shape_colors(frame: &teksilo_canvas::RenderFrame) -> Vec<[f32; 4]> {
1600        frame.shapes.iter().map(|s| s.color).collect()
1601    }
1602
1603    fn approx_color_eq(a: [f32; 4], b: Color) -> bool {
1604        let target = b.to_array();
1605        a.iter()
1606            .zip(target.iter())
1607            .all(|(x, y)| (x - y).abs() < 0.02)
1608    }
1609
1610    #[test]
1611    fn foreground_override_sets_glyph_color() {
1612        // Without override the foreground is auto-contrast — for some
1613        // hash bg it'll be white, for others near-black. We force a
1614        // specific colour and verify it ends up in glyph metadata.
1615        let frame = render_avatar(
1616            Avatar::with_initials(lit!("JD")).foreground(Color::from_rgb(1.0, 0.0, 0.5)),
1617        );
1618        let target = Color::from_rgb(1.0, 0.0, 0.5);
1619        assert!(
1620            glyph_colors(&frame)
1621                .iter()
1622                .any(|c| approx_color_eq(*c, target)),
1623            "expected at least one glyph painted with the foreground override"
1624        );
1625    }
1626
1627    #[test]
1628    fn background_override_sets_bg_shape_color() {
1629        let frame = render_avatar(
1630            Avatar::with_initials(lit!("JD")).background(Color::from_rgb(0.1, 0.7, 0.2)),
1631        );
1632        let target = Color::from_rgb(0.1, 0.7, 0.2);
1633        assert!(
1634            shape_colors(&frame)
1635                .iter()
1636                .any(|c| approx_color_eq(*c, target)),
1637            "expected the bg override colour to appear on a Shape quad"
1638        );
1639    }
1640
1641    #[test]
1642    fn auto_contrast_uses_overridden_bg_for_initials_text() {
1643        // With a near-white background override and no foreground
1644        // override, auto-contrast should pick a dark text colour.
1645        let frame = render_avatar(
1646            Avatar::with_initials(lit!("JD")).background(Color::from_rgb(0.95, 0.95, 0.95)),
1647        );
1648        let glyphs = glyph_colors(&frame);
1649        assert!(
1650            !glyphs.is_empty(),
1651            "expected at least one initials glyph in the frame"
1652        );
1653        for g in &glyphs {
1654            // Each channel should be in the dark range.
1655            assert!(
1656                g[0] < 0.3 && g[1] < 0.3 && g[2] < 0.3,
1657                "expected dark auto-contrast glyph against a light bg, got {:?}",
1658                g
1659            );
1660        }
1661    }
1662
1663    #[test]
1664    fn auto_contrast_uses_overridden_bg_against_dark() {
1665        let frame = render_avatar(
1666            Avatar::with_initials(lit!("JD")).background(Color::from_rgb(0.05, 0.05, 0.05)),
1667        );
1668        let glyphs = glyph_colors(&frame);
1669        assert!(!glyphs.is_empty());
1670        for g in &glyphs {
1671            assert!(
1672                g[0] > 0.9 && g[1] > 0.9 && g[2] > 0.9,
1673                "expected white auto-contrast glyph against a dark bg, got {:?}",
1674                g
1675            );
1676        }
1677    }
1678
1679    #[test]
1680    fn with_name_seed_drives_bg_palette_pick() {
1681        // Two avatars with the same DERIVED initials but DIFFERENT
1682        // full names must pick distinct palette buckets — proving the
1683        // hash uses the seed (full name), not the initials.
1684        // ("Jane Doe" → JD, "Jules Dupont" → JD: identical initials.)
1685        let a = render_avatar(Avatar::with_name(lit!("Jane Doe")));
1686        let b = render_avatar(Avatar::with_name(lit!("Jules Dupont")));
1687        let bg_a = shape_colors(&a)
1688            .into_iter()
1689            .next()
1690            .expect("first shape is the bg circle");
1691        let bg_b = shape_colors(&b)
1692            .into_iter()
1693            .next()
1694            .expect("first shape is the bg circle");
1695        assert_ne!(
1696            bg_a, bg_b,
1697            "Jane Doe and Jules Dupont share initials JD but must hash distinctly via their full names"
1698        );
1699    }
1700
1701    // ── accessibility for image avatars: inner ImageWidget is silenced ─
1702
1703    // ── disclosure pattern (has_popup / expanded_when) ────────────────
1704
1705    #[test]
1706    fn expanded_when_signal_reflects_in_a11y() {
1707        use teksilo_core::signal::Signal;
1708        let open = Signal::new(false);
1709        let mut tree = WidgetTree::new()
1710            .with_theme(teksilo_core::presets::intui::light())
1711            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1712                teksilo_canvas::MockTextBackend::new(),
1713            )));
1714        let id = tree.add(
1715            Avatar::with_initials(lit!("JD"))
1716                .label(lit!("Open user menu"))
1717                .has_popup(teksilo_core::accesskit::HasPopup::Menu)
1718                .expanded_when(open.clone())
1719                .on_activate_fn(|_ctx| {}),
1720        );
1721        tree.layout(SizeProposal::exact(32.0, 32.0));
1722        // Closed.
1723        assert!(!tree.accessibility_node(id).is_expanded());
1724
1725        // Flip — the binding registered in build() must dirty-mark
1726        // this node so the next a11y query sees the new value.
1727        open.set(true);
1728        tree.layout(SizeProposal::exact(32.0, 32.0));
1729        assert!(tree.accessibility_node(id).is_expanded());
1730    }
1731
1732    #[test]
1733    fn has_popup_without_clickable_still_compiles() {
1734        // Non-clickable avatars can still declare `has_popup` —
1735        // builder is a no-op functionally without an action handler,
1736        // but we want `accessibility()` to safely surface it for
1737        // wrappers that supply external state.
1738        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1739        let id = tree.add(
1740            Avatar::with_initials(lit!("JD")).has_popup(teksilo_core::accesskit::HasPopup::Menu),
1741        );
1742        tree.layout(SizeProposal::exact(32.0, 32.0));
1743        // Role stays Label since there's no on_activate_fn.
1744        assert_eq!(
1745            tree.accessibility_node(id).role(),
1746            teksilo_core::accesskit::Role::Label
1747        );
1748    }
1749
1750    // ── focus ring ────────────────────────────────────────────────────
1751
1752    #[test]
1753    fn focus_ring_only_paints_when_focused() {
1754        // Build a real clickable avatar, then drive focus through the
1755        // tree — focus plus a key event, since the ring is
1756        // `:focus-visible`.
1757
1758        let mut tree = WidgetTree::new()
1759            .with_theme(teksilo_core::presets::intui::light())
1760            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1761                teksilo_canvas::MockTextBackend::new(),
1762            )));
1763        let id = tree.add(
1764            Avatar::with_initials(lit!("JD"))
1765                .label(lit!("Open user menu"))
1766                .on_activate_fn(|_ctx| {}),
1767        );
1768        tree.layout(SizeProposal::exact(64.0, 64.0));
1769        let unfocused_shapes = tree.render().shapes.len();
1770
1771        // Focus the avatar AND flip the input modality to keyboard. The focus
1772        // ring is `:focus-visible` (keyboard-only), so it paints only after a
1773        // key event — not on programmatic or pointer focus.
1774        tree.focus(id);
1775        tree.press_key(
1776            teksilo_core::event::Key::ArrowDown,
1777            teksilo_core::event::Modifiers::NONE,
1778        );
1779        tree.layout(SizeProposal::exact(64.0, 64.0));
1780        let focused_shapes = tree.render().shapes.len();
1781
1782        assert_eq!(
1783            focused_shapes,
1784            unfocused_shapes + 1,
1785            "focused avatar should emit one extra Shape (the focus ring stroke)"
1786        );
1787    }
1788
1789    #[test]
1790    fn focus_ring_uses_theme_focus_ring_color() {
1791        let mut tree = WidgetTree::new()
1792            .with_theme(teksilo_core::presets::intui::light())
1793            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1794                teksilo_canvas::MockTextBackend::new(),
1795            )));
1796        let id = tree.add(
1797            Avatar::with_initials(lit!("JD"))
1798                .label(lit!("Click"))
1799                .on_activate_fn(|_ctx| {}),
1800        );
1801        tree.layout(SizeProposal::exact(64.0, 64.0));
1802        // Keyboard modality reveals the `:focus-visible` ring.
1803        tree.focus(id);
1804        tree.press_key(
1805            teksilo_core::event::Key::ArrowDown,
1806            teksilo_core::event::Modifiers::NONE,
1807        );
1808        tree.layout(SizeProposal::exact(64.0, 64.0));
1809        let frame = tree.render();
1810        let target = teksilo_core::presets::intui::light().colors.focus_ring;
1811        assert!(
1812            shape_colors(&frame)
1813                .iter()
1814                .any(|c| approx_color_eq(*c, target)),
1815            "expected at least one Shape painted with the theme's focus_ring colour"
1816        );
1817    }
1818
1819    #[test]
1820    fn non_clickable_avatar_has_no_focus_ring() {
1821        // A pure Label avatar isn't focusable; it can't acquire focus,
1822        // so the chrome's `is_focused` signal never flips and the
1823        // focus-ring branch is skipped.
1824        let mut tree = WidgetTree::new()
1825            .with_theme(teksilo_core::presets::intui::light())
1826            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1827                teksilo_canvas::MockTextBackend::new(),
1828            )));
1829        let id = tree.add(Avatar::with_initials(lit!("JD")));
1830        tree.layout(SizeProposal::exact(64.0, 64.0));
1831        let baseline = tree.render().shapes.len();
1832        // Even if some test harness wrongly tried to focus a
1833        // non-focusable widget, the avatar's paint must NOT add a
1834        // focus-ring shape.
1835        tree.focus(id);
1836        tree.layout(SizeProposal::exact(64.0, 64.0));
1837        assert_eq!(
1838            tree.render().shapes.len(),
1839            baseline,
1840            "non-clickable avatar must never draw a focus ring"
1841        );
1842    }
1843
1844    #[test]
1845    fn image_avatar_announces_alt_on_parent() {
1846        // Inner ImageWidget is `a11y_hidden()` so the avatar is only
1847        // announced once. The parent carries the canonical name.
1848        let icon = rgba_solid(8, [10, 20, 30, 255]);
1849        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1850        let parent = tree.add(Avatar::with_image(&icon).alt(lit!("Jane")));
1851        tree.layout(SizeProposal::exact(32.0, 32.0));
1852        let info = tree.accessibility_node(parent);
1853        assert_eq!(info.role(), teksilo_core::accesskit::Role::Image);
1854        assert_eq!(info.name(), Some("Jane"));
1855    }
1856
1857    #[test]
1858    fn shape_change_after_image_does_not_panic() {
1859        // Pre-refactor we cached masked pixels in Avatar; setting a
1860        // different shape invalidated the cache. Now masking lives on
1861        // ImageWidget, but the test still exercises the builder-time
1862        // ordering: setting the shape after `with_image` works.
1863        let icon = rgba_solid(16, [10, 20, 30, 255]);
1864        let a = Avatar::with_image(&icon).alt(lit!("X"));
1865        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1866        let _ = tree.add(a.shape(AvatarShape::Square));
1867        tree.layout(SizeProposal::exact(32.0, 32.0));
1868        let _ = tree.render();
1869    }
1870
1871    // ── Dynamic content (bind_*) ──────────────────────────────────────
1872
1873    #[test]
1874    fn name_updates_displayed_initials_on_signal_flip() {
1875        use std::cell::RefCell;
1876        use std::rc::Rc as StdRc;
1877        use teksilo_canvas::MockTextBackend;
1878        use teksilo_core::signal::Signal;
1879        let name = Signal::new(String::new()); // logged-out
1880        let mut tree = WidgetTree::new()
1881            .with_theme(teksilo_core::presets::intui::light())
1882            .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1883        let id = tree.add(Avatar::with_initials(lit!("?")).name_signal(name.clone()));
1884        tree.layout(SizeProposal::exact(32.0, 32.0));
1885        // Empty name ⇒ derived initials = "?".
1886        assert_eq!(tree.accessibility_node(id).name(), Some("?"));
1887
1888        name.set("Jane Doe".to_string());
1889        tree.layout(SizeProposal::exact(32.0, 32.0));
1890        // After the rebuild, derived initials = "JD".
1891        assert_eq!(tree.accessibility_node(id).name(), Some("JD"));
1892    }
1893
1894    #[test]
1895    fn image_swap_logged_out_to_logged_in() {
1896        // The login-flow scenario from the API doc: start without an
1897        // image (initials fallback), then publish a real photo.
1898        use std::cell::RefCell;
1899        use std::rc::Rc as StdRc;
1900        use teksilo_canvas::MockTextBackend;
1901        use teksilo_core::signal::Signal;
1902        let icon = rgba_solid(8, [10, 20, 30, 255]);
1903        let image: Signal<Option<Rc<RasterIcon>>> = Signal::new(None);
1904        let mut tree = WidgetTree::new()
1905            .with_theme(teksilo_core::presets::intui::light())
1906            .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1907        let _id = tree.add(
1908            Avatar::with_initials(lit!("JD"))
1909                .alt(lit!("Jane"))
1910                .image_signal(image.clone()),
1911        );
1912        tree.layout(SizeProposal::exact(32.0, 32.0));
1913        // Logged-out: no image quad emitted.
1914        assert!(
1915            tree.render().live_images.is_empty(),
1916            "logged-out avatar must not emit an image quad"
1917        );
1918
1919        // Logged in.
1920        image.set(Some(Rc::new(icon)));
1921        tree.layout(SizeProposal::exact(32.0, 32.0));
1922        assert!(
1923            !tree.render().live_images.is_empty(),
1924            "logged-in avatar must emit an image quad after the signal flips"
1925        );
1926
1927        // Logged out again.
1928        image.set(None);
1929        tree.layout(SizeProposal::exact(32.0, 32.0));
1930        assert!(
1931            tree.render().live_images.is_empty(),
1932            "image quad must disappear when the source signal returns to None"
1933        );
1934    }
1935
1936    #[test]
1937    fn image_signal_wins_over_static_with_image() {
1938        // If both are supplied, the bound signal is the source of
1939        // truth — `None` ⇒ initials fallback even when a static
1940        // image was provided first.
1941        use std::cell::RefCell;
1942        use std::rc::Rc as StdRc;
1943        use teksilo_canvas::MockTextBackend;
1944        use teksilo_core::signal::Signal;
1945        let icon = rgba_solid(8, [10, 20, 30, 255]);
1946        let image: Signal<Option<Rc<RasterIcon>>> = Signal::new(None);
1947        let mut tree = WidgetTree::new()
1948            .with_theme(teksilo_core::presets::intui::light())
1949            .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1950        let _id = tree.add(
1951            Avatar::with_image(&icon)
1952                .alt(lit!("anything"))
1953                .fallback_initials(lit!("XX"))
1954                .image_signal(image.clone()),
1955        );
1956        tree.layout(SizeProposal::exact(32.0, 32.0));
1957        // Signal None overrides the static source — initials only.
1958        assert!(tree.render().live_images.is_empty());
1959    }
1960
1961    #[test]
1962    fn alt_updates_a11y_name_on_image_avatar() {
1963        use std::cell::RefCell;
1964        use std::rc::Rc as StdRc;
1965        use teksilo_canvas::MockTextBackend;
1966        use teksilo_core::signal::Signal;
1967        let icon = rgba_solid(8, [10, 20, 30, 255]);
1968        let alt = Signal::new(Some("Jane Doe".to_string()));
1969        let mut tree = WidgetTree::new()
1970            .with_theme(teksilo_core::presets::intui::light())
1971            .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1972        let id = tree.add(Avatar::with_image(&icon).alt_signal(alt.clone()));
1973        tree.layout(SizeProposal::exact(32.0, 32.0));
1974        assert_eq!(tree.accessibility_node(id).name(), Some("Jane Doe"));
1975
1976        alt.set(Some("Jules Dupont".to_string()));
1977        tree.layout(SizeProposal::exact(32.0, 32.0));
1978        assert_eq!(tree.accessibility_node(id).name(), Some("Jules Dupont"));
1979    }
1980
1981    #[test]
1982    fn presence_swap_changes_dot_color_and_a11y_description() {
1983        use teksilo_core::signal::Signal;
1984        let presence: Signal<Option<AvatarPresence>> = Signal::new(Some(AvatarPresence::Online));
1985        let mut tree = WidgetTree::new()
1986            .with_theme(teksilo_core::presets::intui::light())
1987            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1988                teksilo_canvas::MockTextBackend::new(),
1989            )));
1990        let id = tree.add(Avatar::with_initials(lit!("JD")).presence_signal(presence.clone()));
1991        tree.layout(SizeProposal::exact(32.0, 32.0));
1992        let online_color = teksilo_core::presets::intui::light()
1993            .colors
1994            .status_success_fg;
1995        assert!(
1996            shape_colors(&tree.render())
1997                .iter()
1998                .any(|c| approx_color_eq(*c, online_color)),
1999            "Online presence should paint the success colour"
2000        );
2001        let _ = id;
2002
2003        // Flip to Busy.
2004        presence.set(Some(AvatarPresence::Busy));
2005        tree.layout(SizeProposal::exact(32.0, 32.0));
2006        let busy_color = teksilo_core::presets::intui::light().colors.status_error_fg;
2007        assert!(
2008            shape_colors(&tree.render())
2009                .iter()
2010                .any(|c| approx_color_eq(*c, busy_color)),
2011            "Busy presence should paint the error colour"
2012        );
2013
2014        // Hide.
2015        presence.set(None);
2016        tree.layout(SizeProposal::exact(32.0, 32.0));
2017        let frame = tree.render();
2018        // No presence dot ⇒ neither status colour is on the frame.
2019        assert!(
2020            !shape_colors(&frame)
2021                .iter()
2022                .any(|c| approx_color_eq(*c, online_color) || approx_color_eq(*c, busy_color)),
2023            "presence None must remove the dot from the frame"
2024        );
2025    }
2026
2027    // ── tooltip ───────────────────────────────────────────────────────
2028
2029    #[test]
2030    fn tooltip_appears_on_hover() {
2031        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2032        let id = tree.add(Avatar::with_initials(lit!("JD")).tooltip(lit!("Tip")));
2033        tree.layout(SizeProposal::exact(300.0, 200.0));
2034        tree.pointer_move(tree.bounds(id).center());
2035        tree.advance_time(std::time::Duration::from_secs(1));
2036        assert_eq!(
2037            tree.active_overlays().len(),
2038            1,
2039            "tooltip should appear on hover"
2040        );
2041        assert!(tree.find_by_label("Tip").is_some());
2042    }
2043
2044    #[test]
2045    fn name_changes_hash_seed_so_palette_pick_can_change() {
2046        // Distinct full names with identical initials produce distinct
2047        // palette buckets. After name_signal flips between them, the
2048        // bg shape colour must change too.
2049        use std::cell::RefCell;
2050        use std::rc::Rc as StdRc;
2051        use teksilo_canvas::MockTextBackend;
2052        use teksilo_core::signal::Signal;
2053        let name = Signal::new("Jane Doe".to_string());
2054        let mut tree = WidgetTree::new()
2055            .with_theme(teksilo_core::presets::intui::light())
2056            .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
2057        let _id = tree.add(Avatar::with_initials(lit!("?")).name_signal(name.clone()));
2058        tree.layout(SizeProposal::exact(32.0, 32.0));
2059        let bg_jd = shape_colors(&tree.render())
2060            .into_iter()
2061            .next()
2062            .expect("bg circle is the first Shape");
2063
2064        name.set("Jules Dupont".to_string());
2065        tree.layout(SizeProposal::exact(32.0, 32.0));
2066        let bg_jd2 = shape_colors(&tree.render()).into_iter().next().unwrap();
2067        assert_ne!(
2068            bg_jd, bg_jd2,
2069            "different bound names must hash to different palette buckets"
2070        );
2071    }
2072
2073    // -----------------------------------------------------------------
2074    // Touch targets
2075    // -----------------------------------------------------------------
2076
2077    /// A 20 dp avatar beside a name, both inside a comment-list row that takes
2078    /// taps of its own — the shape the census names as the reason the miss-only
2079    /// slop pass cannot serve the avatar.
2080    ///
2081    /// The row is what makes this test discriminate. The slop pass only wins
2082    /// when the exact hit's bubble path carries no eligible handler, or when
2083    /// its candidate is strictly closer than that path's owner; the row owns
2084    /// the press at distance zero, and a near-miss does not beat zero. So the
2085    /// only mechanism left that can carry the press to the avatar is its own
2086    /// `hit_outset`, and deleting it turns this red.
2087    fn avatar_in_a_tappable_row(
2088        hits: std::rc::Rc<std::cell::Cell<u32>>,
2089        row_taps: std::rc::Rc<std::cell::Cell<u32>>,
2090    ) -> (WidgetTree, teksilo_core::widget_id::WidgetId) {
2091        use teksilo_core::widget_builder::WidgetBuilder;
2092
2093        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2094        let av = tree.add(
2095            Avatar::with_initials(lit!("JD"))
2096                .size(AvatarSize::Custom(20.0))
2097                .on_activate_fn(move |_| hits.set(hits.get() + 1)),
2098        );
2099        let name = tree.add(crate::primitives::TextWidget::new(lit!("Jane Doe")));
2100        let _row = tree.add(
2101            crate::primitives::HStack::new()
2102                .child(av)
2103                .child(name)
2104                .on_tap(move |_e, _c| row_taps.set(row_taps.get() + 1)),
2105        );
2106        tree.layout(SizeProposal::exact(200.0, 60.0));
2107        (tree, av)
2108    }
2109
2110    /// The four shipped sizes all clear the floor, but `AvatarSize::Custom` need
2111    /// not: a 20 dp avatar in a dense comment list earns the difference from the
2112    /// pointer side rather than from the layout.
2113    #[test]
2114    fn a_finger_just_outside_a_small_custom_avatar_still_activates_it() {
2115        use crate::button::press_test_support::{finger, touch};
2116        use teksilo_core::pointer::PointerPhase;
2117
2118        let hits = std::rc::Rc::new(std::cell::Cell::new(0_u32));
2119        let row_taps = std::rc::Rc::new(std::cell::Cell::new(0_u32));
2120        let (mut tree, av) = avatar_in_a_tappable_row(hits.clone(), row_taps.clone());
2121        let b = tree.bounds(av);
2122        assert!(
2123            b.width < 24.0,
2124            "the avatar is meant to be under the floor: {b:?}"
2125        );
2126        // 1.5 dp past the disc, inside the 24 dp target the outset earns it.
2127        let at = teksilo_canvas::Point::new(b.right() + 1.5, b.center().y);
2128        let id = finger();
2129        tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
2130        tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 30));
2131        assert_eq!(hits.get(), 1, "the avatar's outset did not take the press");
2132        assert_eq!(row_taps.get(), 0, "and the row must not have taken it too");
2133    }
2134
2135    /// A mouse is exact: the same press lands on the row, exactly as it did
2136    /// before the touch programme.
2137    #[test]
2138    fn a_mouse_just_outside_a_small_custom_avatar_lands_on_the_row() {
2139        let hits = std::rc::Rc::new(std::cell::Cell::new(0_u32));
2140        let row_taps = std::rc::Rc::new(std::cell::Cell::new(0_u32));
2141        let (mut tree, av) = avatar_in_a_tappable_row(hits.clone(), row_taps.clone());
2142        let b = tree.bounds(av);
2143        let at = teksilo_canvas::Point::new(b.right() + 1.5, b.center().y);
2144        tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
2145        tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
2146        assert_eq!(
2147            hits.get(),
2148            0,
2149            "a mouse must not be given the avatar's outset"
2150        );
2151        assert_eq!(row_taps.get(), 1);
2152    }
2153
2154    /// A decorative avatar takes no press, so it claims no widened target —
2155    /// widening a node that then ignores the press is a hole punched in
2156    /// whatever it sits on.
2157    #[test]
2158    fn a_decorative_avatar_declares_no_outset() {
2159        use teksilo_core::widget::Widget;
2160        use teksilo_tokens::{InputTokens, PointerKind};
2161
2162        let plain = Avatar::with_initials(lit!("JD")).size(AvatarSize::Custom(20.0));
2163        assert_eq!(
2164            plain.hit_outset(PointerKind::Touch, &InputTokens::default()),
2165            teksilo_canvas::EdgeInsets::ZERO,
2166        );
2167    }
2168}