1use std::rc::Rc;
50
51use teksilo_canvas::raster::RasterIcon;
52use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
53use teksilo_core::accessibility::AccessNodeBuilder;
54use teksilo_core::accessibility::text_runs::{TextRunSource, push_text_runs};
55use teksilo_core::build_context::BuildContext;
56use teksilo_core::color_prop::ColorProp;
57use teksilo_core::signal::{Prop, Signal};
58use teksilo_core::styles::{AvatarStyleConfig, SharedAvatarStyle};
59use teksilo_core::widget::{
60 CursorIcon, EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement,
61};
62use teksilo_core::widget_builder::HandlerSet;
63use teksilo_core::widget_id::WidgetId;
64use teksilo_tokens::{Color, FontWeight, TextStyle};
65
66pub use teksilo_core::styles::{AvatarCorner, AvatarPresence, AvatarShape, AvatarSize};
67
68use crate::primitives::ImageWidget;
69use crate::primitives::image_mask::ImageMaskShape;
70use crate::primitives::image_widget::ImageFit;
71use crate::primitives::image_widget::{CommittedImage, masked};
72use crate::styles::recipe_avatar_style::{
73 AVATAR_FONT_RATIO_1CHAR, AVATAR_FONT_RATIO_2CHAR, AVATAR_ROUNDED_RADIUS_RATIO,
74 auto_contrast_text, avatar_pixel_size, hash_pick_palette_color,
75};
76use teksilo_i18n::LocalizedString;
77
78type ActionFn = Rc<dyn Fn(&mut EventContext)>;
81
82pub struct Avatar {
92 initials: String,
96 label: Option<String>,
99 alt: Option<String>,
101 image_source: Option<RawImage>,
104
105 size: AvatarSize,
106 shape: AvatarShape,
107
108 background: Option<ColorProp>,
109 foreground: Option<ColorProp>,
110 border_color: Option<ColorProp>,
111 border_width: Option<f32>,
112
113 presence: Option<AvatarPresence>,
114 presence_corner: AvatarCorner,
115
116 seed: Option<String>,
117
118 a11y_hidden: bool,
119
120 image_visible: Prop<bool>,
121
122 name_signal: Option<Signal<String>>,
127 image_signal: Option<Signal<Option<Rc<RasterIcon>>>>,
130 alt_signal: Option<Signal<Option<String>>>,
133 label_signal: Option<Signal<Option<String>>>,
135 presence_signal: Option<Signal<Option<AvatarPresence>>>,
138
139 has_popup: Option<teksilo_core::accesskit::HasPopup>,
143 expanded_signal: Option<Prop<bool>>,
147
148 action: Option<ActionFn>,
152
153 focused: Option<Signal<bool>>,
157 style_override: Option<SharedAvatarStyle>,
160 image_cache: Option<(ImageKey, Option<Rc<CommittedImage>>)>,
165 root_child_id: Option<WidgetId>,
167
168 tooltip_text: Option<LocalizedString>,
172 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
174 composite_tooltip_content: Option<Box<dyn Widget>>,
176}
177
178#[derive(Clone)]
179struct RawImage {
180 pixels: Rc<Vec<u8>>,
184 width: u32,
185 height: u32,
186}
187
188#[derive(Clone, Copy, PartialEq)]
191struct ImageKey {
192 image: ImageIdentity,
193 mask: ImageMaskShape,
194}
195
196#[derive(Clone, Copy, PartialEq, Eq)]
197enum ImageIdentity {
198 Raw(usize),
201 Icon(u64),
204}
205
206enum CurrentImage {
209 Raw(RawImage),
210 Icon(Rc<RasterIcon>),
211}
212
213impl CurrentImage {
214 fn identity(&self) -> ImageIdentity {
215 match self {
216 CurrentImage::Raw(raw) => ImageIdentity::Raw(Rc::as_ptr(&raw.pixels) as usize),
217 CurrentImage::Icon(icon) => ImageIdentity::Icon(icon.texture_key()),
218 }
219 }
220
221 fn pixels(&self) -> (Vec<u8>, u32, u32) {
222 match self {
223 CurrentImage::Raw(raw) => ((*raw.pixels).clone(), raw.width, raw.height),
224 CurrentImage::Icon(icon) => (icon.pixels().to_vec(), icon.width(), icon.height()),
225 }
226 }
227}
228
229impl Avatar {
232 pub fn with_initials(initials: impl Into<LocalizedString>) -> Self {
235 let ls: LocalizedString = initials.into();
236 let raw = ls.resolve_now();
237 Self::from_initials(normalize_initials(&raw))
238 }
239
240 pub fn with_name(name: impl Into<LocalizedString>) -> Self {
246 let ls: LocalizedString = name.into();
247 let raw = ls.resolve_now();
248 let initials = derive_initials(&raw);
249 let mut a = Self::from_initials(initials);
250 a.seed = Some(raw); a
252 }
253
254 pub fn with_image(icon: &RasterIcon) -> Self {
259 Self::from_raw_image(icon.pixels().to_vec(), icon.width(), icon.height())
260 }
261
262 pub fn from_raw_image(pixels: Vec<u8>, width: u32, height: u32) -> Self {
265 let mut a = Self::from_initials("?".to_string());
266 a.image_source = Some(RawImage {
267 pixels: Rc::new(pixels),
268 width,
269 height,
270 });
271 a
272 }
273
274 fn from_initials(initials: String) -> Self {
275 Self {
276 initials,
277 label: None,
278 alt: None,
279 image_source: None,
280 size: AvatarSize::Medium,
281 shape: AvatarShape::Circle,
282 background: None,
283 foreground: None,
284 border_color: None,
285 border_width: None,
286 presence: None,
287 presence_corner: AvatarCorner::BottomTrailing,
288 seed: None,
289 a11y_hidden: false,
290 image_visible: Prop::Static(true),
291 name_signal: None,
292 image_signal: None,
293 alt_signal: None,
294 label_signal: None,
295 presence_signal: None,
296 has_popup: None,
297 expanded_signal: None,
298 action: None,
299 focused: None,
300 style_override: None,
301 image_cache: None,
302 root_child_id: None,
303 tooltip_text: None,
304 rich_tooltip_source: None,
305 composite_tooltip_content: None,
306 }
307 }
308
309 pub fn style(mut self, style: impl teksilo_core::styles::AvatarStyle) -> Self {
311 self.style_override = Some(Rc::new(style));
312 self
313 }
314}
315
316impl Avatar {
319 pub fn size(mut self, size: AvatarSize) -> Self {
321 self.size = size;
322 self
323 }
324
325 pub fn shape(mut self, shape: AvatarShape) -> Self {
327 self.shape = shape;
331 self
332 }
333
334 pub fn fallback_initials(mut self, initials: impl Into<LocalizedString>) -> Self {
339 let ls: LocalizedString = initials.into();
340 let raw = ls.resolve_now();
341 self.initials = normalize_initials(&raw);
342 self
343 }
344
345 pub fn image_visible(mut self, visible: impl Into<Prop<bool>>) -> Self {
350 self.image_visible = visible.into();
351 self
352 }
353
354 pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
357 self.background = Some(color.into());
358 self
359 }
360
361 pub fn foreground(mut self, color: impl Into<ColorProp>) -> Self {
366 self.foreground = Some(color.into());
367 self
368 }
369
370 pub fn seed(mut self, seed: impl Into<String>) -> Self {
374 self.seed = Some(seed.into());
375 self
376 }
377
378 pub fn border(mut self, width: f32) -> Self {
382 self.border_width = Some(width.max(0.0));
383 self
384 }
385
386 pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self {
390 self.border_color = Some(color.into());
391 self
392 }
393
394 pub fn presence(mut self, presence: AvatarPresence) -> Self {
397 self.presence = Some(presence);
398 self
399 }
400
401 pub fn presence_corner(mut self, corner: AvatarCorner) -> Self {
404 self.presence_corner = corner;
405 self
406 }
407
408 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
412 let ls: LocalizedString = label.into();
413 self.label = Some(ls.resolve_now());
414 self
415 }
416
417 pub fn alt(mut self, alt: impl Into<LocalizedString>) -> Self {
421 let ls: LocalizedString = alt.into();
422 self.alt = Some(ls.resolve_now());
423 self
424 }
425
426 pub fn a11y_hidden(mut self) -> Self {
429 self.a11y_hidden = true;
430 self
431 }
432
433 pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
438 self.action = Some(Rc::new(f));
439 self
440 }
441
442 pub fn has_popup(mut self, kind: teksilo_core::accesskit::HasPopup) -> Self {
449 self.has_popup = Some(kind);
450 self
451 }
452
453 pub fn expanded_when(mut self, signal: impl Into<Prop<bool>>) -> Self {
459 self.expanded_signal = Some(signal.into());
460 self
461 }
462
463 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
470 self.tooltip_text = Some(text.into());
471 self.rich_tooltip_source = None;
472 self.composite_tooltip_content = None;
473 self
474 }
475
476 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
481 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
482 self.tooltip_text = None;
483 self.composite_tooltip_content = None;
484 self
485 }
486
487 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
491 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
492 self.tooltip_text = None;
493 self.composite_tooltip_content = None;
494 self
495 }
496
497 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
502 self.composite_tooltip_content = Some(Box::new(content));
503 self.tooltip_text = None;
504 self.rich_tooltip_source = None;
505 self
506 }
507
508 pub fn name_signal(mut self, signal: Signal<String>) -> Self {
524 self.name_signal = Some(signal);
525 self
526 }
527
528 pub fn image_signal(mut self, signal: Signal<Option<Rc<RasterIcon>>>) -> Self {
532 self.image_signal = Some(signal);
533 self
534 }
535
536 pub fn alt_signal(mut self, signal: Signal<Option<String>>) -> Self {
539 self.alt_signal = Some(signal);
540 self
541 }
542
543 pub fn label_signal(mut self, signal: Signal<Option<String>>) -> Self {
546 self.label_signal = Some(signal);
547 self
548 }
549
550 pub fn presence_signal(mut self, signal: Signal<Option<AvatarPresence>>) -> Self {
555 self.presence_signal = Some(signal);
556 self
557 }
558}
559
560impl std::fmt::Debug for Avatar {
561 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
562 f.debug_struct("Avatar")
563 .field("initials", &self.initials)
564 .field("size", &self.size)
565 .field("shape", &self.shape)
566 .field(
567 "has_image",
568 &(self.image_source.is_some() || self.image_signal.is_some()),
569 )
570 .field("clickable", &self.action.is_some())
571 .finish()
572 }
573}
574
575fn normalize_initials(s: &str) -> String {
584 let mut out = String::new();
585 let mut count = 0;
586 for c in s.trim().chars() {
587 if count >= 2 {
588 break;
589 }
590 for upper in c.to_uppercase() {
591 out.push(upper);
592 }
593 count += 1;
594 }
595 if out.is_empty() { "?".to_string() } else { out }
596}
597
598fn derive_initials(name: &str) -> String {
600 let trimmed = name.trim();
601 if trimmed.is_empty() {
602 return "?".to_string();
603 }
604 let source = trimmed.split('@').next().unwrap_or(trimmed);
606 let parts: Vec<&str> = source
607 .split(|c: char| c.is_whitespace() || c == '.' || c == '_' || c == '-')
608 .filter(|s| !s.is_empty())
609 .collect();
610
611 let mut out = String::new();
612 for part in parts.iter().take(2) {
613 if let Some(c) = part.chars().next() {
614 for upper in c.to_uppercase() {
615 out.push(upper);
616 }
617 }
618 }
619 if out.is_empty() { "?".to_string() } else { out }
620}
621
622fn shape_to_image_mask(shape: AvatarShape) -> ImageMaskShape {
623 match shape {
624 AvatarShape::Circle => ImageMaskShape::Circle,
625 AvatarShape::RoundedSquare => ImageMaskShape::RoundedSquare(AVATAR_ROUNDED_RADIUS_RATIO),
626 AvatarShape::Square => ImageMaskShape::None,
627 }
628}
629
630impl Avatar {
633 fn current_initials(&self) -> String {
637 match &self.name_signal {
638 Some(sig) => derive_initials(&sig.get()),
639 None => self.initials.clone(),
640 }
641 }
642
643 fn current_seed(&self) -> String {
648 match &self.name_signal {
649 Some(sig) => sig.get(),
650 None => self.seed.clone().unwrap_or_else(|| self.initials.clone()),
651 }
652 }
653
654 fn current_alt(&self) -> Option<String> {
655 match &self.alt_signal {
656 Some(sig) => sig.get(),
657 None => self.alt.clone(),
658 }
659 }
660
661 fn current_label(&self) -> Option<String> {
662 match &self.label_signal {
663 Some(sig) => sig.get(),
664 None => self.label.clone(),
665 }
666 }
667
668 fn current_presence(&self) -> Option<AvatarPresence> {
669 match &self.presence_signal {
670 Some(sig) => sig.get(),
671 None => self.presence.clone(),
672 }
673 }
674
675 fn current_image(&self) -> Option<CurrentImage> {
678 if let Some(sig) = &self.image_signal {
679 return sig.get().map(CurrentImage::Icon);
680 }
681 self.image_source.clone().map(CurrentImage::Raw)
682 }
683
684 fn committed(
688 &mut self,
689 image: &CurrentImage,
690 mask: ImageMaskShape,
691 ) -> Option<Rc<CommittedImage>> {
692 let key = ImageKey {
693 image: image.identity(),
694 mask,
695 };
696 if let Some((cached, committed)) = &self.image_cache
697 && *cached == key
698 {
699 return committed.clone();
700 }
701 let (pixels, width, height) = image.pixels();
702 let (pixels, width, height) = masked(pixels, width, height, mask);
703 let committed = match CommittedImage::commit(pixels, width, height) {
704 Ok(committed) => Some(committed),
705 Err(error) => {
706 eprintln!(
707 "teksilo: an avatar image of {width}x{height} was refused ({error}): \
708 its initials show instead"
709 );
710 None
711 }
712 };
713 self.image_cache = Some((key, committed.clone()));
714 committed
715 }
716
717 fn has_image_now(&self) -> bool {
721 let has_image = self
722 .image_signal
723 .as_ref()
724 .is_some_and(|sig| sig.get().is_some())
725 || self.image_source.is_some();
726 has_image && !matches!(&self.image_cache, Some((_, None)))
727 }
728}
729
730impl Widget for Avatar {
733 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
734 let self_id = ctx.self_id();
735 let mask_shape = shape_to_image_mask(self.shape);
736
737 let initials = self.current_initials();
740 let seed = self.current_seed();
741 let alt = self.current_alt();
742 let image = match self.current_image() {
743 Some(image) => self.committed(&image, mask_shape),
744 None => None,
745 };
746
747 let make_initials_leaf = || InitialsLeaf {
751 initials: initials.clone(),
752 seed: seed.clone(),
753 background: self.background.clone(),
754 foreground: self.foreground.clone(),
755 };
756 let make_image_widget = |image: Rc<CommittedImage>, alt: Option<String>| {
757 let mut img = ImageWidget::from_committed(image).fit(ImageFit::Cover);
758 if let Some(a) = alt {
759 img = img.alt(a);
760 } else {
761 img = img.a11y_hidden();
764 }
765 img
766 };
767
768 let content_id = match (image, &self.image_visible) {
773 (Some(image), Prop::Static(true)) => ctx.add(make_image_widget(image, alt.clone())),
774 (Some(_), Prop::Static(false)) => ctx.add(make_initials_leaf()),
775 (Some(image), Prop::Bound(visible_signal)) => {
776 let img_id = ctx.add(make_image_widget(image, alt.clone()));
777 let init_id = ctx.add(make_initials_leaf());
778 let v_clone = visible_signal.clone();
779 ctx.visible_when(img_id, v_clone.clone());
780 ctx.visible_when(init_id, v_clone.map(|v| !*v));
781 ctx.add(
782 crate::primitives::ZStack::new()
783 .child(img_id)
784 .child(init_id),
785 )
786 }
787 (None, _) => ctx.add(make_initials_leaf()),
788 };
789
790 let registry = ctx.binding_registry();
792 if let Some(sig) = &self.name_signal {
793 sig.bind_to(
794 self_id,
795 registry,
796 teksilo_core::binding::BindingLevel::Rebuild,
797 );
798 }
799 if let Some(sig) = &self.image_signal {
800 sig.bind_to(
801 self_id,
802 registry,
803 teksilo_core::binding::BindingLevel::Rebuild,
804 );
805 }
806 if let Some(sig) = &self.presence_signal {
807 sig.bind_to(
808 self_id,
809 registry,
810 teksilo_core::binding::BindingLevel::Rebuild,
811 );
812 }
813 if let Some(sig) = &self.alt_signal {
814 sig.bind_to(
815 self_id,
816 registry,
817 teksilo_core::binding::BindingLevel::AccessibilityOnly,
818 );
819 }
820 if let Some(sig) = &self.label_signal {
821 sig.bind_to(
822 self_id,
823 registry,
824 teksilo_core::binding::BindingLevel::AccessibilityOnly,
825 );
826 }
827
828 let focused = ctx.signal(false);
832 self.focused = Some(focused.clone());
833 if let Some(action) = self.action.clone() {
834 let focus_for_handler = focused.clone();
835
836 let action_for_tap = action.clone();
837 let action_for_key = action.clone();
838 let action_for_access = action;
839 let handlers = HandlerSet::new()
840 .on_tap(move |_pos, ctx| action_for_tap(ctx))
841 .focusable(true)
842 .cursor(CursorIcon::Pointer)
843 .on_focus(move |gained, _ctx| focus_for_handler.set(gained))
844 .on_key(move |event, ctx| {
845 use teksilo_core::event::{EventResponse, Key, WidgetEvent};
846 match event {
847 WidgetEvent::KeyDown {
848 key: Key::Enter | Key::Space,
849 ..
850 } => {
851 action_for_key(ctx);
852 EventResponse::Handled
853 }
854 _ => EventResponse::Ignored,
855 }
856 })
857 .on_access_action(move |action_kind, ctx| {
858 use teksilo_core::event::EventResponse;
859 if action_kind == teksilo_core::accesskit::Action::Click {
860 action_for_access(ctx);
861 EventResponse::Handled
862 } else {
863 EventResponse::Ignored
864 }
865 });
866 ctx.apply_self_handlers(handlers);
867 }
868
869 if let Some(ref expanded_signal) = self.expanded_signal {
871 let self_id = ctx.self_id();
872 let registry = ctx.binding_registry();
873 expanded_signal.register_if_bound(
874 self_id,
875 registry,
876 teksilo_core::binding::BindingLevel::RepaintOnly,
877 );
878 }
879
880 let style: SharedAvatarStyle = self
885 .style_override
886 .clone()
887 .or_else(|| ctx.theme().style_slots.avatar.clone())
888 .unwrap_or_else(|| {
889 Rc::new(crate::styles::RecipeAvatarStyle::for_tokens(
890 &ctx.theme().input,
891 ))
892 });
893 let root = style.make_body(
894 &AvatarStyleConfig {
895 shape: self.shape,
896 size: self.size,
897 content: content_id,
898 presence: self.current_presence(),
899 presence_corner: self.presence_corner,
900 is_focused: focused.and(&ctx.focus_visible()),
903 background_override: self.background.clone(),
904 border_color_override: self.border_color.clone(),
905 border_width_override: self.border_width,
906 seed,
907 },
908 ctx,
909 );
910 self.root_child_id = Some(root);
911
912 if let Some(content) = self.composite_tooltip_content.take() {
914 let delay = ctx.theme().motion.tooltip_delay_heavy;
915 crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
916 } else if let Some(source) = self.rich_tooltip_source.clone() {
917 let delay = ctx.theme().motion.tooltip_delay;
918 crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
919 } else if let Some(text) = self.tooltip_text.clone() {
920 let delay = ctx.theme().motion.tooltip_delay;
921 crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
922 }
923
924 vec![root]
925 }
926
927 fn layout_response(
928 &self,
929 _proposal: SizeProposal,
930 _ctx: &LayoutContext,
931 ) -> teksilo_core::widget::LayoutResponse {
932 let side = avatar_pixel_size(self.size);
933 Size::new(side, side).into()
934 }
935
936 fn place_children(
937 &self,
938 bounds: Rect,
939 _proposal: SizeProposal,
940 children: &mut [WidgetPlacement],
941 _ctx: &LayoutContext,
942 ) {
943 for child in children.iter_mut() {
944 child.origin = bounds.origin();
945 child.size = bounds.size();
946 }
947 }
948
949 fn hit_outset(
960 &self,
961 kind: teksilo_tokens::PointerKind,
962 tokens: &teksilo_tokens::InputTokens,
963 ) -> teksilo_canvas::EdgeInsets {
964 if self.action.is_none() {
965 return teksilo_canvas::EdgeInsets::ZERO;
966 }
967 let side = avatar_pixel_size(self.size);
968 crate::button::target_outset(Size::new(side, side), kind, tokens)
969 }
970
971 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
972 if self.a11y_hidden {
973 builder.set_hidden();
974 return;
975 }
976
977 let clickable = self.action.is_some();
978 let has_image = self.has_image_now();
984 let alt = self.current_alt();
985 let label = self.current_label();
986 let initials = self.current_initials();
987
988 let name = if clickable {
989 builder.set_role(teksilo_core::accesskit::Role::Button);
990 debug_assert!(
994 label.is_some() || alt.is_some(),
995 "Avatar::on_activate_fn requires a `.label(\"...\")` (preferred) or `.alt(\"...\")` (or a `.label(...)` / `.alt_signal(...)`) for screen readers"
996 );
997 builder.add_action(teksilo_core::accesskit::Action::Click);
998 builder.add_action(teksilo_core::accesskit::Action::Focus);
999 label.or(alt).unwrap_or_else(|| initials.clone())
1000 } else if has_image {
1001 builder.set_role(teksilo_core::accesskit::Role::Image);
1002 debug_assert!(
1005 alt.is_some() || label.is_some(),
1006 "Avatar::with_image requires a `.alt(\"...\")` (or `.alt_signal(...)`) for meaningful images, or call `.a11y_hidden()` if decorative"
1007 );
1008 alt.or(label).unwrap_or_else(|| initials.clone())
1009 } else {
1010 builder.set_role(teksilo_core::accesskit::Role::Label);
1011 label.unwrap_or_else(|| initials.clone())
1012 };
1013
1014 let side = avatar_pixel_size(self.size);
1024 let source =
1025 TextRunSource::flat(&name, 0).with_fallback_rect(Rect::new(0.0, 0.0, side, side));
1026 let emission = push_text_runs(builder, None, &source);
1027 builder.set_name(&emission.value);
1032
1033 if let Some(presence) = self.current_presence() {
1034 builder.set_description(presence.label());
1035 }
1036
1037 if let Some(kind) = self.has_popup {
1043 builder.set_has_popup(kind);
1044 }
1045 if let Some(ref signal) = self.expanded_signal {
1046 builder.set_expanded(signal.get());
1047 }
1048 }
1049
1050 fn children(&self) -> Vec<WidgetId> {
1051 self.root_child_id.into_iter().collect()
1052 }
1053}
1054
1055#[derive(Debug)]
1071struct InitialsLeaf {
1072 initials: String,
1073 seed: String,
1074 background: Option<ColorProp>,
1075 foreground: Option<ColorProp>,
1076}
1077
1078impl InitialsLeaf {
1079 fn resolve_bg(&self, theme: &teksilo_core::Theme, enabled: bool) -> Color {
1083 match &self.background {
1084 Some(prop) => prop.resolve(theme, enabled),
1085 None => hash_pick_palette_color(&self.seed, theme),
1086 }
1087 }
1088}
1089
1090impl Widget for InitialsLeaf {
1091 fn layout_response(
1092 &self,
1093 proposal: SizeProposal,
1094 _ctx: &LayoutContext,
1095 ) -> teksilo_core::widget::LayoutResponse {
1096 proposal.resolve(0.0, 0.0).into()
1098 }
1099
1100 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1101 let theme = ctx.theme;
1102
1103 let font_size = bounds.width.min(bounds.height)
1104 * if self.initials.chars().count() <= 1 {
1105 AVATAR_FONT_RATIO_1CHAR
1106 } else {
1107 AVATAR_FONT_RATIO_2CHAR
1108 };
1109
1110 let text_style = TextStyle {
1111 family: theme.typography.body_bold.family.clone(),
1112 size: font_size,
1113 weight: FontWeight::SEMI_BOLD,
1114 line_height: 1.0,
1115 letter_spacing: 0.0,
1116 };
1117
1118 let fg = match &self.foreground {
1121 Some(prop) => prop.resolve(theme, ctx.effective_enabled),
1122 None => auto_contrast_text(self.resolve_bg(theme, ctx.effective_enabled)),
1123 };
1124
1125 let Some(backend) = canvas.text_backend().cloned() else {
1128 return;
1129 };
1130 let layout = {
1131 let mut b = backend.borrow_mut();
1132 b.layout_single_line(&self.initials, &text_style, None)
1133 };
1134 let text_w = layout.width;
1135 let text_h = layout.height;
1136
1137 let cx = bounds.x + (bounds.width - text_w) / 2.0;
1138 let cy = bounds.y + (bounds.height - text_h) / 2.0;
1139 let position = Rect::new(cx, cy, text_w, text_h);
1140 canvas.draw_text(&self.initials, position, &text_style, fg);
1141 }
1142
1143 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1144 builder.set_hidden();
1148 }
1149}
1150
1151#[cfg(test)]
1154mod tests {
1155 use super::*;
1156 use crate::styles::recipe_avatar_style::fnv1a_64;
1157 use teksilo_core::widget::LayoutContext;
1158 use teksilo_core::widget_tree::WidgetTree;
1159 use teksilo_i18n::lit;
1160
1161 fn rgba_solid(side: u32, rgba: [u8; 4]) -> RasterIcon {
1164 let mut p = Vec::with_capacity((side * side * 4) as usize);
1165 for _ in 0..(side * side) {
1166 p.extend_from_slice(&rgba);
1167 }
1168 RasterIcon::from_raw(p, side, side)
1169 }
1170
1171 #[test]
1174 fn normalize_uppercase_truncate() {
1175 assert_eq!(normalize_initials("jdq"), "JD");
1176 assert_eq!(normalize_initials("jd"), "JD");
1177 assert_eq!(normalize_initials("j"), "J");
1178 assert_eq!(normalize_initials(" "), "?");
1179 assert_eq!(normalize_initials(""), "?");
1180 }
1181
1182 #[test]
1183 fn derive_full_name() {
1184 assert_eq!(derive_initials("Jane Doe"), "JD");
1185 }
1186
1187 #[test]
1188 fn derive_single_word() {
1189 assert_eq!(derive_initials("Cher"), "C");
1190 }
1191
1192 #[test]
1193 fn derive_email() {
1194 assert_eq!(derive_initials("jane.doe@x.com"), "JD");
1195 assert_eq!(derive_initials("jane_doe@x.com"), "JD");
1196 }
1197
1198 #[test]
1199 fn derive_unicode_name() {
1200 assert_eq!(derive_initials("María José"), "MJ");
1201 }
1202
1203 #[test]
1204 fn derive_empty_yields_question_mark() {
1205 assert_eq!(derive_initials(""), "?");
1206 assert_eq!(derive_initials(" "), "?");
1207 }
1208
1209 #[test]
1210 fn derive_three_words_takes_first_two() {
1211 assert_eq!(derive_initials("Anna María José"), "AM");
1212 }
1213
1214 #[test]
1215 fn derive_hyphenated_name() {
1216 assert_eq!(derive_initials("Jean-Luc Picard"), "JL");
1217 }
1218
1219 #[test]
1222 fn fnv1a_is_stable() {
1223 let h1 = fnv1a_64(b"jane.doe");
1224 let h2 = fnv1a_64(b"jane.doe");
1225 assert_eq!(h1, h2);
1226 assert_ne!(fnv1a_64(b"jane.doe"), fnv1a_64(b"john.smith"));
1227 }
1228
1229 #[test]
1230 fn hash_distributes_over_palette() {
1231 let theme = teksilo_core::presets::intui::light();
1232 let mut buckets = [0_u32; 8];
1233 for i in 0..200 {
1234 let seed = format!("user_{i}");
1235 let color = hash_pick_palette_color(&seed, &theme);
1236 let idx = theme
1238 .colors
1239 .chart_palette
1240 .iter()
1241 .position(|c| c == &color)
1242 .expect("color must be a palette member");
1243 buckets[idx] += 1;
1244 }
1245 let nonzero = buckets.iter().filter(|n| **n > 0).count();
1246 assert!(
1247 nonzero >= 6,
1248 "expected hash to cover at least 6 of 8 buckets, got {nonzero} (buckets: {:?})",
1249 buckets
1250 );
1251 }
1252
1253 #[test]
1256 fn size_default_is_medium_32px() {
1257 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1258 let id = tree.add(Avatar::with_initials(lit!("JD")));
1259 tree.layout(SizeProposal {
1260 width: None,
1261 height: None,
1262 });
1263 let b = tree.bounds(id);
1264 assert!((b.width - 32.0).abs() < 0.01);
1265 assert!((b.height - 32.0).abs() < 0.01);
1266 }
1267
1268 #[test]
1269 fn size_custom_passes_through() {
1270 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1271 let id = tree.add(Avatar::with_initials(lit!("JD")).size(AvatarSize::Custom(40.0)));
1272 tree.layout(SizeProposal {
1273 width: None,
1274 height: None,
1275 });
1276 let b = tree.bounds(id);
1277 assert!((b.width - 40.0).abs() < 0.01);
1278 assert!((b.height - 40.0).abs() < 0.01);
1279 }
1280
1281 #[test]
1282 fn size_that_fits_ignores_proposal() {
1283 let widget = Avatar::with_initials(lit!("JD"));
1289 let theme = teksilo_core::presets::intui::light();
1290 let ctx = LayoutContext::for_testing(&theme);
1291 let s = widget
1292 .layout_response(SizeProposal::exact(400.0, 400.0), &ctx)
1293 .size;
1294 assert!((s.width - 32.0).abs() < 0.01);
1295 assert!((s.height - 32.0).abs() < 0.01);
1296 }
1297
1298 #[test]
1299 fn small_medium_large_xlarge_sizes() {
1300 let theme = teksilo_core::presets::intui::light();
1301 use crate::styles::recipe_avatar_style as av;
1302 let cases = [
1303 (AvatarSize::Small, av::AVATAR_SIZE_SMALL),
1304 (AvatarSize::Medium, av::AVATAR_SIZE_MEDIUM),
1305 (AvatarSize::Large, av::AVATAR_SIZE_LARGE),
1306 (AvatarSize::XLarge, av::AVATAR_SIZE_X_LARGE),
1307 ];
1308 for (variant, expected) in cases {
1309 let mut tree = WidgetTree::new().with_theme(theme.clone());
1310 let id = tree.add(Avatar::with_initials(lit!("X")).size(variant));
1311 tree.layout(SizeProposal {
1312 width: None,
1313 height: None,
1314 });
1315 let b = tree.bounds(id);
1316 assert!(
1317 (b.width - expected).abs() < 0.01,
1318 "size {variant:?}: expected {expected}, got {}",
1319 b.width
1320 );
1321 }
1322 }
1323
1324 fn render_avatar(avatar: Avatar) -> std::rc::Rc<teksilo_canvas::RenderFrame> {
1327 use std::cell::RefCell;
1328 use std::rc::Rc;
1329 use teksilo_canvas::MockTextBackend;
1330 let mut tree = WidgetTree::new()
1331 .with_theme(teksilo_core::presets::intui::light())
1332 .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
1333 tree.add(avatar);
1334 tree.layout(SizeProposal::exact(64.0, 64.0));
1335 tree.render()
1336 }
1337
1338 fn count_shapes(frame: &teksilo_canvas::RenderFrame) -> usize {
1339 frame.shapes.len()
1343 }
1344
1345 #[test]
1346 fn paint_initials_emits_a_shape_quad() {
1347 let frame = render_avatar(Avatar::with_initials(lit!("JD")));
1348 assert!(
1349 count_shapes(&frame) >= 1,
1350 "expected at least one ShapeQuad (the bg circle)"
1351 );
1352 }
1353
1354 #[test]
1355 fn paint_with_border_adds_extra_shape() {
1356 let plain = render_avatar(Avatar::with_initials(lit!("JD")));
1357 let bordered = render_avatar(Avatar::with_initials(lit!("JD")).border(2.0));
1358 assert!(
1359 count_shapes(&bordered) > count_shapes(&plain),
1360 "border path should add at least one extra Shape (the stroked ring)"
1361 );
1362 }
1363
1364 #[test]
1365 fn paint_presence_adds_two_shapes() {
1366 let plain = render_avatar(Avatar::with_initials(lit!("JD")));
1367 let with_dot =
1368 render_avatar(Avatar::with_initials(lit!("JD")).presence(AvatarPresence::Online));
1369 assert_eq!(count_shapes(&with_dot), count_shapes(&plain) + 2);
1371 }
1372
1373 #[test]
1374 fn paint_rounded_square_emits_shape() {
1375 let frame =
1378 render_avatar(Avatar::with_initials(lit!("JD")).shape(AvatarShape::RoundedSquare));
1379 assert!(count_shapes(&frame) >= 1);
1380 }
1381
1382 #[test]
1383 fn paint_square_emits_shape() {
1384 let frame = render_avatar(Avatar::with_initials(lit!("JD")).shape(AvatarShape::Square));
1385 assert!(count_shapes(&frame) >= 1);
1386 }
1387
1388 #[test]
1389 fn paint_image_uses_image_pipeline() {
1390 let icon = rgba_solid(8, [50, 100, 200, 255]);
1391 let frame = render_avatar(Avatar::with_image(&icon).alt(lit!("avatar")));
1392 assert!(
1393 !frame.live_images.is_empty(),
1394 "image avatar should render an image"
1395 );
1396 }
1397
1398 #[test]
1399 fn auto_contrast_dark_bg_chooses_white() {
1400 let dark = Color::from_rgb(0.05, 0.05, 0.05);
1401 let fg = auto_contrast_text(dark);
1402 assert!(fg.r() > 0.9 && fg.g() > 0.9 && fg.b() > 0.9);
1403 }
1404
1405 #[test]
1406 fn auto_contrast_light_bg_chooses_dark() {
1407 let light = Color::from_rgb(0.95, 0.95, 0.95);
1408 let fg = auto_contrast_text(light);
1409 assert!(fg.r() < 0.3 && fg.g() < 0.3 && fg.b() < 0.3);
1410 }
1411
1412 #[test]
1415 fn accessibility_initials_default_role_is_label() {
1416 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1417 let id = tree.add(Avatar::with_initials(lit!("JD")));
1418 tree.layout(SizeProposal::exact(32.0, 32.0));
1419 let info = tree.accessibility_node(id);
1420 assert_eq!(info.role(), teksilo_core::accesskit::Role::Label);
1421 assert_eq!(info.name(), Some("JD"));
1422 }
1423
1424 #[test]
1425 fn accessibility_image_default_role_is_image() {
1426 let icon = rgba_solid(8, [10, 20, 30, 255]);
1427 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1428 let id = tree.add(Avatar::with_image(&icon).alt(lit!("Jane Doe")));
1429 tree.layout(SizeProposal::exact(32.0, 32.0));
1430 let info = tree.accessibility_node(id);
1431 assert_eq!(info.role(), teksilo_core::accesskit::Role::Image);
1432 assert_eq!(info.name(), Some("Jane Doe"));
1433 }
1434
1435 #[test]
1436 fn accessibility_clickable_becomes_button() {
1437 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1438 let id = tree.add(
1439 Avatar::with_initials(lit!("JD"))
1440 .label(lit!("Open user menu"))
1441 .on_activate_fn(|_ctx| {}),
1442 );
1443 tree.layout(SizeProposal::exact(32.0, 32.0));
1444 let info = tree.accessibility_node(id);
1445 assert_eq!(info.role(), teksilo_core::accesskit::Role::Button);
1446 assert!(
1447 info.actions()
1448 .contains(&teksilo_core::accesskit::Action::Click)
1449 );
1450 assert!(
1451 info.actions()
1452 .contains(&teksilo_core::accesskit::Action::Focus)
1453 );
1454 assert_eq!(info.name(), Some("Open user menu"));
1455 }
1456
1457 #[test]
1458 fn accessibility_a11y_hidden_does_not_set_role() {
1459 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1460 let id = tree.add(Avatar::with_initials(lit!("JD")).a11y_hidden());
1461 tree.layout(SizeProposal::exact(32.0, 32.0));
1462 let info = tree.accessibility_node(id);
1463 assert_eq!(info.name(), None);
1466 }
1467
1468 #[test]
1469 fn accessibility_label_overrides_initials() {
1470 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1471 let id = tree.add(Avatar::with_initials(lit!("JD")).label(lit!("Jane Doe (offline)")));
1472 tree.layout(SizeProposal::exact(32.0, 32.0));
1473 let info = tree.accessibility_node(id);
1474 assert_eq!(info.name(), Some("Jane Doe (offline)"));
1475 }
1476
1477 #[test]
1478 fn accessibility_presence_appears_in_description() {
1479 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1480 let id = tree.add(Avatar::with_initials(lit!("JD")).presence(AvatarPresence::Online));
1481 tree.layout(SizeProposal::exact(32.0, 32.0));
1482 assert_eq!(
1486 tree.accessibility_node(id).role(),
1487 teksilo_core::accesskit::Role::Label
1488 );
1489 }
1490
1491 #[test]
1492 fn an_initials_avatar_is_reviewable_by_character() {
1493 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1498 let id = tree.add(Avatar::with_initials(lit!("JD")));
1499 tree.layout(SizeProposal::exact(32.0, 32.0));
1500 let update = tree.sync_accessibility();
1501 let node_id = teksilo_core::accessibility::widget_id_to_node_id(id);
1502
1503 let consumer = accesskit_consumer::Tree::new(update, false);
1504 let state = consumer.state();
1505 let mut stack = vec![state.root()];
1506 let node = loop {
1507 let node = stack
1508 .pop()
1509 .expect("the avatar is absent from the emitted tree");
1510 if node.locate().0 == node_id {
1511 break node;
1512 }
1513 stack.extend(node.children());
1514 };
1515 assert!(node.supports_text_ranges());
1516 assert_eq!(node.document_range().text(), "JD");
1517 assert!(
1518 !node.document_range().bounding_boxes().is_empty(),
1519 "the avatar reports no geometry, so a magnifier cannot follow it"
1520 );
1521 }
1522
1523 #[test]
1524 fn a_clickable_avatar_keeps_its_button_role_and_drops_the_runs() {
1525 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1529 let id = tree.add(
1530 Avatar::with_initials(lit!("JD"))
1531 .label(lit!("Open user menu"))
1532 .on_activate_fn(|_| {}),
1533 );
1534 tree.layout(SizeProposal::exact(32.0, 32.0));
1535 let update = tree.sync_accessibility();
1536 let node_id = teksilo_core::accessibility::widget_id_to_node_id(id);
1537
1538 let consumer = accesskit_consumer::Tree::new(update, false);
1539 let state = consumer.state();
1540 let mut stack = vec![state.root()];
1541 let node = loop {
1542 let node = stack
1543 .pop()
1544 .expect("the avatar is absent from the emitted tree");
1545 if node.locate().0 == node_id {
1546 break node;
1547 }
1548 stack.extend(node.children());
1549 };
1550 assert_eq!(node.role(), teksilo_core::accesskit::Role::Button);
1551 assert!(!node.supports_text_ranges());
1552 assert!(
1553 node.children()
1554 .all(|child| child.role() != teksilo_core::accesskit::Role::TextRun),
1555 "the inert runs must be gone"
1556 );
1557 }
1558
1559 #[test]
1562 fn image_visible_false_hides_image_child() {
1563 use teksilo_core::signal::Signal;
1564
1565 let icon = rgba_solid(8, [10, 20, 30, 255]);
1566 let visible = Signal::new(true);
1567 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1568 let id = tree.add(
1569 Avatar::with_image(&icon)
1570 .alt(lit!("Jane"))
1571 .fallback_initials(lit!("JD"))
1572 .image_visible(visible.clone()),
1573 );
1574 tree.layout(SizeProposal::exact(32.0, 32.0));
1575 assert!(!tree.render().live_images.is_empty());
1577
1578 visible.set(false);
1581 tree.layout(SizeProposal::exact(32.0, 32.0));
1582 let frame_after = tree.render();
1583 assert!(
1584 frame_after.live_images.is_empty(),
1585 "image should be hidden when image_visible == false"
1586 );
1587 assert!(tree.is_visible(id));
1589 }
1590
1591 fn glyph_colors(frame: &teksilo_canvas::RenderFrame) -> Vec<[f32; 4]> {
1596 frame.glyphs.iter().map(|g| g.color).collect()
1597 }
1598
1599 fn shape_colors(frame: &teksilo_canvas::RenderFrame) -> Vec<[f32; 4]> {
1600 frame.shapes.iter().map(|s| s.color).collect()
1601 }
1602
1603 fn approx_color_eq(a: [f32; 4], b: Color) -> bool {
1604 let target = b.to_array();
1605 a.iter()
1606 .zip(target.iter())
1607 .all(|(x, y)| (x - y).abs() < 0.02)
1608 }
1609
1610 #[test]
1611 fn foreground_override_sets_glyph_color() {
1612 let frame = render_avatar(
1616 Avatar::with_initials(lit!("JD")).foreground(Color::from_rgb(1.0, 0.0, 0.5)),
1617 );
1618 let target = Color::from_rgb(1.0, 0.0, 0.5);
1619 assert!(
1620 glyph_colors(&frame)
1621 .iter()
1622 .any(|c| approx_color_eq(*c, target)),
1623 "expected at least one glyph painted with the foreground override"
1624 );
1625 }
1626
1627 #[test]
1628 fn background_override_sets_bg_shape_color() {
1629 let frame = render_avatar(
1630 Avatar::with_initials(lit!("JD")).background(Color::from_rgb(0.1, 0.7, 0.2)),
1631 );
1632 let target = Color::from_rgb(0.1, 0.7, 0.2);
1633 assert!(
1634 shape_colors(&frame)
1635 .iter()
1636 .any(|c| approx_color_eq(*c, target)),
1637 "expected the bg override colour to appear on a Shape quad"
1638 );
1639 }
1640
1641 #[test]
1642 fn auto_contrast_uses_overridden_bg_for_initials_text() {
1643 let frame = render_avatar(
1646 Avatar::with_initials(lit!("JD")).background(Color::from_rgb(0.95, 0.95, 0.95)),
1647 );
1648 let glyphs = glyph_colors(&frame);
1649 assert!(
1650 !glyphs.is_empty(),
1651 "expected at least one initials glyph in the frame"
1652 );
1653 for g in &glyphs {
1654 assert!(
1656 g[0] < 0.3 && g[1] < 0.3 && g[2] < 0.3,
1657 "expected dark auto-contrast glyph against a light bg, got {:?}",
1658 g
1659 );
1660 }
1661 }
1662
1663 #[test]
1664 fn auto_contrast_uses_overridden_bg_against_dark() {
1665 let frame = render_avatar(
1666 Avatar::with_initials(lit!("JD")).background(Color::from_rgb(0.05, 0.05, 0.05)),
1667 );
1668 let glyphs = glyph_colors(&frame);
1669 assert!(!glyphs.is_empty());
1670 for g in &glyphs {
1671 assert!(
1672 g[0] > 0.9 && g[1] > 0.9 && g[2] > 0.9,
1673 "expected white auto-contrast glyph against a dark bg, got {:?}",
1674 g
1675 );
1676 }
1677 }
1678
1679 #[test]
1680 fn with_name_seed_drives_bg_palette_pick() {
1681 let a = render_avatar(Avatar::with_name(lit!("Jane Doe")));
1686 let b = render_avatar(Avatar::with_name(lit!("Jules Dupont")));
1687 let bg_a = shape_colors(&a)
1688 .into_iter()
1689 .next()
1690 .expect("first shape is the bg circle");
1691 let bg_b = shape_colors(&b)
1692 .into_iter()
1693 .next()
1694 .expect("first shape is the bg circle");
1695 assert_ne!(
1696 bg_a, bg_b,
1697 "Jane Doe and Jules Dupont share initials JD but must hash distinctly via their full names"
1698 );
1699 }
1700
1701 #[test]
1706 fn expanded_when_signal_reflects_in_a11y() {
1707 use teksilo_core::signal::Signal;
1708 let open = Signal::new(false);
1709 let mut tree = WidgetTree::new()
1710 .with_theme(teksilo_core::presets::intui::light())
1711 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1712 teksilo_canvas::MockTextBackend::new(),
1713 )));
1714 let id = tree.add(
1715 Avatar::with_initials(lit!("JD"))
1716 .label(lit!("Open user menu"))
1717 .has_popup(teksilo_core::accesskit::HasPopup::Menu)
1718 .expanded_when(open.clone())
1719 .on_activate_fn(|_ctx| {}),
1720 );
1721 tree.layout(SizeProposal::exact(32.0, 32.0));
1722 assert!(!tree.accessibility_node(id).is_expanded());
1724
1725 open.set(true);
1728 tree.layout(SizeProposal::exact(32.0, 32.0));
1729 assert!(tree.accessibility_node(id).is_expanded());
1730 }
1731
1732 #[test]
1733 fn has_popup_without_clickable_still_compiles() {
1734 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1739 let id = tree.add(
1740 Avatar::with_initials(lit!("JD")).has_popup(teksilo_core::accesskit::HasPopup::Menu),
1741 );
1742 tree.layout(SizeProposal::exact(32.0, 32.0));
1743 assert_eq!(
1745 tree.accessibility_node(id).role(),
1746 teksilo_core::accesskit::Role::Label
1747 );
1748 }
1749
1750 #[test]
1753 fn focus_ring_only_paints_when_focused() {
1754 let mut tree = WidgetTree::new()
1759 .with_theme(teksilo_core::presets::intui::light())
1760 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1761 teksilo_canvas::MockTextBackend::new(),
1762 )));
1763 let id = tree.add(
1764 Avatar::with_initials(lit!("JD"))
1765 .label(lit!("Open user menu"))
1766 .on_activate_fn(|_ctx| {}),
1767 );
1768 tree.layout(SizeProposal::exact(64.0, 64.0));
1769 let unfocused_shapes = tree.render().shapes.len();
1770
1771 tree.focus(id);
1775 tree.press_key(
1776 teksilo_core::event::Key::ArrowDown,
1777 teksilo_core::event::Modifiers::NONE,
1778 );
1779 tree.layout(SizeProposal::exact(64.0, 64.0));
1780 let focused_shapes = tree.render().shapes.len();
1781
1782 assert_eq!(
1783 focused_shapes,
1784 unfocused_shapes + 1,
1785 "focused avatar should emit one extra Shape (the focus ring stroke)"
1786 );
1787 }
1788
1789 #[test]
1790 fn focus_ring_uses_theme_focus_ring_color() {
1791 let mut tree = WidgetTree::new()
1792 .with_theme(teksilo_core::presets::intui::light())
1793 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1794 teksilo_canvas::MockTextBackend::new(),
1795 )));
1796 let id = tree.add(
1797 Avatar::with_initials(lit!("JD"))
1798 .label(lit!("Click"))
1799 .on_activate_fn(|_ctx| {}),
1800 );
1801 tree.layout(SizeProposal::exact(64.0, 64.0));
1802 tree.focus(id);
1804 tree.press_key(
1805 teksilo_core::event::Key::ArrowDown,
1806 teksilo_core::event::Modifiers::NONE,
1807 );
1808 tree.layout(SizeProposal::exact(64.0, 64.0));
1809 let frame = tree.render();
1810 let target = teksilo_core::presets::intui::light().colors.focus_ring;
1811 assert!(
1812 shape_colors(&frame)
1813 .iter()
1814 .any(|c| approx_color_eq(*c, target)),
1815 "expected at least one Shape painted with the theme's focus_ring colour"
1816 );
1817 }
1818
1819 #[test]
1820 fn non_clickable_avatar_has_no_focus_ring() {
1821 let mut tree = WidgetTree::new()
1825 .with_theme(teksilo_core::presets::intui::light())
1826 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1827 teksilo_canvas::MockTextBackend::new(),
1828 )));
1829 let id = tree.add(Avatar::with_initials(lit!("JD")));
1830 tree.layout(SizeProposal::exact(64.0, 64.0));
1831 let baseline = tree.render().shapes.len();
1832 tree.focus(id);
1836 tree.layout(SizeProposal::exact(64.0, 64.0));
1837 assert_eq!(
1838 tree.render().shapes.len(),
1839 baseline,
1840 "non-clickable avatar must never draw a focus ring"
1841 );
1842 }
1843
1844 #[test]
1845 fn image_avatar_announces_alt_on_parent() {
1846 let icon = rgba_solid(8, [10, 20, 30, 255]);
1849 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1850 let parent = tree.add(Avatar::with_image(&icon).alt(lit!("Jane")));
1851 tree.layout(SizeProposal::exact(32.0, 32.0));
1852 let info = tree.accessibility_node(parent);
1853 assert_eq!(info.role(), teksilo_core::accesskit::Role::Image);
1854 assert_eq!(info.name(), Some("Jane"));
1855 }
1856
1857 #[test]
1858 fn shape_change_after_image_does_not_panic() {
1859 let icon = rgba_solid(16, [10, 20, 30, 255]);
1864 let a = Avatar::with_image(&icon).alt(lit!("X"));
1865 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1866 let _ = tree.add(a.shape(AvatarShape::Square));
1867 tree.layout(SizeProposal::exact(32.0, 32.0));
1868 let _ = tree.render();
1869 }
1870
1871 #[test]
1874 fn name_updates_displayed_initials_on_signal_flip() {
1875 use std::cell::RefCell;
1876 use std::rc::Rc as StdRc;
1877 use teksilo_canvas::MockTextBackend;
1878 use teksilo_core::signal::Signal;
1879 let name = Signal::new(String::new()); let mut tree = WidgetTree::new()
1881 .with_theme(teksilo_core::presets::intui::light())
1882 .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1883 let id = tree.add(Avatar::with_initials(lit!("?")).name_signal(name.clone()));
1884 tree.layout(SizeProposal::exact(32.0, 32.0));
1885 assert_eq!(tree.accessibility_node(id).name(), Some("?"));
1887
1888 name.set("Jane Doe".to_string());
1889 tree.layout(SizeProposal::exact(32.0, 32.0));
1890 assert_eq!(tree.accessibility_node(id).name(), Some("JD"));
1892 }
1893
1894 #[test]
1895 fn image_swap_logged_out_to_logged_in() {
1896 use std::cell::RefCell;
1899 use std::rc::Rc as StdRc;
1900 use teksilo_canvas::MockTextBackend;
1901 use teksilo_core::signal::Signal;
1902 let icon = rgba_solid(8, [10, 20, 30, 255]);
1903 let image: Signal<Option<Rc<RasterIcon>>> = Signal::new(None);
1904 let mut tree = WidgetTree::new()
1905 .with_theme(teksilo_core::presets::intui::light())
1906 .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1907 let _id = tree.add(
1908 Avatar::with_initials(lit!("JD"))
1909 .alt(lit!("Jane"))
1910 .image_signal(image.clone()),
1911 );
1912 tree.layout(SizeProposal::exact(32.0, 32.0));
1913 assert!(
1915 tree.render().live_images.is_empty(),
1916 "logged-out avatar must not emit an image quad"
1917 );
1918
1919 image.set(Some(Rc::new(icon)));
1921 tree.layout(SizeProposal::exact(32.0, 32.0));
1922 assert!(
1923 !tree.render().live_images.is_empty(),
1924 "logged-in avatar must emit an image quad after the signal flips"
1925 );
1926
1927 image.set(None);
1929 tree.layout(SizeProposal::exact(32.0, 32.0));
1930 assert!(
1931 tree.render().live_images.is_empty(),
1932 "image quad must disappear when the source signal returns to None"
1933 );
1934 }
1935
1936 #[test]
1937 fn image_signal_wins_over_static_with_image() {
1938 use std::cell::RefCell;
1942 use std::rc::Rc as StdRc;
1943 use teksilo_canvas::MockTextBackend;
1944 use teksilo_core::signal::Signal;
1945 let icon = rgba_solid(8, [10, 20, 30, 255]);
1946 let image: Signal<Option<Rc<RasterIcon>>> = Signal::new(None);
1947 let mut tree = WidgetTree::new()
1948 .with_theme(teksilo_core::presets::intui::light())
1949 .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1950 let _id = tree.add(
1951 Avatar::with_image(&icon)
1952 .alt(lit!("anything"))
1953 .fallback_initials(lit!("XX"))
1954 .image_signal(image.clone()),
1955 );
1956 tree.layout(SizeProposal::exact(32.0, 32.0));
1957 assert!(tree.render().live_images.is_empty());
1959 }
1960
1961 #[test]
1962 fn alt_updates_a11y_name_on_image_avatar() {
1963 use std::cell::RefCell;
1964 use std::rc::Rc as StdRc;
1965 use teksilo_canvas::MockTextBackend;
1966 use teksilo_core::signal::Signal;
1967 let icon = rgba_solid(8, [10, 20, 30, 255]);
1968 let alt = Signal::new(Some("Jane Doe".to_string()));
1969 let mut tree = WidgetTree::new()
1970 .with_theme(teksilo_core::presets::intui::light())
1971 .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1972 let id = tree.add(Avatar::with_image(&icon).alt_signal(alt.clone()));
1973 tree.layout(SizeProposal::exact(32.0, 32.0));
1974 assert_eq!(tree.accessibility_node(id).name(), Some("Jane Doe"));
1975
1976 alt.set(Some("Jules Dupont".to_string()));
1977 tree.layout(SizeProposal::exact(32.0, 32.0));
1978 assert_eq!(tree.accessibility_node(id).name(), Some("Jules Dupont"));
1979 }
1980
1981 #[test]
1982 fn presence_swap_changes_dot_color_and_a11y_description() {
1983 use teksilo_core::signal::Signal;
1984 let presence: Signal<Option<AvatarPresence>> = Signal::new(Some(AvatarPresence::Online));
1985 let mut tree = WidgetTree::new()
1986 .with_theme(teksilo_core::presets::intui::light())
1987 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1988 teksilo_canvas::MockTextBackend::new(),
1989 )));
1990 let id = tree.add(Avatar::with_initials(lit!("JD")).presence_signal(presence.clone()));
1991 tree.layout(SizeProposal::exact(32.0, 32.0));
1992 let online_color = teksilo_core::presets::intui::light()
1993 .colors
1994 .status_success_fg;
1995 assert!(
1996 shape_colors(&tree.render())
1997 .iter()
1998 .any(|c| approx_color_eq(*c, online_color)),
1999 "Online presence should paint the success colour"
2000 );
2001 let _ = id;
2002
2003 presence.set(Some(AvatarPresence::Busy));
2005 tree.layout(SizeProposal::exact(32.0, 32.0));
2006 let busy_color = teksilo_core::presets::intui::light().colors.status_error_fg;
2007 assert!(
2008 shape_colors(&tree.render())
2009 .iter()
2010 .any(|c| approx_color_eq(*c, busy_color)),
2011 "Busy presence should paint the error colour"
2012 );
2013
2014 presence.set(None);
2016 tree.layout(SizeProposal::exact(32.0, 32.0));
2017 let frame = tree.render();
2018 assert!(
2020 !shape_colors(&frame)
2021 .iter()
2022 .any(|c| approx_color_eq(*c, online_color) || approx_color_eq(*c, busy_color)),
2023 "presence None must remove the dot from the frame"
2024 );
2025 }
2026
2027 #[test]
2030 fn tooltip_appears_on_hover() {
2031 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2032 let id = tree.add(Avatar::with_initials(lit!("JD")).tooltip(lit!("Tip")));
2033 tree.layout(SizeProposal::exact(300.0, 200.0));
2034 tree.pointer_move(tree.bounds(id).center());
2035 tree.advance_time(std::time::Duration::from_secs(1));
2036 assert_eq!(
2037 tree.active_overlays().len(),
2038 1,
2039 "tooltip should appear on hover"
2040 );
2041 assert!(tree.find_by_label("Tip").is_some());
2042 }
2043
2044 #[test]
2045 fn name_changes_hash_seed_so_palette_pick_can_change() {
2046 use std::cell::RefCell;
2050 use std::rc::Rc as StdRc;
2051 use teksilo_canvas::MockTextBackend;
2052 use teksilo_core::signal::Signal;
2053 let name = Signal::new("Jane Doe".to_string());
2054 let mut tree = WidgetTree::new()
2055 .with_theme(teksilo_core::presets::intui::light())
2056 .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
2057 let _id = tree.add(Avatar::with_initials(lit!("?")).name_signal(name.clone()));
2058 tree.layout(SizeProposal::exact(32.0, 32.0));
2059 let bg_jd = shape_colors(&tree.render())
2060 .into_iter()
2061 .next()
2062 .expect("bg circle is the first Shape");
2063
2064 name.set("Jules Dupont".to_string());
2065 tree.layout(SizeProposal::exact(32.0, 32.0));
2066 let bg_jd2 = shape_colors(&tree.render()).into_iter().next().unwrap();
2067 assert_ne!(
2068 bg_jd, bg_jd2,
2069 "different bound names must hash to different palette buckets"
2070 );
2071 }
2072
2073 fn avatar_in_a_tappable_row(
2088 hits: std::rc::Rc<std::cell::Cell<u32>>,
2089 row_taps: std::rc::Rc<std::cell::Cell<u32>>,
2090 ) -> (WidgetTree, teksilo_core::widget_id::WidgetId) {
2091 use teksilo_core::widget_builder::WidgetBuilder;
2092
2093 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2094 let av = tree.add(
2095 Avatar::with_initials(lit!("JD"))
2096 .size(AvatarSize::Custom(20.0))
2097 .on_activate_fn(move |_| hits.set(hits.get() + 1)),
2098 );
2099 let name = tree.add(crate::primitives::TextWidget::new(lit!("Jane Doe")));
2100 let _row = tree.add(
2101 crate::primitives::HStack::new()
2102 .child(av)
2103 .child(name)
2104 .on_tap(move |_e, _c| row_taps.set(row_taps.get() + 1)),
2105 );
2106 tree.layout(SizeProposal::exact(200.0, 60.0));
2107 (tree, av)
2108 }
2109
2110 #[test]
2114 fn a_finger_just_outside_a_small_custom_avatar_still_activates_it() {
2115 use crate::button::press_test_support::{finger, touch};
2116 use teksilo_core::pointer::PointerPhase;
2117
2118 let hits = std::rc::Rc::new(std::cell::Cell::new(0_u32));
2119 let row_taps = std::rc::Rc::new(std::cell::Cell::new(0_u32));
2120 let (mut tree, av) = avatar_in_a_tappable_row(hits.clone(), row_taps.clone());
2121 let b = tree.bounds(av);
2122 assert!(
2123 b.width < 24.0,
2124 "the avatar is meant to be under the floor: {b:?}"
2125 );
2126 let at = teksilo_canvas::Point::new(b.right() + 1.5, b.center().y);
2128 let id = finger();
2129 tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
2130 tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 30));
2131 assert_eq!(hits.get(), 1, "the avatar's outset did not take the press");
2132 assert_eq!(row_taps.get(), 0, "and the row must not have taken it too");
2133 }
2134
2135 #[test]
2138 fn a_mouse_just_outside_a_small_custom_avatar_lands_on_the_row() {
2139 let hits = std::rc::Rc::new(std::cell::Cell::new(0_u32));
2140 let row_taps = std::rc::Rc::new(std::cell::Cell::new(0_u32));
2141 let (mut tree, av) = avatar_in_a_tappable_row(hits.clone(), row_taps.clone());
2142 let b = tree.bounds(av);
2143 let at = teksilo_canvas::Point::new(b.right() + 1.5, b.center().y);
2144 tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
2145 tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
2146 assert_eq!(
2147 hits.get(),
2148 0,
2149 "a mouse must not be given the avatar's outset"
2150 );
2151 assert_eq!(row_taps.get(), 1);
2152 }
2153
2154 #[test]
2158 fn a_decorative_avatar_declares_no_outset() {
2159 use teksilo_core::widget::Widget;
2160 use teksilo_tokens::{InputTokens, PointerKind};
2161
2162 let plain = Avatar::with_initials(lit!("JD")).size(AvatarSize::Custom(20.0));
2163 assert_eq!(
2164 plain.hit_outset(PointerKind::Touch, &InputTokens::default()),
2165 teksilo_canvas::EdgeInsets::ZERO,
2166 );
2167 }
2168}