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