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