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::density::{dp, spacing};
26use teksilo_core::styles::{
27 AvatarCorner, AvatarPresence, AvatarShape, AvatarSize, AvatarStyle, AvatarStyleConfig,
28 FocusRingStroke,
29};
30use teksilo_core::widget::{
31 LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
32};
33use teksilo_core::widget_id::WidgetId;
34use teksilo_tokens::{Color, CornerRadius, InputTokens, TargetRole};
35
36pub const AVATAR_SIZE_SMALL: f32 = 24.0;
42pub const AVATAR_SIZE_MEDIUM: f32 = 32.0;
43pub const AVATAR_SIZE_LARGE: f32 = 48.0;
44pub const AVATAR_SIZE_X_LARGE: f32 = 64.0;
45
46pub const AVATAR_BORDER_DEFAULT: f32 = 2.0;
49
50pub const AVATAR_PRESENCE_DIAMETER_RATIO: f32 = 0.28;
52pub const AVATAR_PRESENCE_DIAMETER_MIN: f32 = 8.0;
53pub const AVATAR_PRESENCE_DIAMETER_MAX: f32 = 20.0;
54pub const AVATAR_PRESENCE_OUTLINE_WIDTH: f32 = 1.5;
56pub const AVATAR_PRESENCE_INSET: f32 = 0.0;
58
59pub const AVATAR_FONT_RATIO_1CHAR: f32 = 0.45;
61pub const AVATAR_FONT_RATIO_2CHAR: f32 = 0.40;
62
63pub const AVATAR_ROUNDED_RADIUS_RATIO: f32 = 0.25;
65
66pub fn avatar_pixel_size(size: AvatarSize) -> f32 {
70 match size {
71 AvatarSize::Small => AVATAR_SIZE_SMALL,
72 AvatarSize::Medium => AVATAR_SIZE_MEDIUM,
73 AvatarSize::Large => AVATAR_SIZE_LARGE,
74 AvatarSize::XLarge => AVATAR_SIZE_X_LARGE,
75 AvatarSize::Custom(px) => px.max(1.0),
76 }
77}
78
79pub fn fnv1a_64(bytes: &[u8]) -> u64 {
81 let mut h: u64 = 0xcbf29ce484222325;
82 for &b in bytes {
83 h ^= b as u64;
84 h = h.wrapping_mul(0x100000001b3);
85 }
86 h
87}
88
89pub fn hash_pick_palette_color(seed: &str, theme: &teksilo_core::Theme) -> Color {
93 let palette = &theme.colors.chart_palette;
94 if palette.is_empty() {
95 return Color::from_rgb(0.5, 0.5, 0.5);
96 }
97 let h = fnv1a_64(seed.as_bytes());
98 let idx = (h as usize) % palette.len();
99 palette[idx]
100}
101
102pub fn auto_contrast_text(bg: Color) -> Color {
104 if bg.relative_luminance() < 0.5 {
105 Color::WHITE
106 } else {
107 Color::from_rgb(0.121, 0.121, 0.121)
108 }
109}
110
111pub fn paint_focus_ring(
114 canvas: &mut Canvas,
115 bounds: Rect,
116 shape: AvatarShape,
117 rounded_radius_ratio: f32,
118 offset: f32,
119 width: f32,
120 color: Color,
121) {
122 let ring = focus_ring_stroke(bounds, shape, rounded_radius_ratio, offset, width);
123 match shape {
124 AvatarShape::Circle => {
125 let outer = ring.rect;
126 let radius = outer.width.min(outer.height) / 2.0;
127 let center = Point::new(outer.x + outer.width / 2.0, outer.y + outer.height / 2.0);
128 canvas.stroke_circle(
129 center,
130 radius,
131 Paint::from(color),
132 StrokeStyle::solid(width),
133 );
134 }
135 AvatarShape::RoundedSquare | AvatarShape::Square => {
136 canvas.stroke_rounded_rect(
137 ring.rect,
138 CornerRadius::uniform(ring.corner_radius),
139 color,
140 StrokeStyle::solid(width),
141 );
142 }
143 }
144}
145
146fn focus_ring_stroke(
150 bounds: Rect,
151 shape: AvatarShape,
152 rounded_radius_ratio: f32,
153 offset: f32,
154 width: f32,
155) -> FocusRingStroke {
156 let distance = offset + width / 2.0;
157 match shape {
158 AvatarShape::Square => FocusRingStroke {
160 corner_radius: 0.0,
161 ..FocusRingStroke::at_distance(bounds, 0.0, distance)
162 },
163 AvatarShape::RoundedSquare => {
164 let r = bounds.width.min(bounds.height) * rounded_radius_ratio;
165 FocusRingStroke::at_distance(bounds, r, distance)
166 }
167 AvatarShape::Circle => {
168 FocusRingStroke::at_distance(bounds, bounds.width.min(bounds.height) / 2.0, distance)
169 }
170 }
171}
172
173pub fn paint_border(
175 canvas: &mut Canvas,
176 bounds: Rect,
177 shape: AvatarShape,
178 rounded_radius_ratio: f32,
179 width: f32,
180 color: Color,
181) {
182 let half = width / 2.0;
183 let inner = Rect::new(
184 bounds.x + half,
185 bounds.y + half,
186 (bounds.width - width).max(0.0),
187 (bounds.height - width).max(0.0),
188 );
189 match shape {
190 AvatarShape::Circle => {
191 let radius = inner.width.min(inner.height) / 2.0;
192 let center = Point::new(inner.x + inner.width / 2.0, inner.y + inner.height / 2.0);
193 canvas.stroke_circle(
194 center,
195 radius,
196 Paint::from(color),
197 StrokeStyle::solid(width),
198 );
199 }
200 AvatarShape::RoundedSquare => {
201 let r = inner.width.min(inner.height) * rounded_radius_ratio;
202 canvas.stroke_rounded_rect(
203 inner,
204 CornerRadius::uniform(r),
205 color,
206 StrokeStyle::solid(width),
207 );
208 }
209 AvatarShape::Square => {
210 canvas.stroke_rounded_rect(
211 inner,
212 CornerRadius::uniform(0.0),
213 color,
214 StrokeStyle::solid(width),
215 );
216 }
217 }
218}
219
220#[derive(Debug, Clone, Copy, PartialEq)]
226pub struct AvatarRecipe {
227 pub size_small: f32,
228 pub size_medium: f32,
229 pub size_large: f32,
230 pub size_x_large: f32,
231 pub border_default: f32,
232 pub presence_diameter_ratio: f32,
233 pub presence_diameter_min: f32,
234 pub presence_diameter_max: f32,
235 pub presence_outline_width: f32,
236 pub presence_inset: f32,
237 pub font_ratio_1char: f32,
238 pub font_ratio_2char: f32,
239 pub rounded_radius_ratio: f32,
240}
241
242impl AvatarRecipe {
243 pub fn for_tokens(tokens: &InputTokens) -> Self {
249 Self {
250 size_small: dp(AVATAR_SIZE_SMALL, TargetRole::Target, tokens),
251 size_medium: dp(AVATAR_SIZE_MEDIUM, TargetRole::Target, tokens),
252 size_large: dp(AVATAR_SIZE_LARGE, TargetRole::Target, tokens),
253 size_x_large: dp(AVATAR_SIZE_X_LARGE, TargetRole::Target, tokens),
254 border_default: AVATAR_BORDER_DEFAULT,
255 presence_diameter_ratio: AVATAR_PRESENCE_DIAMETER_RATIO,
256 presence_diameter_min: AVATAR_PRESENCE_DIAMETER_MIN,
257 presence_diameter_max: AVATAR_PRESENCE_DIAMETER_MAX,
258 presence_outline_width: AVATAR_PRESENCE_OUTLINE_WIDTH,
259 presence_inset: spacing(AVATAR_PRESENCE_INSET, tokens),
260 font_ratio_1char: AVATAR_FONT_RATIO_1CHAR,
261 font_ratio_2char: AVATAR_FONT_RATIO_2CHAR,
262 rounded_radius_ratio: AVATAR_ROUNDED_RADIUS_RATIO,
263 }
264 }
265}
266
267impl Default for AvatarRecipe {
268 fn default() -> Self {
269 Self::for_tokens(&InputTokens::default())
270 }
271}
272
273#[derive(Debug, Default, Clone, Copy)]
275pub struct RecipeAvatarStyle {
276 pub recipe: AvatarRecipe,
277}
278
279impl RecipeAvatarStyle {
280 pub fn new(recipe: AvatarRecipe) -> Self {
281 Self { recipe }
282 }
283
284 pub fn for_tokens(tokens: &InputTokens) -> Self {
291 Self {
292 recipe: AvatarRecipe::for_tokens(tokens),
293 }
294 }
295}
296
297impl AvatarStyle for RecipeAvatarStyle {
298 fn make_body(&self, cfg: &AvatarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
299 ctx.add(AvatarChromeFrame {
300 child_id: None,
301 pending_child: Some(PendingChild::Id(cfg.content)),
302 shape: cfg.shape,
303 presence: cfg.presence.clone(),
304 presence_corner: cfg.presence_corner,
305 is_focused: cfg.is_focused.clone(),
306 background: cfg.background_override.clone(),
307 border_color: cfg.border_color_override.clone(),
308 border_width: cfg.border_width_override,
309 seed: cfg.seed.clone(),
310 recipe: self.recipe,
311 })
312 }
313}
314
315struct AvatarChromeFrame {
320 child_id: Option<WidgetId>,
321 pending_child: Option<PendingChild>,
322 shape: AvatarShape,
323 presence: Option<AvatarPresence>,
324 presence_corner: AvatarCorner,
325 is_focused: Signal<bool>,
326 background: Option<ColorProp>,
327 border_color: Option<ColorProp>,
328 border_width: Option<f32>,
329 seed: String,
330 recipe: AvatarRecipe,
331}
332
333impl std::fmt::Debug for AvatarChromeFrame {
334 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335 f.debug_struct("AvatarChromeFrame")
336 .field("shape", &self.shape)
337 .finish()
338 }
339}
340
341impl Widget for AvatarChromeFrame {
342 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
343 if let Some(pending) = self.pending_child.take() {
344 self.child_id = Some(match pending {
345 PendingChild::Id(id) => id,
346 PendingChild::Deferred(w) => ctx.add_boxed(w),
347 });
348 }
349 let id = ctx.self_id();
351 let registry = ctx.binding_registry();
352 self.is_focused
353 .bind_to(id, registry, BindingLevel::RepaintOnly);
354 self.child_id.into_iter().collect()
355 }
356
357 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
358 Size::new(
361 proposal.width.unwrap_or(0.0),
362 proposal.height.unwrap_or(0.0),
363 )
364 .into()
365 }
366
367 fn place_children(
368 &self,
369 bounds: Rect,
370 _proposal: SizeProposal,
371 children: &mut [WidgetPlacement],
372 _ctx: &LayoutContext,
373 ) {
374 for child in children.iter_mut() {
375 child.origin = bounds.origin();
376 child.size = bounds.size();
377 }
378 }
379
380 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
381 let theme = ctx.theme;
382
383 let bg = match &self.background {
386 Some(prop) => prop.resolve(theme, ctx.effective_enabled),
387 None => hash_pick_palette_color(&self.seed, theme),
388 };
389 match self.shape {
390 AvatarShape::Circle => {
391 let radius = bounds.width.min(bounds.height) / 2.0;
392 let center = Point::new(
393 bounds.x + bounds.width / 2.0,
394 bounds.y + bounds.height / 2.0,
395 );
396 canvas.fill_circle(center, radius, bg);
397 }
398 AvatarShape::RoundedSquare => {
399 let r = bounds.width.min(bounds.height) * self.recipe.rounded_radius_ratio;
400 canvas.fill_rounded_rect(bounds, CornerRadius::uniform(r), bg);
401 }
402 AvatarShape::Square => {
403 canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), bg);
404 }
405 }
406
407 if let Some(width) = self.border_width
410 && width > 0.0
411 {
412 let color = match &self.border_color {
413 Some(prop) => prop.resolve(theme, ctx.effective_enabled),
414 None => theme.colors.surface_main,
415 };
416 paint_border(
417 canvas,
418 bounds,
419 self.shape,
420 self.recipe.rounded_radius_ratio,
421 width,
422 color,
423 );
424 }
425
426 if self.is_focused.get() {
428 paint_focus_ring(
429 canvas,
430 bounds,
431 self.shape,
432 self.recipe.rounded_radius_ratio,
433 theme.shape.focus_ring_offset,
434 theme.shape.focus_ring_width,
435 theme.colors.focus_ring,
436 );
437 }
438
439 if let Some(presence) = &self.presence {
442 let color = presence.color(theme);
443 let dot_diameter =
444 (bounds.width.min(bounds.height) * self.recipe.presence_diameter_ratio).clamp(
445 self.recipe.presence_diameter_min,
446 self.recipe.presence_diameter_max,
447 );
448 let dot_radius = dot_diameter / 2.0;
449 let (xf, yf) = self.presence_corner.offset();
450 let cx = if xf < 0.0 {
451 bounds.x + dot_radius + self.recipe.presence_inset
452 } else {
453 bounds.x + bounds.width - dot_radius - self.recipe.presence_inset
454 };
455 let cy = if yf < 0.0 {
456 bounds.y + dot_radius + self.recipe.presence_inset
457 } else {
458 bounds.y + bounds.height - dot_radius - self.recipe.presence_inset
459 };
460 let center = Point::new(cx, cy);
461 let outline_radius = dot_radius + self.recipe.presence_outline_width;
462 canvas.fill_circle(center, outline_radius, theme.colors.surface_main);
463 canvas.fill_circle(center, dot_radius, color);
464 }
465 }
466
467 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
468 builder.set_hidden();
471 }
472
473 fn children(&self) -> Vec<WidgetId> {
474 self.child_id.into_iter().collect()
475 }
476}
477
478#[cfg(test)]
479mod focus_ring_tests {
480 use super::*;
481
482 #[test]
483 fn a_negative_focus_ring_offset_shrinks_the_ring_without_inverting() {
484 let bounds = Rect::new(0.0, 0.0, 32.0, 32.0);
485 for shape in [
486 AvatarShape::Circle,
487 AvatarShape::RoundedSquare,
488 AvatarShape::Square,
489 ] {
490 let outer = focus_ring_stroke(bounds, shape, 0.25, 2.0, 2.0);
491 let inner = focus_ring_stroke(bounds, shape, 0.25, -1.0, 2.0);
492 assert!(inner.rect.width < outer.rect.width, "{shape:?}");
493 assert!(inner.rect.height < outer.rect.height, "{shape:?}");
494 assert!(inner.rect.width >= 0.0 && inner.rect.height >= 0.0);
495
496 let deep = focus_ring_stroke(bounds, shape, 0.25, -40.0, 2.0);
497 assert!(deep.rect.width >= 0.0 && deep.rect.height >= 0.0);
498 assert!(deep.corner_radius >= 0.0);
499 }
500 }
501
502 #[test]
503 fn a_square_ring_keeps_sharp_corners() {
504 let bounds = Rect::new(0.0, 0.0, 32.0, 32.0);
505 let ring = focus_ring_stroke(bounds, AvatarShape::Square, 0.25, 2.0, 2.0);
506 assert_eq!(ring.corner_radius, 0.0);
507 }
508}