Skip to main content

teksilo_widgets/styles/
recipe_avatar_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `AvatarStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeAvatarStyle` ports the IntUI avatar chrome exactly: the
7//! shape-aware background fill (with hash-derived palette pick when no
8//! caller override is supplied), the optional border ring drawn over
9//! the inner content to mask any image bleed at the rim, the keyboard
10//! focus ring hugging the configured shape, and the presence
11//! indicator dot positioned at one of the four corners with a
12//! `surface_main`-coloured outline.
13//!
14//! The chrome helpers (`hash_pick_palette_color`, `auto_contrast_text`,
15//! `paint_border`, `paint_focus_ring`, `fnv1a_64`, `avatar_pixel_size`)
16//! live here so custom `AvatarStyle` implementations can reuse them
17//! when they only want to swap one piece of the chrome.
18
19use teksilo_canvas::{Canvas, Paint, Point, Rect, Size, SizeProposal, StrokeStyle};
20use teksilo_core::accessibility::AccessNodeBuilder;
21use teksilo_core::binding::BindingLevel;
22use teksilo_core::build_context::BuildContext;
23use teksilo_core::color_prop::ColorProp;
24use teksilo_core::signal::Signal;
25use teksilo_core::styles::density::{dp, spacing};
26use teksilo_core::styles::{
27    AvatarCorner, AvatarPresence, AvatarShape, AvatarSize, AvatarStyle, AvatarStyleConfig,
28    FocusRingStroke,
29};
30use teksilo_core::widget::{
31    LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
32};
33use teksilo_core::widget_id::WidgetId;
34use teksilo_tokens::{Color, CornerRadius, InputTokens, TargetRole};
35
36// ─── IntUI design tokens for Avatar ────────────────────────────────
37// The recipe owns its own dimensions. `Avatar` (and its `InitialsLeaf`
38// sub-widget) reads the public `pub const`s below directly when it
39// needs sizing data outside the chrome frame.
40
41pub const AVATAR_SIZE_SMALL: f32 = 24.0;
42pub const AVATAR_SIZE_MEDIUM: f32 = 32.0;
43pub const AVATAR_SIZE_LARGE: f32 = 48.0;
44pub const AVATAR_SIZE_X_LARGE: f32 = 64.0;
45
46/// Default border (ring) thickness when `.border()` is called without
47/// an explicit width override.
48pub const AVATAR_BORDER_DEFAULT: f32 = 2.0;
49
50/// Presence dot diameter as a fraction of avatar diameter.
51pub const AVATAR_PRESENCE_DIAMETER_RATIO: f32 = 0.28;
52pub const AVATAR_PRESENCE_DIAMETER_MIN: f32 = 8.0;
53pub const AVATAR_PRESENCE_DIAMETER_MAX: f32 = 20.0;
54/// Outline drawn around the presence dot.
55pub const AVATAR_PRESENCE_OUTLINE_WIDTH: f32 = 1.5;
56/// Inset of the presence dot from the avatar's bounding box edge.
57pub const AVATAR_PRESENCE_INSET: f32 = 0.0;
58
59/// Initials font-size as a fraction of avatar diameter.
60pub const AVATAR_FONT_RATIO_1CHAR: f32 = 0.45;
61pub const AVATAR_FONT_RATIO_2CHAR: f32 = 0.40;
62
63/// Corner-radius ratio for `AvatarShape::RoundedSquare`.
64pub const AVATAR_ROUNDED_RADIUS_RATIO: f32 = 0.25;
65
66/// Resolve a discrete `AvatarSize` to a logical-pixel side length
67/// using the recipe's size table. `Custom(px)` is clamped to at least
68/// 1 px.
69pub fn avatar_pixel_size(size: AvatarSize) -> f32 {
70    match size {
71        AvatarSize::Small => AVATAR_SIZE_SMALL,
72        AvatarSize::Medium => AVATAR_SIZE_MEDIUM,
73        AvatarSize::Large => AVATAR_SIZE_LARGE,
74        AvatarSize::XLarge => AVATAR_SIZE_X_LARGE,
75        AvatarSize::Custom(px) => px.max(1.0),
76    }
77}
78
79/// FNV-1a 64-bit hash — deterministic palette-bucket selection.
80pub fn fnv1a_64(bytes: &[u8]) -> u64 {
81    let mut h: u64 = 0xcbf29ce484222325;
82    for &b in bytes {
83        h ^= b as u64;
84        h = h.wrapping_mul(0x100000001b3);
85    }
86    h
87}
88
89/// Pick a colour from the theme's chart palette deterministically
90/// from `seed`. Empty palette falls back to a neutral grey so the
91/// widget still renders.
92pub fn hash_pick_palette_color(seed: &str, theme: &teksilo_core::Theme) -> Color {
93    let palette = &theme.colors.chart_palette;
94    if palette.is_empty() {
95        return Color::from_rgb(0.5, 0.5, 0.5);
96    }
97    let h = fnv1a_64(seed.as_bytes());
98    let idx = (h as usize) % palette.len();
99    palette[idx]
100}
101
102/// Auto-contrast foreground for a given background.
103pub fn auto_contrast_text(bg: Color) -> Color {
104    if bg.relative_luminance() < 0.5 {
105        Color::WHITE
106    } else {
107        Color::from_rgb(0.121, 0.121, 0.121)
108    }
109}
110
111/// Stroke a focus ring outside the avatar's content bounds. Hugs the
112/// configured shape so a square avatar gets a square ring.
113pub fn paint_focus_ring(
114    canvas: &mut Canvas,
115    bounds: Rect,
116    shape: AvatarShape,
117    rounded_radius_ratio: f32,
118    offset: f32,
119    width: f32,
120    color: Color,
121) {
122    let ring = focus_ring_stroke(bounds, shape, rounded_radius_ratio, offset, width);
123    match shape {
124        AvatarShape::Circle => {
125            let outer = ring.rect;
126            let radius = outer.width.min(outer.height) / 2.0;
127            let center = Point::new(outer.x + outer.width / 2.0, outer.y + outer.height / 2.0);
128            canvas.stroke_circle(
129                center,
130                radius,
131                Paint::from(color),
132                StrokeStyle::solid(width),
133            );
134        }
135        AvatarShape::RoundedSquare | AvatarShape::Square => {
136            canvas.stroke_rounded_rect(
137                ring.rect,
138                CornerRadius::uniform(ring.corner_radius),
139                color,
140                StrokeStyle::solid(width),
141            );
142        }
143    }
144}
145
146/// The centre line of [`paint_focus_ring`]: `offset + width / 2` outside
147/// `bounds`, hugging the avatar's shape. A negative `offset` draws the ring
148/// partly inside the avatar; the rectangle and radius never invert.
149fn focus_ring_stroke(
150    bounds: Rect,
151    shape: AvatarShape,
152    rounded_radius_ratio: f32,
153    offset: f32,
154    width: f32,
155) -> FocusRingStroke {
156    let distance = offset + width / 2.0;
157    match shape {
158        // A square ring keeps sharp corners at every distance.
159        AvatarShape::Square => FocusRingStroke {
160            corner_radius: 0.0,
161            ..FocusRingStroke::at_distance(bounds, 0.0, distance)
162        },
163        AvatarShape::RoundedSquare => {
164            let r = bounds.width.min(bounds.height) * rounded_radius_ratio;
165            FocusRingStroke::at_distance(bounds, r, distance)
166        }
167        AvatarShape::Circle => {
168            FocusRingStroke::at_distance(bounds, bounds.width.min(bounds.height) / 2.0, distance)
169        }
170    }
171}
172
173/// Stroke a border ring inside the avatar's content bounds.
174pub fn paint_border(
175    canvas: &mut Canvas,
176    bounds: Rect,
177    shape: AvatarShape,
178    rounded_radius_ratio: f32,
179    width: f32,
180    color: Color,
181) {
182    let half = width / 2.0;
183    let inner = Rect::new(
184        bounds.x + half,
185        bounds.y + half,
186        (bounds.width - width).max(0.0),
187        (bounds.height - width).max(0.0),
188    );
189    match shape {
190        AvatarShape::Circle => {
191            let radius = inner.width.min(inner.height) / 2.0;
192            let center = Point::new(inner.x + inner.width / 2.0, inner.y + inner.height / 2.0);
193            canvas.stroke_circle(
194                center,
195                radius,
196                Paint::from(color),
197                StrokeStyle::solid(width),
198            );
199        }
200        AvatarShape::RoundedSquare => {
201            let r = inner.width.min(inner.height) * rounded_radius_ratio;
202            canvas.stroke_rounded_rect(
203                inner,
204                CornerRadius::uniform(r),
205                color,
206                StrokeStyle::solid(width),
207            );
208        }
209        AvatarShape::Square => {
210            canvas.stroke_rounded_rect(
211                inner,
212                CornerRadius::uniform(0.0),
213                color,
214                StrokeStyle::solid(width),
215            );
216        }
217    }
218}
219
220/// Dimension recipe for `RecipeAvatarStyle`.
221///
222/// All tunable measurements in one place. The recipe is `Copy` so it
223/// can be stored inside the internal `AvatarChromeFrame` body widget
224/// without any allocation.
225#[derive(Debug, Clone, Copy, PartialEq)]
226pub struct AvatarRecipe {
227    pub size_small: f32,
228    pub size_medium: f32,
229    pub size_large: f32,
230    pub size_x_large: f32,
231    pub border_default: f32,
232    pub presence_diameter_ratio: f32,
233    pub presence_diameter_min: f32,
234    pub presence_diameter_max: f32,
235    pub presence_outline_width: f32,
236    pub presence_inset: f32,
237    pub font_ratio_1char: f32,
238    pub font_ratio_2char: f32,
239    pub rounded_radius_ratio: f32,
240}
241
242impl AvatarRecipe {
243    /// This recipe's dimensions resolved against a density's [`InputTokens`].
244    ///
245    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
246    /// ladder — so the shipped values below are the Compact column by
247    /// construction and cannot drift from it.
248    pub fn for_tokens(tokens: &InputTokens) -> Self {
249        Self {
250            size_small: dp(AVATAR_SIZE_SMALL, TargetRole::Target, tokens),
251            size_medium: dp(AVATAR_SIZE_MEDIUM, TargetRole::Target, tokens),
252            size_large: dp(AVATAR_SIZE_LARGE, TargetRole::Target, tokens),
253            size_x_large: dp(AVATAR_SIZE_X_LARGE, TargetRole::Target, tokens),
254            border_default: AVATAR_BORDER_DEFAULT,
255            presence_diameter_ratio: AVATAR_PRESENCE_DIAMETER_RATIO,
256            presence_diameter_min: AVATAR_PRESENCE_DIAMETER_MIN,
257            presence_diameter_max: AVATAR_PRESENCE_DIAMETER_MAX,
258            presence_outline_width: AVATAR_PRESENCE_OUTLINE_WIDTH,
259            presence_inset: spacing(AVATAR_PRESENCE_INSET, tokens),
260            font_ratio_1char: AVATAR_FONT_RATIO_1CHAR,
261            font_ratio_2char: AVATAR_FONT_RATIO_2CHAR,
262            rounded_radius_ratio: AVATAR_ROUNDED_RADIUS_RATIO,
263        }
264    }
265}
266
267impl Default for AvatarRecipe {
268    fn default() -> Self {
269        Self::for_tokens(&InputTokens::default())
270    }
271}
272
273/// Default `AvatarStyle` shipped with Teksilo.
274#[derive(Debug, Default, Clone, Copy)]
275pub struct RecipeAvatarStyle {
276    pub recipe: AvatarRecipe,
277}
278
279impl RecipeAvatarStyle {
280    pub fn new(recipe: AvatarRecipe) -> Self {
281        Self { recipe }
282    }
283
284    /// This style with every dimension resolved against a density's
285    /// [`InputTokens`], as `RecipeAvatarStyle::for_tokens(&ctx.theme().input)` at
286    /// the widget's own build site.
287    ///
288    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
289    /// tree gets exactly the values this module documents.
290    pub fn for_tokens(tokens: &InputTokens) -> Self {
291        Self {
292            recipe: AvatarRecipe::for_tokens(tokens),
293        }
294    }
295}
296
297impl AvatarStyle for RecipeAvatarStyle {
298    fn make_body(&self, cfg: &AvatarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
299        ctx.add(AvatarChromeFrame {
300            child_id: None,
301            pending_child: Some(PendingChild::Id(cfg.content)),
302            shape: cfg.shape,
303            presence: cfg.presence.clone(),
304            presence_corner: cfg.presence_corner,
305            is_focused: cfg.is_focused.clone(),
306            background: cfg.background_override.clone(),
307            border_color: cfg.border_color_override.clone(),
308            border_width: cfg.border_width_override,
309            seed: cfg.seed.clone(),
310            recipe: self.recipe,
311        })
312    }
313}
314
315/// Internal container that paints the avatar chrome (shape-aware
316/// background fill, border ring, keyboard focus ring, presence dot)
317/// around the pre-built content child. Mirrors the pre-migration
318/// `Avatar::paint` exactly.
319struct AvatarChromeFrame {
320    child_id: Option<WidgetId>,
321    pending_child: Option<PendingChild>,
322    shape: AvatarShape,
323    presence: Option<AvatarPresence>,
324    presence_corner: AvatarCorner,
325    is_focused: Signal<bool>,
326    background: Option<ColorProp>,
327    border_color: Option<ColorProp>,
328    border_width: Option<f32>,
329    seed: String,
330    recipe: AvatarRecipe,
331}
332
333impl std::fmt::Debug for AvatarChromeFrame {
334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        f.debug_struct("AvatarChromeFrame")
336            .field("shape", &self.shape)
337            .finish()
338    }
339}
340
341impl Widget for AvatarChromeFrame {
342    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
343        if let Some(pending) = self.pending_child.take() {
344            self.child_id = Some(match pending {
345                PendingChild::Id(id) => id,
346                PendingChild::Deferred(w) => ctx.add_boxed(w),
347            });
348        }
349        // Repaint when focus changes — the ring appears / disappears.
350        let id = ctx.self_id();
351        let registry = ctx.binding_registry();
352        self.is_focused
353            .bind_to(id, registry, BindingLevel::RepaintOnly);
354        self.child_id.into_iter().collect()
355    }
356
357    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
358        // Fill the parent's proposal — the parent `Avatar` widget owns
359        // the size policy (`avatar_pixel_size(self.size)`).
360        Size::new(
361            proposal.width.unwrap_or(0.0),
362            proposal.height.unwrap_or(0.0),
363        )
364        .into()
365    }
366
367    fn place_children(
368        &self,
369        bounds: Rect,
370        _proposal: SizeProposal,
371        children: &mut [WidgetPlacement],
372        _ctx: &LayoutContext,
373    ) {
374        for child in children.iter_mut() {
375            child.origin = bounds.origin();
376            child.size = bounds.size();
377        }
378    }
379
380    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
381        let theme = ctx.theme;
382
383        // Background fill — shape-aware. Default is hash-picked from
384        // the chart palette using `seed`.
385        let bg = match &self.background {
386            Some(prop) => prop.resolve(theme, ctx.effective_enabled),
387            None => hash_pick_palette_color(&self.seed, theme),
388        };
389        match self.shape {
390            AvatarShape::Circle => {
391                let radius = bounds.width.min(bounds.height) / 2.0;
392                let center = Point::new(
393                    bounds.x + bounds.width / 2.0,
394                    bounds.y + bounds.height / 2.0,
395                );
396                canvas.fill_circle(center, radius, bg);
397            }
398            AvatarShape::RoundedSquare => {
399                let r = bounds.width.min(bounds.height) * self.recipe.rounded_radius_ratio;
400                canvas.fill_rounded_rect(bounds, CornerRadius::uniform(r), bg);
401            }
402            AvatarShape::Square => {
403                canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), bg);
404            }
405        }
406
407        // Border (outer ring) — drawn over content to mask image
408        // bleed. Half-stroke inset so the ring sits inside `bounds`.
409        if let Some(width) = self.border_width
410            && width > 0.0
411        {
412            let color = match &self.border_color {
413                Some(prop) => prop.resolve(theme, ctx.effective_enabled),
414                None => theme.colors.surface_main,
415            };
416            paint_border(
417                canvas,
418                bounds,
419                self.shape,
420                self.recipe.rounded_radius_ratio,
421                width,
422                color,
423            );
424        }
425
426        // Focus ring — outside the avatar bounds, hugging the shape.
427        if self.is_focused.get() {
428            paint_focus_ring(
429                canvas,
430                bounds,
431                self.shape,
432                self.recipe.rounded_radius_ratio,
433                theme.shape.focus_ring_offset,
434                theme.shape.focus_ring_width,
435                theme.colors.focus_ring,
436            );
437        }
438
439        // Presence dot — on top of everything, with a surface_main
440        // outline that "punches" it out of the avatar.
441        if let Some(presence) = &self.presence {
442            let color = presence.color(theme);
443            let dot_diameter =
444                (bounds.width.min(bounds.height) * self.recipe.presence_diameter_ratio).clamp(
445                    self.recipe.presence_diameter_min,
446                    self.recipe.presence_diameter_max,
447                );
448            let dot_radius = dot_diameter / 2.0;
449            let (xf, yf) = self.presence_corner.offset();
450            let cx = if xf < 0.0 {
451                bounds.x + dot_radius + self.recipe.presence_inset
452            } else {
453                bounds.x + bounds.width - dot_radius - self.recipe.presence_inset
454            };
455            let cy = if yf < 0.0 {
456                bounds.y + dot_radius + self.recipe.presence_inset
457            } else {
458                bounds.y + bounds.height - dot_radius - self.recipe.presence_inset
459            };
460            let center = Point::new(cx, cy);
461            let outline_radius = dot_radius + self.recipe.presence_outline_width;
462            canvas.fill_circle(center, outline_radius, theme.colors.surface_main);
463            canvas.fill_circle(center, dot_radius, color);
464        }
465    }
466
467    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
468        // Presentational — the parent `Avatar` emits the user-facing
469        // Image / Label / Button node.
470        builder.set_hidden();
471    }
472
473    fn children(&self) -> Vec<WidgetId> {
474        self.child_id.into_iter().collect()
475    }
476}
477
478#[cfg(test)]
479mod focus_ring_tests {
480    use super::*;
481
482    #[test]
483    fn a_negative_focus_ring_offset_shrinks_the_ring_without_inverting() {
484        let bounds = Rect::new(0.0, 0.0, 32.0, 32.0);
485        for shape in [
486            AvatarShape::Circle,
487            AvatarShape::RoundedSquare,
488            AvatarShape::Square,
489        ] {
490            let outer = focus_ring_stroke(bounds, shape, 0.25, 2.0, 2.0);
491            let inner = focus_ring_stroke(bounds, shape, 0.25, -1.0, 2.0);
492            assert!(inner.rect.width < outer.rect.width, "{shape:?}");
493            assert!(inner.rect.height < outer.rect.height, "{shape:?}");
494            assert!(inner.rect.width >= 0.0 && inner.rect.height >= 0.0);
495
496            let deep = focus_ring_stroke(bounds, shape, 0.25, -40.0, 2.0);
497            assert!(deep.rect.width >= 0.0 && deep.rect.height >= 0.0);
498            assert!(deep.corner_radius >= 0.0);
499        }
500    }
501
502    #[test]
503    fn a_square_ring_keeps_sharp_corners() {
504        let bounds = Rect::new(0.0, 0.0, 32.0, 32.0);
505        let ring = focus_ring_stroke(bounds, AvatarShape::Square, 0.25, 2.0, 2.0);
506        assert_eq!(ring.corner_radius, 0.0);
507    }
508}