Skip to main content

teksilo_core/styles/
avatar_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Tier-3 style protocol for `Avatar`. See `docs/styling-system.md`.
5//!
6//! The style owns everything *around* the inner content: the shape's
7//! background fill (with the hash-derived palette pick when no caller
8//! override is supplied), the border ring, the keyboard focus ring,
9//! and the presence indicator dot. The `Avatar` widget builds the
10//! inner content (`InitialsLeaf` or an `ImageWidget`) and passes it
11//! in as a pre-built `content` id; the style composes its chrome
12//! around that content.
13//!
14//! Avatar's domain enums (`AvatarShape`, `AvatarSize`, `AvatarPresence`,
15//! `AvatarCorner`) live here so the config can carry them and custom
16//! `AvatarStyle` implementations can branch on them.
17
18use std::rc::Rc;
19
20use teksilo_tokens::Color;
21
22use crate::build_context::BuildContext;
23use crate::color_prop::ColorProp;
24use crate::signal::Signal;
25use crate::widget_id::WidgetId;
26
27/// Discrete avatar size variants. `Custom(px)` accepts an arbitrary
28/// logical-pixel side length. The default size resolution table lives
29/// in `teksilo_widgets::styles::recipe_avatar_style::avatar_pixel_size`.
30#[derive(Debug, Clone, Copy, PartialEq, Default)]
31pub enum AvatarSize {
32    /// Small — list rows, mention chips.
33    Small,
34    /// Medium — comment threads, sidebars (default).
35    #[default]
36    Medium,
37    /// Large — profile cards.
38    Large,
39    /// X-large — settings, "your account" headers.
40    XLarge,
41    /// Arbitrary side length.
42    Custom(f32),
43}
44
45/// Outer outline.
46#[derive(Debug, Clone, Copy, PartialEq, Default)]
47pub enum AvatarShape {
48    #[default]
49    Circle,
50    RoundedSquare,
51    Square,
52}
53
54/// Presence indicator dot drawn at one corner of the avatar.
55#[derive(Debug, Clone)]
56pub enum AvatarPresence {
57    Online,
58    Offline,
59    Away,
60    Busy,
61    Custom { color: ColorProp, label: String },
62}
63
64impl AvatarPresence {
65    /// Resolve the dot's fill colour against the active theme.
66    pub fn color(&self, theme: &crate::styles::Theme) -> Color {
67        match self {
68            AvatarPresence::Online => theme.colors.status_success_fg,
69            AvatarPresence::Offline => theme.colors.text_disabled,
70            AvatarPresence::Away => theme.colors.status_warning_fg,
71            AvatarPresence::Busy => theme.colors.status_error_fg,
72            // The Avatar widget calls this helper from its paint() but
73            // doesn't currently thread `effective_enabled` here. When
74            // the Avatar composite migrates (commit 4 of the
75            // enabled-state refactor) this signature widens to take
76            // `enabled: bool` and the Custom presence respects it.
77            AvatarPresence::Custom { color, .. } => color.resolve(theme, true),
78        }
79    }
80
81    /// Accessible label for screen readers.
82    pub fn label(&self) -> String {
83        match self {
84            AvatarPresence::Online => "Online".to_string(),
85            AvatarPresence::Offline => "Offline".to_string(),
86            AvatarPresence::Away => "Away".to_string(),
87            AvatarPresence::Busy => "Busy".to_string(),
88            AvatarPresence::Custom { label, .. } => label.clone(),
89        }
90    }
91}
92
93/// Where the presence dot is rendered relative to the avatar bounds.
94#[derive(Debug, Clone, Copy, PartialEq, Default)]
95pub enum AvatarCorner {
96    #[default]
97    BottomTrailing,
98    BottomLeading,
99    TopTrailing,
100    TopLeading,
101}
102
103impl AvatarCorner {
104    /// `(x_factor, y_factor)` in `{-1, 1}` — `-1` = leading/top, `1` =
105    /// trailing/bottom. Used by the recipe to position the presence
106    /// dot.
107    pub fn offset(self) -> (f32, f32) {
108        match self {
109            AvatarCorner::BottomTrailing => (1.0, 1.0),
110            AvatarCorner::BottomLeading => (-1.0, 1.0),
111            AvatarCorner::TopTrailing => (1.0, -1.0),
112            AvatarCorner::TopLeading => (-1.0, -1.0),
113        }
114    }
115}
116
117#[derive(Clone, Debug)]
118pub struct AvatarStyleConfig {
119    pub shape: AvatarShape,
120    pub size: AvatarSize,
121    /// Pre-built content subtree (`InitialsLeaf` or `ImageWidget`).
122    pub content: WidgetId,
123    /// Current presence (if any). The widget passes the live value
124    /// resolved from any bound signal at build time; reactive presence
125    /// changes re-run `Avatar::build` so the chrome rebuilds with the
126    /// new value.
127    pub presence: Option<AvatarPresence>,
128    pub presence_corner: AvatarCorner,
129    /// `true` while the avatar holds keyboard focus — drives the
130    /// outer focus ring.
131    pub is_focused: Signal<bool>,
132    /// Caller override for the background fill. `None` lets the
133    /// recipe pick a colour from the chart palette using `seed`.
134    pub background_override: Option<ColorProp>,
135    /// Caller override for the border ring colour. `None` lets the
136    /// recipe use `theme.colors.surface_main`.
137    pub border_color_override: Option<ColorProp>,
138    /// Caller override for the border ring width. `None` = no border.
139    pub border_width_override: Option<f32>,
140    /// Seed string for the hash-derived background palette pick
141    /// (typically the avatar's name or initials).
142    pub seed: String,
143}
144
145pub trait AvatarStyle: 'static {
146    fn make_body(&self, cfg: &AvatarStyleConfig, ctx: &mut BuildContext) -> WidgetId;
147}
148
149pub type SharedAvatarStyle = Rc<dyn AvatarStyle>;