1use 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
34pub 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
44pub const AVATAR_BORDER_DEFAULT: f32 = 2.0;
47
48pub 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;
52pub const AVATAR_PRESENCE_OUTLINE_WIDTH: f32 = 1.5;
54pub const AVATAR_PRESENCE_INSET: f32 = 0.0;
56
57pub const AVATAR_FONT_RATIO_1CHAR: f32 = 0.45;
59pub const AVATAR_FONT_RATIO_2CHAR: f32 = 0.40;
60
61pub const AVATAR_ROUNDED_RADIUS_RATIO: f32 = 0.25;
63
64pub 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
77pub 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
87pub 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
100pub 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
109pub 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
158pub 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#[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#[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
277struct 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 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 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 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 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 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 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 builder.set_hidden();
433 }
434
435 fn children(&self) -> Vec<WidgetId> {
436 self.child_id.into_iter().collect()
437 }
438}