1use std::rc::Rc;
40
41use teksilo_canvas::raster::RasterIcon;
42use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
43use teksilo_core::accessibility::AccessNodeBuilder;
44use teksilo_core::build_context::BuildContext;
45use teksilo_core::color_prop::ColorProp;
46use teksilo_core::signal::{Prop, Signal};
47use teksilo_core::styles::{AvatarStyleConfig, SharedAvatarStyle};
48use teksilo_core::widget::{
49 CursorIcon, EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement,
50};
51use teksilo_core::widget_builder::HandlerSet;
52use teksilo_core::widget_id::WidgetId;
53use teksilo_tokens::{Color, FontWeight, TextStyle};
54
55pub use teksilo_core::styles::{AvatarCorner, AvatarPresence, AvatarShape, AvatarSize};
56
57use crate::primitives::ImageWidget;
58use crate::primitives::image_mask::ImageMaskShape;
59use crate::primitives::image_widget::ImageFit;
60use crate::styles::recipe_avatar_style::{
61 AVATAR_FONT_RATIO_1CHAR, AVATAR_FONT_RATIO_2CHAR, AVATAR_ROUNDED_RADIUS_RATIO,
62 auto_contrast_text, avatar_pixel_size, hash_pick_palette_color,
63};
64use teksilo_i18n::LocalizedString;
65
66type ActionFn = Rc<dyn Fn(&mut EventContext)>;
69
70pub struct Avatar {
80 initials: String,
84 label: Option<String>,
87 alt: Option<String>,
89 image_source: Option<RawImage>,
92
93 size: AvatarSize,
94 shape: AvatarShape,
95
96 background: Option<ColorProp>,
97 foreground: Option<ColorProp>,
98 border_color: Option<ColorProp>,
99 border_width: Option<f32>,
100
101 presence: Option<AvatarPresence>,
102 presence_corner: AvatarCorner,
103
104 seed: Option<String>,
105
106 a11y_hidden: bool,
107
108 image_visible: Prop<bool>,
109
110 name_signal: Option<Signal<String>>,
115 image_signal: Option<Signal<Option<Rc<RasterIcon>>>>,
118 alt_signal: Option<Signal<Option<String>>>,
121 label_signal: Option<Signal<Option<String>>>,
123 presence_signal: Option<Signal<Option<AvatarPresence>>>,
126
127 has_popup: Option<teksilo_core::accesskit::HasPopup>,
131 expanded_signal: Option<Prop<bool>>,
135
136 action: Option<ActionFn>,
140
141 focused: Option<Signal<bool>>,
145 style_override: Option<SharedAvatarStyle>,
148 root_child_id: Option<WidgetId>,
150
151 tooltip_text: Option<LocalizedString>,
155 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
157 composite_tooltip_content: Option<Box<dyn Widget>>,
159}
160
161#[derive(Clone)]
162struct RawImage {
163 pixels: Rc<Vec<u8>>,
167 width: u32,
168 height: u32,
169}
170
171impl Avatar {
174 pub fn with_initials(initials: impl Into<LocalizedString>) -> Self {
177 let ls: LocalizedString = initials.into();
178 let raw = ls.resolve_now();
179 Self::from_initials(normalize_initials(&raw))
180 }
181
182 pub fn with_name(name: impl Into<LocalizedString>) -> Self {
188 let ls: LocalizedString = name.into();
189 let raw = ls.resolve_now();
190 let initials = derive_initials(&raw);
191 let mut a = Self::from_initials(initials);
192 a.seed = Some(raw); a
194 }
195
196 pub fn with_image(icon: &RasterIcon) -> Self {
201 Self::from_raw_image(icon.pixels().to_vec(), icon.width(), icon.height())
202 }
203
204 pub fn from_raw_image(pixels: Vec<u8>, width: u32, height: u32) -> Self {
207 let mut a = Self::from_initials("?".to_string());
208 a.image_source = Some(RawImage {
209 pixels: Rc::new(pixels),
210 width,
211 height,
212 });
213 a
214 }
215
216 fn from_initials(initials: String) -> Self {
217 Self {
218 initials,
219 label: None,
220 alt: None,
221 image_source: None,
222 size: AvatarSize::Medium,
223 shape: AvatarShape::Circle,
224 background: None,
225 foreground: None,
226 border_color: None,
227 border_width: None,
228 presence: None,
229 presence_corner: AvatarCorner::BottomTrailing,
230 seed: None,
231 a11y_hidden: false,
232 image_visible: Prop::Static(true),
233 name_signal: None,
234 image_signal: None,
235 alt_signal: None,
236 label_signal: None,
237 presence_signal: None,
238 has_popup: None,
239 expanded_signal: None,
240 action: None,
241 focused: None,
242 style_override: None,
243 root_child_id: None,
244 tooltip_text: None,
245 rich_tooltip_source: None,
246 composite_tooltip_content: None,
247 }
248 }
249
250 pub fn style(mut self, style: impl teksilo_core::styles::AvatarStyle) -> Self {
252 self.style_override = Some(Rc::new(style));
253 self
254 }
255}
256
257impl Avatar {
260 pub fn size(mut self, size: AvatarSize) -> Self {
262 self.size = size;
263 self
264 }
265
266 pub fn shape(mut self, shape: AvatarShape) -> Self {
268 self.shape = shape;
272 self
273 }
274
275 pub fn fallback_initials(mut self, initials: impl Into<LocalizedString>) -> Self {
280 let ls: LocalizedString = initials.into();
281 let raw = ls.resolve_now();
282 self.initials = normalize_initials(&raw);
283 self
284 }
285
286 pub fn image_visible(mut self, visible: impl Into<Prop<bool>>) -> Self {
291 self.image_visible = visible.into();
292 self
293 }
294
295 pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
298 self.background = Some(color.into());
299 self
300 }
301
302 pub fn foreground(mut self, color: impl Into<ColorProp>) -> Self {
307 self.foreground = Some(color.into());
308 self
309 }
310
311 pub fn seed(mut self, seed: impl Into<String>) -> Self {
315 self.seed = Some(seed.into());
316 self
317 }
318
319 pub fn border(mut self, width: f32) -> Self {
323 self.border_width = Some(width.max(0.0));
324 self
325 }
326
327 pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self {
331 self.border_color = Some(color.into());
332 self
333 }
334
335 pub fn presence(mut self, presence: AvatarPresence) -> Self {
338 self.presence = Some(presence);
339 self
340 }
341
342 pub fn presence_corner(mut self, corner: AvatarCorner) -> Self {
345 self.presence_corner = corner;
346 self
347 }
348
349 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
353 let ls: LocalizedString = label.into();
354 self.label = Some(ls.resolve_now());
355 self
356 }
357
358 pub fn alt(mut self, alt: impl Into<LocalizedString>) -> Self {
362 let ls: LocalizedString = alt.into();
363 self.alt = Some(ls.resolve_now());
364 self
365 }
366
367 pub fn a11y_hidden(mut self) -> Self {
370 self.a11y_hidden = true;
371 self
372 }
373
374 pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
379 self.action = Some(Rc::new(f));
380 self
381 }
382
383 pub fn has_popup(mut self, kind: teksilo_core::accesskit::HasPopup) -> Self {
390 self.has_popup = Some(kind);
391 self
392 }
393
394 pub fn expanded_when(mut self, signal: impl Into<Prop<bool>>) -> Self {
400 self.expanded_signal = Some(signal.into());
401 self
402 }
403
404 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
411 self.tooltip_text = Some(text.into());
412 self.rich_tooltip_source = None;
413 self.composite_tooltip_content = None;
414 self
415 }
416
417 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
422 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
423 self.tooltip_text = None;
424 self.composite_tooltip_content = None;
425 self
426 }
427
428 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
432 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
433 self.tooltip_text = None;
434 self.composite_tooltip_content = None;
435 self
436 }
437
438 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
443 self.composite_tooltip_content = Some(Box::new(content));
444 self.tooltip_text = None;
445 self.rich_tooltip_source = None;
446 self
447 }
448
449 pub fn name_signal(mut self, signal: Signal<String>) -> Self {
465 self.name_signal = Some(signal);
466 self
467 }
468
469 pub fn image_signal(mut self, signal: Signal<Option<Rc<RasterIcon>>>) -> Self {
473 self.image_signal = Some(signal);
474 self
475 }
476
477 pub fn alt_signal(mut self, signal: Signal<Option<String>>) -> Self {
480 self.alt_signal = Some(signal);
481 self
482 }
483
484 pub fn label_signal(mut self, signal: Signal<Option<String>>) -> Self {
487 self.label_signal = Some(signal);
488 self
489 }
490
491 pub fn presence_signal(mut self, signal: Signal<Option<AvatarPresence>>) -> Self {
496 self.presence_signal = Some(signal);
497 self
498 }
499}
500
501impl std::fmt::Debug for Avatar {
502 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
503 f.debug_struct("Avatar")
504 .field("initials", &self.initials)
505 .field("size", &self.size)
506 .field("shape", &self.shape)
507 .field(
508 "has_image",
509 &(self.image_source.is_some() || self.image_signal.is_some()),
510 )
511 .field("clickable", &self.action.is_some())
512 .finish()
513 }
514}
515
516fn normalize_initials(s: &str) -> String {
525 let mut out = String::new();
526 let mut count = 0;
527 for c in s.trim().chars() {
528 if count >= 2 {
529 break;
530 }
531 for upper in c.to_uppercase() {
532 out.push(upper);
533 }
534 count += 1;
535 }
536 if out.is_empty() { "?".to_string() } else { out }
537}
538
539fn derive_initials(name: &str) -> String {
541 let trimmed = name.trim();
542 if trimmed.is_empty() {
543 return "?".to_string();
544 }
545 let source = trimmed.split('@').next().unwrap_or(trimmed);
547 let parts: Vec<&str> = source
548 .split(|c: char| c.is_whitespace() || c == '.' || c == '_' || c == '-')
549 .filter(|s| !s.is_empty())
550 .collect();
551
552 let mut out = String::new();
553 for part in parts.iter().take(2) {
554 if let Some(c) = part.chars().next() {
555 for upper in c.to_uppercase() {
556 out.push(upper);
557 }
558 }
559 }
560 if out.is_empty() { "?".to_string() } else { out }
561}
562
563fn shape_to_image_mask(shape: AvatarShape) -> ImageMaskShape {
564 match shape {
565 AvatarShape::Circle => ImageMaskShape::Circle,
566 AvatarShape::RoundedSquare => ImageMaskShape::RoundedSquare(AVATAR_ROUNDED_RADIUS_RATIO),
567 AvatarShape::Square => ImageMaskShape::None,
568 }
569}
570
571impl Avatar {
574 fn current_initials(&self) -> String {
578 match &self.name_signal {
579 Some(sig) => derive_initials(&sig.get()),
580 None => self.initials.clone(),
581 }
582 }
583
584 fn current_seed(&self) -> String {
589 match &self.name_signal {
590 Some(sig) => sig.get(),
591 None => self.seed.clone().unwrap_or_else(|| self.initials.clone()),
592 }
593 }
594
595 fn current_alt(&self) -> Option<String> {
596 match &self.alt_signal {
597 Some(sig) => sig.get(),
598 None => self.alt.clone(),
599 }
600 }
601
602 fn current_label(&self) -> Option<String> {
603 match &self.label_signal {
604 Some(sig) => sig.get(),
605 None => self.label.clone(),
606 }
607 }
608
609 fn current_presence(&self) -> Option<AvatarPresence> {
610 match &self.presence_signal {
611 Some(sig) => sig.get(),
612 None => self.presence.clone(),
613 }
614 }
615
616 fn current_image(&self) -> Option<(Rc<Vec<u8>>, u32, u32)> {
620 if let Some(sig) = &self.image_signal {
621 return sig
622 .get()
623 .map(|rc| (Rc::new(rc.pixels().to_vec()), rc.width(), rc.height()));
624 }
625 self.image_source
626 .as_ref()
627 .map(|raw| (raw.pixels.clone(), raw.width, raw.height))
628 }
629
630 fn has_image_now(&self) -> bool {
632 self.image_signal
633 .as_ref()
634 .is_some_and(|sig| sig.get().is_some())
635 || self.image_source.is_some()
636 }
637}
638
639impl Widget for Avatar {
642 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
643 let self_id = ctx.self_id();
644 let mask_shape = shape_to_image_mask(self.shape);
645
646 let initials = self.current_initials();
649 let seed = self.current_seed();
650 let alt = self.current_alt();
651 let image_bytes = self.current_image();
652
653 let make_initials_leaf = || InitialsLeaf {
657 initials: initials.clone(),
658 seed: seed.clone(),
659 background: self.background.clone(),
660 foreground: self.foreground.clone(),
661 };
662 let make_image_widget = |bytes: Rc<Vec<u8>>, w: u32, h: u32, alt: Option<String>| {
663 let mut img = ImageWidget::from_raw((*bytes).clone(), w, h)
664 .fit(ImageFit::Cover)
665 .mask(mask_shape);
666 if let Some(a) = alt {
667 img = img.alt(a);
668 } else {
669 img = img.a11y_hidden();
672 }
673 img
674 };
675
676 let content_id = match (image_bytes, &self.image_visible) {
681 (Some((bytes, w, h)), Prop::Static(true)) => {
682 ctx.add(make_image_widget(bytes, w, h, alt.clone()))
683 }
684 (Some(_), Prop::Static(false)) => ctx.add(make_initials_leaf()),
685 (Some((bytes, w, h)), Prop::Bound(visible_signal)) => {
686 let img_id = ctx.add(make_image_widget(bytes, w, h, alt.clone()));
687 let init_id = ctx.add(make_initials_leaf());
688 let v_clone = visible_signal.clone();
689 ctx.visible_when(img_id, v_clone.clone());
690 ctx.visible_when(init_id, v_clone.map(|v| !*v));
691 ctx.add(
692 crate::primitives::ZStack::new()
693 .add_child(img_id)
694 .add_child(init_id),
695 )
696 }
697 (None, _) => ctx.add(make_initials_leaf()),
698 };
699
700 let registry = ctx.binding_registry();
702 if let Some(sig) = &self.name_signal {
703 sig.bind_to(
704 self_id,
705 registry,
706 teksilo_core::binding::BindingLevel::Rebuild,
707 );
708 }
709 if let Some(sig) = &self.image_signal {
710 sig.bind_to(
711 self_id,
712 registry,
713 teksilo_core::binding::BindingLevel::Rebuild,
714 );
715 }
716 if let Some(sig) = &self.presence_signal {
717 sig.bind_to(
718 self_id,
719 registry,
720 teksilo_core::binding::BindingLevel::Rebuild,
721 );
722 }
723 if let Some(sig) = &self.alt_signal {
724 sig.bind_to(
725 self_id,
726 registry,
727 teksilo_core::binding::BindingLevel::AccessibilityOnly,
728 );
729 }
730 if let Some(sig) = &self.label_signal {
731 sig.bind_to(
732 self_id,
733 registry,
734 teksilo_core::binding::BindingLevel::AccessibilityOnly,
735 );
736 }
737
738 let focused = ctx.signal(false);
742 self.focused = Some(focused.clone());
743 if let Some(action) = self.action.clone() {
744 let focus_for_handler = focused.clone();
745
746 let action_for_tap = action.clone();
747 let action_for_key = action.clone();
748 let action_for_access = action;
749 let handlers = HandlerSet::new()
750 .on_tap(move |_pos, ctx| action_for_tap(ctx))
751 .focusable(true)
752 .cursor(CursorIcon::Pointer)
753 .on_focus(move |gained, _ctx| focus_for_handler.set(gained))
754 .on_key(move |event, ctx| {
755 use teksilo_core::event::{EventResponse, Key, WidgetEvent};
756 match event {
757 WidgetEvent::KeyDown {
758 key: Key::Enter | Key::Space,
759 ..
760 } => {
761 action_for_key(ctx);
762 EventResponse::Handled
763 }
764 _ => EventResponse::Ignored,
765 }
766 })
767 .on_access_action(move |action_kind, ctx| {
768 use teksilo_core::event::EventResponse;
769 if action_kind == teksilo_core::accesskit::Action::Click {
770 action_for_access(ctx);
771 EventResponse::Handled
772 } else {
773 EventResponse::Ignored
774 }
775 });
776 ctx.apply_self_handlers(handlers);
777 }
778
779 if let Some(ref expanded_signal) = self.expanded_signal {
781 let self_id = ctx.self_id();
782 let registry = ctx.binding_registry();
783 expanded_signal.register_if_bound(
784 self_id,
785 registry,
786 teksilo_core::binding::BindingLevel::RepaintOnly,
787 );
788 }
789
790 let style: SharedAvatarStyle = self
795 .style_override
796 .clone()
797 .or_else(|| ctx.theme().style_slots.avatar.clone())
798 .unwrap_or_else(|| Rc::new(crate::styles::RecipeAvatarStyle::default()));
799 let root = style.make_body(
800 &AvatarStyleConfig {
801 shape: self.shape,
802 size: self.size,
803 content: content_id,
804 presence: self.current_presence(),
805 presence_corner: self.presence_corner,
806 is_focused: focused.and(&ctx.focus_visible()),
809 background_override: self.background.clone(),
810 border_color_override: self.border_color.clone(),
811 border_width_override: self.border_width,
812 seed,
813 },
814 ctx,
815 );
816 self.root_child_id = Some(root);
817
818 if let Some(content) = self.composite_tooltip_content.take() {
820 let delay = ctx.theme().motion.tooltip_delay_heavy;
821 crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
822 } else if let Some(source) = self.rich_tooltip_source.clone() {
823 let delay = ctx.theme().motion.tooltip_delay;
824 crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
825 } else if let Some(text) = self.tooltip_text.clone() {
826 let delay = ctx.theme().motion.tooltip_delay;
827 crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
828 }
829
830 vec![root]
831 }
832
833 fn layout_response(
834 &self,
835 _proposal: SizeProposal,
836 _ctx: &LayoutContext,
837 ) -> teksilo_core::widget::LayoutResponse {
838 let side = avatar_pixel_size(self.size);
839 Size::new(side, side).into()
840 }
841
842 fn place_children(
843 &self,
844 bounds: Rect,
845 _proposal: SizeProposal,
846 children: &mut [WidgetPlacement],
847 _ctx: &LayoutContext,
848 ) {
849 for child in children.iter_mut() {
850 child.origin = bounds.origin();
851 child.size = bounds.size();
852 }
853 }
854
855 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
856 if self.a11y_hidden {
857 builder.set_hidden();
858 return;
859 }
860
861 let clickable = self.action.is_some();
862 let has_image = self.has_image_now();
868 let alt = self.current_alt();
869 let label = self.current_label();
870 let initials = self.current_initials();
871
872 if clickable {
873 builder.set_role(teksilo_core::accesskit::Role::Button);
874 debug_assert!(
878 label.is_some() || alt.is_some(),
879 "Avatar::on_activate_fn requires a `.label(\"...\")` (preferred) or `.alt(\"...\")` (or a `.label(...)` / `.alt_signal(...)`) for screen readers"
880 );
881 let name = label.or(alt).unwrap_or_else(|| initials.clone());
882 builder.set_name(name);
883 builder.add_action(teksilo_core::accesskit::Action::Click);
884 builder.add_action(teksilo_core::accesskit::Action::Focus);
885 } else if has_image {
886 builder.set_role(teksilo_core::accesskit::Role::Image);
887 debug_assert!(
890 alt.is_some() || label.is_some(),
891 "Avatar::with_image requires a `.alt(\"...\")` (or `.alt_signal(...)`) for meaningful images, or call `.a11y_hidden()` if decorative"
892 );
893 let name = alt.or(label).unwrap_or_else(|| initials.clone());
894 builder.set_name(name);
895 } else {
896 builder.set_role(teksilo_core::accesskit::Role::Label);
897 let name = label.unwrap_or_else(|| initials.clone());
898 builder.set_name(name);
899 }
900
901 if let Some(presence) = self.current_presence() {
902 builder.set_description(presence.label());
903 }
904
905 if let Some(kind) = self.has_popup {
911 builder.set_has_popup(kind);
912 }
913 if let Some(ref signal) = self.expanded_signal {
914 builder.set_expanded(signal.get());
915 }
916 }
917
918 fn children(&self) -> Vec<WidgetId> {
919 self.root_child_id.into_iter().collect()
920 }
921}
922
923#[derive(Debug)]
939struct InitialsLeaf {
940 initials: String,
941 seed: String,
942 background: Option<ColorProp>,
943 foreground: Option<ColorProp>,
944}
945
946impl InitialsLeaf {
947 fn resolve_bg(&self, theme: &teksilo_core::Theme, enabled: bool) -> Color {
950 match &self.background {
951 Some(prop) => prop.resolve(theme, enabled),
952 None => hash_pick_palette_color(&self.seed, theme),
953 }
954 }
955}
956
957impl Widget for InitialsLeaf {
958 fn layout_response(
959 &self,
960 proposal: SizeProposal,
961 _ctx: &LayoutContext,
962 ) -> teksilo_core::widget::LayoutResponse {
963 proposal.resolve(0.0, 0.0).into()
965 }
966
967 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
968 let theme = ctx.theme;
969
970 let font_size = bounds.width.min(bounds.height)
971 * if self.initials.chars().count() <= 1 {
972 AVATAR_FONT_RATIO_1CHAR
973 } else {
974 AVATAR_FONT_RATIO_2CHAR
975 };
976
977 let text_style = TextStyle {
978 family: theme.typography.body_bold.family.clone(),
979 size: font_size,
980 weight: FontWeight::SEMI_BOLD,
981 line_height: 1.0,
982 letter_spacing: 0.0,
983 };
984
985 let fg = match &self.foreground {
988 Some(prop) => prop.resolve(theme, ctx.effective_enabled),
989 None => auto_contrast_text(self.resolve_bg(theme, ctx.effective_enabled)),
990 };
991
992 let Some(backend) = canvas.text_backend().cloned() else {
995 return;
996 };
997 let layout = {
998 let mut b = backend.borrow_mut();
999 b.layout_single_line(&self.initials, &text_style, None)
1000 };
1001 let text_w = layout.width;
1002 let text_h = layout.height;
1003
1004 let cx = bounds.x + (bounds.width - text_w) / 2.0;
1005 let cy = bounds.y + (bounds.height - text_h) / 2.0;
1006 let position = Rect::new(cx, cy, text_w, text_h);
1007 canvas.draw_text(&self.initials, position, &text_style, fg);
1008 }
1009
1010 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1011 builder.set_hidden();
1015 }
1016}
1017
1018#[cfg(test)]
1021mod tests {
1022 use super::*;
1023 use crate::styles::recipe_avatar_style::fnv1a_64;
1024 use teksilo_core::widget::LayoutContext;
1025 use teksilo_core::widget_tree::WidgetTree;
1026 use teksilo_i18n::lit;
1027
1028 fn rgba_solid(side: u32, rgba: [u8; 4]) -> RasterIcon {
1031 let mut p = Vec::with_capacity((side * side * 4) as usize);
1032 for _ in 0..(side * side) {
1033 p.extend_from_slice(&rgba);
1034 }
1035 RasterIcon::from_raw(p, side, side)
1036 }
1037
1038 #[test]
1041 fn normalize_uppercase_truncate() {
1042 assert_eq!(normalize_initials("jdq"), "JD");
1043 assert_eq!(normalize_initials("jd"), "JD");
1044 assert_eq!(normalize_initials("j"), "J");
1045 assert_eq!(normalize_initials(" "), "?");
1046 assert_eq!(normalize_initials(""), "?");
1047 }
1048
1049 #[test]
1050 fn derive_full_name() {
1051 assert_eq!(derive_initials("Jane Doe"), "JD");
1052 }
1053
1054 #[test]
1055 fn derive_single_word() {
1056 assert_eq!(derive_initials("Cher"), "C");
1057 }
1058
1059 #[test]
1060 fn derive_email() {
1061 assert_eq!(derive_initials("jane.doe@x.com"), "JD");
1062 assert_eq!(derive_initials("jane_doe@x.com"), "JD");
1063 }
1064
1065 #[test]
1066 fn derive_unicode_name() {
1067 assert_eq!(derive_initials("María José"), "MJ");
1068 }
1069
1070 #[test]
1071 fn derive_empty_yields_question_mark() {
1072 assert_eq!(derive_initials(""), "?");
1073 assert_eq!(derive_initials(" "), "?");
1074 }
1075
1076 #[test]
1077 fn derive_three_words_takes_first_two() {
1078 assert_eq!(derive_initials("Anna María José"), "AM");
1079 }
1080
1081 #[test]
1082 fn derive_hyphenated_name() {
1083 assert_eq!(derive_initials("Jean-Luc Picard"), "JL");
1084 }
1085
1086 #[test]
1089 fn fnv1a_is_stable() {
1090 let h1 = fnv1a_64(b"jane.doe");
1091 let h2 = fnv1a_64(b"jane.doe");
1092 assert_eq!(h1, h2);
1093 assert_ne!(fnv1a_64(b"jane.doe"), fnv1a_64(b"john.smith"));
1094 }
1095
1096 #[test]
1097 fn hash_distributes_over_palette() {
1098 let theme = teksilo_core::presets::intui::light();
1099 let mut buckets = [0_u32; 8];
1100 for i in 0..200 {
1101 let seed = format!("user_{i}");
1102 let color = hash_pick_palette_color(&seed, &theme);
1103 let idx = theme
1105 .colors
1106 .chart_palette
1107 .iter()
1108 .position(|c| c == &color)
1109 .expect("color must be a palette member");
1110 buckets[idx] += 1;
1111 }
1112 let nonzero = buckets.iter().filter(|n| **n > 0).count();
1113 assert!(
1114 nonzero >= 6,
1115 "expected hash to cover at least 6 of 8 buckets, got {nonzero} (buckets: {:?})",
1116 buckets
1117 );
1118 }
1119
1120 #[test]
1123 fn size_default_is_medium_32px() {
1124 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1125 let id = tree.add(Avatar::with_initials(lit!("JD")));
1126 tree.layout(SizeProposal {
1127 width: None,
1128 height: None,
1129 });
1130 let b = tree.bounds(id);
1131 assert!((b.width - 32.0).abs() < 0.01);
1132 assert!((b.height - 32.0).abs() < 0.01);
1133 }
1134
1135 #[test]
1136 fn size_custom_passes_through() {
1137 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1138 let id = tree.add(Avatar::with_initials(lit!("JD")).size(AvatarSize::Custom(40.0)));
1139 tree.layout(SizeProposal {
1140 width: None,
1141 height: None,
1142 });
1143 let b = tree.bounds(id);
1144 assert!((b.width - 40.0).abs() < 0.01);
1145 assert!((b.height - 40.0).abs() < 0.01);
1146 }
1147
1148 #[test]
1149 fn size_that_fits_ignores_proposal() {
1150 let widget = Avatar::with_initials(lit!("JD"));
1156 let theme = teksilo_core::presets::intui::light();
1157 let ctx = LayoutContext::for_testing(&theme);
1158 let s = widget
1159 .layout_response(SizeProposal::exact(400.0, 400.0), &ctx)
1160 .size;
1161 assert!((s.width - 32.0).abs() < 0.01);
1162 assert!((s.height - 32.0).abs() < 0.01);
1163 }
1164
1165 #[test]
1166 fn small_medium_large_xlarge_sizes() {
1167 let theme = teksilo_core::presets::intui::light();
1168 use crate::styles::recipe_avatar_style as av;
1169 let cases = [
1170 (AvatarSize::Small, av::AVATAR_SIZE_SMALL),
1171 (AvatarSize::Medium, av::AVATAR_SIZE_MEDIUM),
1172 (AvatarSize::Large, av::AVATAR_SIZE_LARGE),
1173 (AvatarSize::XLarge, av::AVATAR_SIZE_X_LARGE),
1174 ];
1175 for (variant, expected) in cases {
1176 let mut tree = WidgetTree::new().with_theme(theme.clone());
1177 let id = tree.add(Avatar::with_initials(lit!("X")).size(variant));
1178 tree.layout(SizeProposal {
1179 width: None,
1180 height: None,
1181 });
1182 let b = tree.bounds(id);
1183 assert!(
1184 (b.width - expected).abs() < 0.01,
1185 "size {variant:?}: expected {expected}, got {}",
1186 b.width
1187 );
1188 }
1189 }
1190
1191 fn render_avatar(avatar: Avatar) -> std::rc::Rc<teksilo_canvas::RenderFrame> {
1194 use std::cell::RefCell;
1195 use std::rc::Rc;
1196 use teksilo_canvas::MockTextBackend;
1197 let mut tree = WidgetTree::new()
1198 .with_theme(teksilo_core::presets::intui::light())
1199 .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
1200 tree.add(avatar);
1201 tree.layout(SizeProposal::exact(64.0, 64.0));
1202 tree.render()
1203 }
1204
1205 fn count_shapes(frame: &teksilo_canvas::RenderFrame) -> usize {
1206 frame.shapes.len()
1210 }
1211
1212 #[test]
1213 fn paint_initials_emits_a_shape_quad() {
1214 let frame = render_avatar(Avatar::with_initials(lit!("JD")));
1215 assert!(
1216 count_shapes(&frame) >= 1,
1217 "expected at least one ShapeQuad (the bg circle)"
1218 );
1219 }
1220
1221 #[test]
1222 fn paint_with_border_adds_extra_shape() {
1223 let plain = render_avatar(Avatar::with_initials(lit!("JD")));
1224 let bordered = render_avatar(Avatar::with_initials(lit!("JD")).border(2.0));
1225 assert!(
1226 count_shapes(&bordered) > count_shapes(&plain),
1227 "border path should add at least one extra Shape (the stroked ring)"
1228 );
1229 }
1230
1231 #[test]
1232 fn paint_presence_adds_two_shapes() {
1233 let plain = render_avatar(Avatar::with_initials(lit!("JD")));
1234 let with_dot =
1235 render_avatar(Avatar::with_initials(lit!("JD")).presence(AvatarPresence::Online));
1236 assert_eq!(count_shapes(&with_dot), count_shapes(&plain) + 2);
1238 }
1239
1240 #[test]
1241 fn paint_rounded_square_emits_shape() {
1242 let frame =
1245 render_avatar(Avatar::with_initials(lit!("JD")).shape(AvatarShape::RoundedSquare));
1246 assert!(count_shapes(&frame) >= 1);
1247 }
1248
1249 #[test]
1250 fn paint_square_emits_shape() {
1251 let frame = render_avatar(Avatar::with_initials(lit!("JD")).shape(AvatarShape::Square));
1252 assert!(count_shapes(&frame) >= 1);
1253 }
1254
1255 #[test]
1256 fn paint_image_uses_image_pipeline() {
1257 let icon = rgba_solid(8, [50, 100, 200, 255]);
1258 let frame = render_avatar(Avatar::with_image(&icon).alt(lit!("avatar")));
1259 assert!(
1260 !frame.images.is_empty(),
1261 "image avatar should render an image"
1262 );
1263 }
1264
1265 #[test]
1266 fn auto_contrast_dark_bg_chooses_white() {
1267 let dark = Color::from_rgb(0.05, 0.05, 0.05);
1268 let fg = auto_contrast_text(dark);
1269 assert!(fg.r() > 0.9 && fg.g() > 0.9 && fg.b() > 0.9);
1270 }
1271
1272 #[test]
1273 fn auto_contrast_light_bg_chooses_dark() {
1274 let light = Color::from_rgb(0.95, 0.95, 0.95);
1275 let fg = auto_contrast_text(light);
1276 assert!(fg.r() < 0.3 && fg.g() < 0.3 && fg.b() < 0.3);
1277 }
1278
1279 #[test]
1282 fn accessibility_initials_default_role_is_label() {
1283 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1284 let id = tree.add(Avatar::with_initials(lit!("JD")));
1285 tree.layout(SizeProposal::exact(32.0, 32.0));
1286 let info = tree.accessibility_node(id);
1287 assert_eq!(info.role(), teksilo_core::accesskit::Role::Label);
1288 assert_eq!(info.name(), Some("JD"));
1289 }
1290
1291 #[test]
1292 fn accessibility_image_default_role_is_image() {
1293 let icon = rgba_solid(8, [10, 20, 30, 255]);
1294 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1295 let id = tree.add(Avatar::with_image(&icon).alt(lit!("Jane Doe")));
1296 tree.layout(SizeProposal::exact(32.0, 32.0));
1297 let info = tree.accessibility_node(id);
1298 assert_eq!(info.role(), teksilo_core::accesskit::Role::Image);
1299 assert_eq!(info.name(), Some("Jane Doe"));
1300 }
1301
1302 #[test]
1303 fn accessibility_clickable_becomes_button() {
1304 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1305 let id = tree.add(
1306 Avatar::with_initials(lit!("JD"))
1307 .label(lit!("Open user menu"))
1308 .on_activate_fn(|_ctx| {}),
1309 );
1310 tree.layout(SizeProposal::exact(32.0, 32.0));
1311 let info = tree.accessibility_node(id);
1312 assert_eq!(info.role(), teksilo_core::accesskit::Role::Button);
1313 assert!(
1314 info.actions()
1315 .contains(&teksilo_core::accesskit::Action::Click)
1316 );
1317 assert!(
1318 info.actions()
1319 .contains(&teksilo_core::accesskit::Action::Focus)
1320 );
1321 assert_eq!(info.name(), Some("Open user menu"));
1322 }
1323
1324 #[test]
1325 fn accessibility_a11y_hidden_does_not_set_role() {
1326 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1327 let id = tree.add(Avatar::with_initials(lit!("JD")).a11y_hidden());
1328 tree.layout(SizeProposal::exact(32.0, 32.0));
1329 let info = tree.accessibility_node(id);
1330 assert_eq!(info.name(), None);
1333 }
1334
1335 #[test]
1336 fn accessibility_label_overrides_initials() {
1337 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1338 let id = tree.add(Avatar::with_initials(lit!("JD")).label(lit!("Jane Doe (offline)")));
1339 tree.layout(SizeProposal::exact(32.0, 32.0));
1340 let info = tree.accessibility_node(id);
1341 assert_eq!(info.name(), Some("Jane Doe (offline)"));
1342 }
1343
1344 #[test]
1345 fn accessibility_presence_appears_in_description() {
1346 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1347 let id = tree.add(Avatar::with_initials(lit!("JD")).presence(AvatarPresence::Online));
1348 tree.layout(SizeProposal::exact(32.0, 32.0));
1349 assert_eq!(
1353 tree.accessibility_node(id).role(),
1354 teksilo_core::accesskit::Role::Label
1355 );
1356 }
1357
1358 #[test]
1361 fn image_visible_false_hides_image_child() {
1362 use teksilo_core::signal::Signal;
1363
1364 let icon = rgba_solid(8, [10, 20, 30, 255]);
1365 let visible = Signal::new(true);
1366 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1367 let id = tree.add(
1368 Avatar::with_image(&icon)
1369 .alt(lit!("Jane"))
1370 .fallback_initials(lit!("JD"))
1371 .image_visible(visible.clone()),
1372 );
1373 tree.layout(SizeProposal::exact(32.0, 32.0));
1374 assert!(!tree.render().images.is_empty());
1376
1377 visible.set(false);
1380 tree.layout(SizeProposal::exact(32.0, 32.0));
1381 let frame_after = tree.render();
1382 assert!(
1383 frame_after.images.is_empty(),
1384 "image should be hidden when image_visible == false"
1385 );
1386 assert!(tree.is_visible(id));
1388 }
1389
1390 fn glyph_colors(frame: &teksilo_canvas::RenderFrame) -> Vec<[f32; 4]> {
1395 frame.glyphs.iter().map(|g| g.color).collect()
1396 }
1397
1398 fn shape_colors(frame: &teksilo_canvas::RenderFrame) -> Vec<[f32; 4]> {
1399 frame.shapes.iter().map(|s| s.color).collect()
1400 }
1401
1402 fn approx_color_eq(a: [f32; 4], b: Color) -> bool {
1403 let target = b.to_array();
1404 a.iter()
1405 .zip(target.iter())
1406 .all(|(x, y)| (x - y).abs() < 0.02)
1407 }
1408
1409 #[test]
1410 fn foreground_override_sets_glyph_color() {
1411 let frame = render_avatar(
1415 Avatar::with_initials(lit!("JD")).foreground(Color::from_rgb(1.0, 0.0, 0.5)),
1416 );
1417 let target = Color::from_rgb(1.0, 0.0, 0.5);
1418 assert!(
1419 glyph_colors(&frame)
1420 .iter()
1421 .any(|c| approx_color_eq(*c, target)),
1422 "expected at least one glyph painted with the foreground override"
1423 );
1424 }
1425
1426 #[test]
1427 fn background_override_sets_bg_shape_color() {
1428 let frame = render_avatar(
1429 Avatar::with_initials(lit!("JD")).background(Color::from_rgb(0.1, 0.7, 0.2)),
1430 );
1431 let target = Color::from_rgb(0.1, 0.7, 0.2);
1432 assert!(
1433 shape_colors(&frame)
1434 .iter()
1435 .any(|c| approx_color_eq(*c, target)),
1436 "expected the bg override colour to appear on a Shape quad"
1437 );
1438 }
1439
1440 #[test]
1441 fn auto_contrast_uses_overridden_bg_for_initials_text() {
1442 let frame = render_avatar(
1445 Avatar::with_initials(lit!("JD")).background(Color::from_rgb(0.95, 0.95, 0.95)),
1446 );
1447 let glyphs = glyph_colors(&frame);
1448 assert!(
1449 !glyphs.is_empty(),
1450 "expected at least one initials glyph in the frame"
1451 );
1452 for g in &glyphs {
1453 assert!(
1455 g[0] < 0.3 && g[1] < 0.3 && g[2] < 0.3,
1456 "expected dark auto-contrast glyph against a light bg, got {:?}",
1457 g
1458 );
1459 }
1460 }
1461
1462 #[test]
1463 fn auto_contrast_uses_overridden_bg_against_dark() {
1464 let frame = render_avatar(
1465 Avatar::with_initials(lit!("JD")).background(Color::from_rgb(0.05, 0.05, 0.05)),
1466 );
1467 let glyphs = glyph_colors(&frame);
1468 assert!(!glyphs.is_empty());
1469 for g in &glyphs {
1470 assert!(
1471 g[0] > 0.9 && g[1] > 0.9 && g[2] > 0.9,
1472 "expected white auto-contrast glyph against a dark bg, got {:?}",
1473 g
1474 );
1475 }
1476 }
1477
1478 #[test]
1479 fn with_name_seed_drives_bg_palette_pick() {
1480 let a = render_avatar(Avatar::with_name(lit!("Jane Doe")));
1485 let b = render_avatar(Avatar::with_name(lit!("Jules Dupont")));
1486 let bg_a = shape_colors(&a)
1487 .into_iter()
1488 .next()
1489 .expect("first shape is the bg circle");
1490 let bg_b = shape_colors(&b)
1491 .into_iter()
1492 .next()
1493 .expect("first shape is the bg circle");
1494 assert_ne!(
1495 bg_a, bg_b,
1496 "Jane Doe and Jules Dupont share initials JD but must hash distinctly via their full names"
1497 );
1498 }
1499
1500 #[test]
1505 fn expanded_when_signal_reflects_in_a11y() {
1506 use teksilo_core::signal::Signal;
1507 let open = Signal::new(false);
1508 let mut tree = WidgetTree::new()
1509 .with_theme(teksilo_core::presets::intui::light())
1510 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1511 teksilo_canvas::MockTextBackend::new(),
1512 )));
1513 let id = tree.add(
1514 Avatar::with_initials(lit!("JD"))
1515 .label(lit!("Open user menu"))
1516 .has_popup(teksilo_core::accesskit::HasPopup::Menu)
1517 .expanded_when(open.clone())
1518 .on_activate_fn(|_ctx| {}),
1519 );
1520 tree.layout(SizeProposal::exact(32.0, 32.0));
1521 assert!(!tree.accessibility_node(id).is_expanded());
1523
1524 open.set(true);
1527 tree.layout(SizeProposal::exact(32.0, 32.0));
1528 assert!(tree.accessibility_node(id).is_expanded());
1529 }
1530
1531 #[test]
1532 fn has_popup_without_clickable_still_compiles() {
1533 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1538 let id = tree.add(
1539 Avatar::with_initials(lit!("JD")).has_popup(teksilo_core::accesskit::HasPopup::Menu),
1540 );
1541 tree.layout(SizeProposal::exact(32.0, 32.0));
1542 assert_eq!(
1544 tree.accessibility_node(id).role(),
1545 teksilo_core::accesskit::Role::Label
1546 );
1547 }
1548
1549 #[test]
1552 fn focus_ring_only_paints_when_focused() {
1553 let mut tree = WidgetTree::new()
1557 .with_theme(teksilo_core::presets::intui::light())
1558 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1559 teksilo_canvas::MockTextBackend::new(),
1560 )));
1561 let id = tree.add(
1562 Avatar::with_initials(lit!("JD"))
1563 .label(lit!("Open user menu"))
1564 .on_activate_fn(|_ctx| {}),
1565 );
1566 tree.layout(SizeProposal::exact(64.0, 64.0));
1567 let unfocused_shapes = tree.render().shapes.len();
1568
1569 tree.focus(id);
1573 tree.press_key(
1574 teksilo_core::event::Key::ArrowDown,
1575 teksilo_core::event::Modifiers::NONE,
1576 );
1577 tree.layout(SizeProposal::exact(64.0, 64.0));
1578 let focused_shapes = tree.render().shapes.len();
1579
1580 assert_eq!(
1581 focused_shapes,
1582 unfocused_shapes + 1,
1583 "focused avatar should emit one extra Shape (the focus ring stroke)"
1584 );
1585 }
1586
1587 #[test]
1588 fn focus_ring_uses_theme_focus_ring_color() {
1589 let mut tree = WidgetTree::new()
1590 .with_theme(teksilo_core::presets::intui::light())
1591 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1592 teksilo_canvas::MockTextBackend::new(),
1593 )));
1594 let id = tree.add(
1595 Avatar::with_initials(lit!("JD"))
1596 .label(lit!("Click"))
1597 .on_activate_fn(|_ctx| {}),
1598 );
1599 tree.layout(SizeProposal::exact(64.0, 64.0));
1600 tree.focus(id);
1602 tree.press_key(
1603 teksilo_core::event::Key::ArrowDown,
1604 teksilo_core::event::Modifiers::NONE,
1605 );
1606 tree.layout(SizeProposal::exact(64.0, 64.0));
1607 let frame = tree.render();
1608 let target = teksilo_core::presets::intui::light().colors.focus_ring;
1609 assert!(
1610 shape_colors(&frame)
1611 .iter()
1612 .any(|c| approx_color_eq(*c, target)),
1613 "expected at least one Shape painted with the theme's focus_ring colour"
1614 );
1615 }
1616
1617 #[test]
1618 fn non_clickable_avatar_has_no_focus_ring() {
1619 let mut tree = WidgetTree::new()
1623 .with_theme(teksilo_core::presets::intui::light())
1624 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1625 teksilo_canvas::MockTextBackend::new(),
1626 )));
1627 let id = tree.add(Avatar::with_initials(lit!("JD")));
1628 tree.layout(SizeProposal::exact(64.0, 64.0));
1629 let baseline = tree.render().shapes.len();
1630 tree.focus(id);
1634 tree.layout(SizeProposal::exact(64.0, 64.0));
1635 assert_eq!(
1636 tree.render().shapes.len(),
1637 baseline,
1638 "non-clickable avatar must never draw a focus ring"
1639 );
1640 }
1641
1642 #[test]
1643 fn image_avatar_announces_alt_on_parent() {
1644 let icon = rgba_solid(8, [10, 20, 30, 255]);
1647 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1648 let parent = tree.add(Avatar::with_image(&icon).alt(lit!("Jane")));
1649 tree.layout(SizeProposal::exact(32.0, 32.0));
1650 let info = tree.accessibility_node(parent);
1651 assert_eq!(info.role(), teksilo_core::accesskit::Role::Image);
1652 assert_eq!(info.name(), Some("Jane"));
1653 }
1654
1655 #[test]
1656 fn shape_change_after_image_does_not_panic() {
1657 let icon = rgba_solid(16, [10, 20, 30, 255]);
1662 let a = Avatar::with_image(&icon).alt(lit!("X"));
1663 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1664 let _ = tree.add(a.shape(AvatarShape::Square));
1665 tree.layout(SizeProposal::exact(32.0, 32.0));
1666 let _ = tree.render();
1667 }
1668
1669 #[test]
1672 fn name_updates_displayed_initials_on_signal_flip() {
1673 use std::cell::RefCell;
1674 use std::rc::Rc as StdRc;
1675 use teksilo_canvas::MockTextBackend;
1676 use teksilo_core::signal::Signal;
1677 let name = Signal::new(String::new()); let mut tree = WidgetTree::new()
1679 .with_theme(teksilo_core::presets::intui::light())
1680 .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1681 let id = tree.add(Avatar::with_initials(lit!("?")).name_signal(name.clone()));
1682 tree.layout(SizeProposal::exact(32.0, 32.0));
1683 assert_eq!(tree.accessibility_node(id).name(), Some("?"));
1685
1686 name.set("Jane Doe".to_string());
1687 tree.layout(SizeProposal::exact(32.0, 32.0));
1688 assert_eq!(tree.accessibility_node(id).name(), Some("JD"));
1690 }
1691
1692 #[test]
1693 fn image_swap_logged_out_to_logged_in() {
1694 use std::cell::RefCell;
1697 use std::rc::Rc as StdRc;
1698 use teksilo_canvas::MockTextBackend;
1699 use teksilo_core::signal::Signal;
1700 let icon = rgba_solid(8, [10, 20, 30, 255]);
1701 let image: Signal<Option<Rc<RasterIcon>>> = Signal::new(None);
1702 let mut tree = WidgetTree::new()
1703 .with_theme(teksilo_core::presets::intui::light())
1704 .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1705 let _id = tree.add(
1706 Avatar::with_initials(lit!("JD"))
1707 .alt(lit!("Jane"))
1708 .image_signal(image.clone()),
1709 );
1710 tree.layout(SizeProposal::exact(32.0, 32.0));
1711 assert!(
1713 tree.render().images.is_empty(),
1714 "logged-out avatar must not emit an image quad"
1715 );
1716
1717 image.set(Some(Rc::new(icon)));
1719 tree.layout(SizeProposal::exact(32.0, 32.0));
1720 assert!(
1721 !tree.render().images.is_empty(),
1722 "logged-in avatar must emit an image quad after the signal flips"
1723 );
1724
1725 image.set(None);
1727 tree.layout(SizeProposal::exact(32.0, 32.0));
1728 assert!(
1729 tree.render().images.is_empty(),
1730 "image quad must disappear when the source signal returns to None"
1731 );
1732 }
1733
1734 #[test]
1735 fn image_signal_wins_over_static_with_image() {
1736 use std::cell::RefCell;
1740 use std::rc::Rc as StdRc;
1741 use teksilo_canvas::MockTextBackend;
1742 use teksilo_core::signal::Signal;
1743 let icon = rgba_solid(8, [10, 20, 30, 255]);
1744 let image: Signal<Option<Rc<RasterIcon>>> = Signal::new(None);
1745 let mut tree = WidgetTree::new()
1746 .with_theme(teksilo_core::presets::intui::light())
1747 .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1748 let _id = tree.add(
1749 Avatar::with_image(&icon)
1750 .alt(lit!("anything"))
1751 .fallback_initials(lit!("XX"))
1752 .image_signal(image.clone()),
1753 );
1754 tree.layout(SizeProposal::exact(32.0, 32.0));
1755 assert!(tree.render().images.is_empty());
1757 }
1758
1759 #[test]
1760 fn alt_updates_a11y_name_on_image_avatar() {
1761 use std::cell::RefCell;
1762 use std::rc::Rc as StdRc;
1763 use teksilo_canvas::MockTextBackend;
1764 use teksilo_core::signal::Signal;
1765 let icon = rgba_solid(8, [10, 20, 30, 255]);
1766 let alt = Signal::new(Some("Jane Doe".to_string()));
1767 let mut tree = WidgetTree::new()
1768 .with_theme(teksilo_core::presets::intui::light())
1769 .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1770 let id = tree.add(Avatar::with_image(&icon).alt_signal(alt.clone()));
1771 tree.layout(SizeProposal::exact(32.0, 32.0));
1772 assert_eq!(tree.accessibility_node(id).name(), Some("Jane Doe"));
1773
1774 alt.set(Some("Jules Dupont".to_string()));
1775 tree.layout(SizeProposal::exact(32.0, 32.0));
1776 assert_eq!(tree.accessibility_node(id).name(), Some("Jules Dupont"));
1777 }
1778
1779 #[test]
1780 fn presence_swap_changes_dot_color_and_a11y_description() {
1781 use teksilo_core::signal::Signal;
1782 let presence: Signal<Option<AvatarPresence>> = Signal::new(Some(AvatarPresence::Online));
1783 let mut tree = WidgetTree::new()
1784 .with_theme(teksilo_core::presets::intui::light())
1785 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1786 teksilo_canvas::MockTextBackend::new(),
1787 )));
1788 let id = tree.add(Avatar::with_initials(lit!("JD")).presence_signal(presence.clone()));
1789 tree.layout(SizeProposal::exact(32.0, 32.0));
1790 let online_color = teksilo_core::presets::intui::light()
1791 .colors
1792 .status_success_fg;
1793 assert!(
1794 shape_colors(&tree.render())
1795 .iter()
1796 .any(|c| approx_color_eq(*c, online_color)),
1797 "Online presence should paint the success colour"
1798 );
1799 let _ = id;
1800
1801 presence.set(Some(AvatarPresence::Busy));
1803 tree.layout(SizeProposal::exact(32.0, 32.0));
1804 let busy_color = teksilo_core::presets::intui::light().colors.status_error_fg;
1805 assert!(
1806 shape_colors(&tree.render())
1807 .iter()
1808 .any(|c| approx_color_eq(*c, busy_color)),
1809 "Busy presence should paint the error colour"
1810 );
1811
1812 presence.set(None);
1814 tree.layout(SizeProposal::exact(32.0, 32.0));
1815 let frame = tree.render();
1816 assert!(
1818 !shape_colors(&frame)
1819 .iter()
1820 .any(|c| approx_color_eq(*c, online_color) || approx_color_eq(*c, busy_color)),
1821 "presence None must remove the dot from the frame"
1822 );
1823 }
1824
1825 #[test]
1828 fn tooltip_appears_on_hover() {
1829 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1830 let id = tree.add(Avatar::with_initials(lit!("JD")).tooltip(lit!("Tip")));
1831 tree.layout(SizeProposal::exact(300.0, 200.0));
1832 tree.pointer_move(tree.bounds(id).center());
1833 tree.advance_time(std::time::Duration::from_secs(1));
1834 assert_eq!(
1835 tree.active_overlays().len(),
1836 1,
1837 "tooltip should appear on hover"
1838 );
1839 assert!(tree.find_by_label("Tip").is_some());
1840 }
1841
1842 #[test]
1843 fn name_changes_hash_seed_so_palette_pick_can_change() {
1844 use std::cell::RefCell;
1848 use std::rc::Rc as StdRc;
1849 use teksilo_canvas::MockTextBackend;
1850 use teksilo_core::signal::Signal;
1851 let name = Signal::new("Jane Doe".to_string());
1852 let mut tree = WidgetTree::new()
1853 .with_theme(teksilo_core::presets::intui::light())
1854 .with_text_backend(StdRc::new(RefCell::new(MockTextBackend::new())));
1855 let _id = tree.add(Avatar::with_initials(lit!("?")).name_signal(name.clone()));
1856 tree.layout(SizeProposal::exact(32.0, 32.0));
1857 let bg_jd = shape_colors(&tree.render())
1858 .into_iter()
1859 .next()
1860 .expect("bg circle is the first Shape");
1861
1862 name.set("Jules Dupont".to_string());
1863 tree.layout(SizeProposal::exact(32.0, 32.0));
1864 let bg_jd2 = shape_colors(&tree.render()).into_iter().next().unwrap();
1865 assert_ne!(
1866 bg_jd, bg_jd2,
1867 "different bound names must hash to different palette buckets"
1868 );
1869 }
1870}