1use sequoia_core::{Color, Edges, Length, Size};
2use sequoia_layout::{LayoutCtx, LayoutStyle};
3use sequoia_style::AvatarStyle;
4use sequoia_view::{AssetRegistry, FontWeight, PaintCtx, Unit, View, icon_key};
5
6const SIZE: f32 = 32.0;
7const FONT_RATIO: f32 = 0.38;
8const ICON_RATIO: f32 = 0.55;
9
10pub fn initials_for(name: &str) -> String {
11 let mut words = name.split_whitespace();
12 let first = words.next().unwrap_or("");
13 let last = words.last();
14 let mut out = String::new();
15 if let Some(c) = first.chars().next() {
16 out.extend(c.to_uppercase());
17 }
18 if let Some(last) = last
19 && let Some(c) = last.chars().next()
20 {
21 out.extend(c.to_uppercase());
22 }
23 if out.is_empty() { "?".to_string() } else { out }
24}
25
26pub fn avatar_color_for(name: &str) -> Color {
27 const PALETTE: [Color; 8] = [
28 Color::hex(0x356FD1),
29 Color::hex(0x7A5ACD),
30 Color::hex(0x2E8B57),
31 Color::hex(0xB3541E),
32 Color::hex(0xA03A5A),
33 Color::hex(0x2A9D8F),
34 Color::hex(0x8A6D1B),
35 Color::hex(0x5A6E7F),
36 ];
37 let mut hash: u32 = 5381;
38 for b in name.bytes() {
39 hash = hash.wrapping_mul(33).wrapping_add(b as u32);
40 }
41
42 PALETTE[(hash as usize) % PALETTE.len()]
43}
44
45pub struct Avatar {
46 name: String,
47 initials: Option<String>,
48 icon: Option<String>,
49 size: f32,
50 auto_color: bool,
51 style: Option<AvatarStyle>,
52 background: Option<Color>,
53 text_color: Option<Color>,
54 font_size: Option<f32>,
55 radius: Option<f32>,
56 margin: Edges,
57}
58
59impl Avatar {
60 pub fn named(name: impl Into<String>) -> Self {
61 Self {
62 name: name.into(),
63 initials: None,
64 icon: None,
65 size: SIZE,
66 auto_color: true,
67 style: None,
68 background: None,
69 text_color: None,
70 font_size: None,
71 radius: None,
72 margin: Edges::ZERO,
73 }
74 }
75
76 pub fn icon(name: impl Into<String>) -> Self {
77 Self {
78 name: String::new(),
79 initials: None,
80 icon: Some(name.into()),
81 size: SIZE,
82 auto_color: true,
83 style: None,
84 background: None,
85 text_color: None,
86 font_size: None,
87 radius: None,
88 margin: Edges::ZERO,
89 }
90 }
91
92 pub fn initials(mut self, initials: impl Into<String>) -> Self {
93 self.initials = Some(initials.into());
94 self
95 }
96
97 pub fn size(mut self, px: f32) -> Self {
98 self.size = px.max(8.0);
99 self
100 }
101
102 pub fn auto_color(mut self, on: bool) -> Self {
103 self.auto_color = on;
104 self
105 }
106
107 pub fn style(mut self, style: AvatarStyle) -> Self {
108 self.style = Some(style);
109 self
110 }
111
112 pub fn background(mut self, color: Color) -> Self {
113 self.background = Some(color);
114 self
115 }
116
117 pub fn text_color(mut self, color: Color) -> Self {
118 self.text_color = Some(color);
119 self
120 }
121
122 pub fn font_size(mut self, size: f32) -> Self {
123 self.font_size = Some(size);
124 self
125 }
126
127 pub fn radius(mut self, radius: f32) -> Self {
128 self.radius = Some(radius);
129 self
130 }
131
132 pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
133 let m = margin.into();
134 self.margin = Edges::only(
135 sanitize_margin(m.left),
136 sanitize_margin(m.top),
137 sanitize_margin(m.right),
138 sanitize_margin(m.bottom),
139 );
140 self
141 }
142
143 pub fn margin_xy(self, x: f32, y: f32) -> Self {
144 self.margin(Edges::xy(x, y))
145 }
146
147 pub fn margin_x(mut self, x: f32) -> Self {
148 let x = sanitize_margin(x);
149 self.margin.left = x;
150 self.margin.right = x;
151 self
152 }
153
154 pub fn margin_y(mut self, y: f32) -> Self {
155 let y = sanitize_margin(y);
156 self.margin.top = y;
157 self.margin.bottom = y;
158 self
159 }
160
161 pub fn margin_left(mut self, v: f32) -> Self {
162 self.margin.left = sanitize_margin(v);
163 self
164 }
165
166 pub fn margin_right(mut self, v: f32) -> Self {
167 self.margin.right = sanitize_margin(v);
168 self
169 }
170
171 pub fn margin_top(mut self, v: f32) -> Self {
172 self.margin.top = sanitize_margin(v);
173 self
174 }
175
176 pub fn margin_bottom(mut self, v: f32) -> Self {
177 self.margin.bottom = sanitize_margin(v);
178 self
179 }
180
181 fn label(&self) -> String {
182 self.initials
183 .clone()
184 .unwrap_or_else(|| initials_for(&self.name))
185 }
186}
187
188pub(crate) fn sanitize_margin(v: f32) -> f32 {
189 if v.is_finite() { v.max(0.0) } else { 0.0 }
190}
191
192impl View for Avatar {
193 fn body(&self) -> impl View + 'static {
194 Unit
195 }
196 fn is_leaf(&self) -> bool {
197 true
198 }
199 fn hit_testable(&self) -> bool {
200 false
201 }
202 fn layout_style(&self) -> LayoutStyle {
203 LayoutStyle {
204 width: Length::Px(self.size),
205 height: Length::Px(self.size),
206 margin: self.margin,
207 ..LayoutStyle::default().hit_testable(false)
208 }
209 }
210 fn intrinsic_size(&self, _ctx: &mut LayoutCtx) -> Option<Size> {
211 Some(Size::new(self.size, self.size))
212 }
213 fn paint_self(&self, ctx: &mut PaintCtx) {
214 let rect = ctx.rect;
215 if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
216 return;
217 }
218 let theme = ctx.theme();
219 let style = self.style.unwrap_or_default();
220 let side = rect.size.width.min(rect.size.height);
221 let radius = self.radius.or(style.radius).unwrap_or(side * 0.5).max(0.0);
222 let background = self.background.or(style.background).unwrap_or_else(|| {
223 if self.auto_color && self.icon.is_none() {
224 avatar_color_for(&self.name)
225 } else {
226 theme.surface
227 }
228 });
229 ctx.quad(rect, background, radius);
230
231 let text = self.text_color.or(style.text).unwrap_or(theme.text);
232 if let Some(name) = &self.icon {
233 let px = side * ICON_RATIO;
234 if let Some(assets) = ctx.environment.get::<AssetRegistry>() {
235 let raster_px = ((px * ctx.scale_factor()).round() as u32).max(1);
236 if let Ok(sprite) = assets.rasterize_icon(name, raster_px) {
237 let x = rect.left() + (rect.size.width - px) * 0.5;
238 let y = rect.top() + (rect.size.height - px) * 0.5;
239 ctx.draw_sprite(
240 icon_key(name, raster_px),
241 sprite.width,
242 sprite.height,
243 &sprite.alpha,
244 sequoia_core::Rect::from_xywh(x, y, px, px),
245 text,
246 );
247 return;
248 }
249 }
250 }
251 let label = self.label();
252 let size = self
253 .font_size
254 .or(style.font_size)
255 .unwrap_or(side * FONT_RATIO);
256 let w = ctx.text_width_weighted(&label, size, FontWeight::SEMIBOLD);
257 let x = rect.left() + (rect.size.width - w) * 0.5;
258 let y = rect.top() + rect.size.height * 0.5 + size * 0.35;
259 ctx.draw_text_weighted(&label, (x, y), size, FontWeight::SEMIBOLD, text);
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn initials_take_first_and_last() {
269 assert_eq!(initials_for("Ada Lovelace"), "AL");
270 assert_eq!(initials_for("ada lovelace byron"), "AB");
271 assert_eq!(initials_for("root"), "R");
272 assert_eq!(initials_for(" "), "?");
273 assert_eq!(initials_for(""), "?");
274 }
275
276 #[test]
277 fn color_is_stable_and_spread() {
278 assert_eq!(avatar_color_for("ada"), avatar_color_for("ada"));
279 let mut seen = std::collections::HashSet::new();
280 for name in [
281 "ada", "bob", "ced", "dan", "eli", "fay", "gus", "hal", "ida", "jay",
282 ] {
283 seen.insert(avatar_color_for(name).to_rgb8());
284 }
285 assert!(seen.len() > 1, "palette differs: {seen:?}");
286 }
287}