1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT
//! Avatar widget — a circular or square user avatar/image placeholder with initials fallback.
//!
//! The Avatar widget displays a colored circle (or rounded square) with centered
//! initials text, commonly used for user profile pictures, contact avatars, and
//! identity placeholders in modern UI design.
use crate::compat::Vec;
use crate::core::{Color, Font, HorizontalAlignment, Point, Rect};
use crate::event::{key_codes, Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::GenericSignal;
use crate::widget::capability::coercion::{expect_string, expect_u32};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::{dimensions, ControlMetrics};
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
/// The inset on each side that bounds an avatar's initials.
///
/// Fixed rather than proportional: the budget is "the disc less a margin", and a proportional
/// margin on a 96 px avatar would take a fifth of the disc away from two letters. The inset is
/// capped at a quarter of the disc in `draw` so a tiny avatar still has a non-empty budget.
const INITIALS_INSET: u32 = 4;
/// A status badge painted on an avatar's lower-right corner.
#[derive(Clone, Debug, PartialEq)]
pub struct AvatarBadge {
/// The badge caption (typically one character or a short count).
pub text: String,
/// The badge's fill colour.
pub color: Color,
}
impl AvatarBadge {
/// Creates a badge with a coloured fill.
pub fn new(text: impl Into<String>, color: Color) -> Self {
Self { text: text.into(), color }
}
}
/// Avatar widget for displaying user profile images or initials-based placeholders.
///
/// By default the avatar renders as a filled circle with the background color and
/// white bold initials centered inside. Setting `square` to `true` produces a
/// rounded rectangle instead.
pub struct Avatar {
base: BaseWidget,
/// Initials text displayed inside the avatar (typically 1-2 characters).
text: String,
/// Optional source path or URL of the avatar image.
///
/// Read by [`Avatar::paint_image_source`], which draws the decoded picture over the disc and
/// falls back to the initials when it cannot be loaded. An empty source is the initials-only
/// avatar the widget has always had.
image_source: String,
/// RGBA8 pixels for [`Self::image_source`], decoded once per source.
///
/// `None` is cached too, so a source that failed to load is not re-read every frame.
decoded_source: Option<alloc::sync::Arc<Vec<u8>>>,
/// The source string [`Self::decoded_source`] was produced for, so a change to the source
/// invalidates the pixels without the caller having to clear them.
decoded_source_key: Option<String>,
/// Background fill color of the avatar.
bg_color: Color,
/// Whether the fill was chosen by the caller through [`Avatar::set_bg_color`].
///
/// The caller's choice must win over the active theme, but the colour the
/// constructor seeds must not: without this flag a theme switch could never
/// reach an avatar that was never explicitly coloured, which is exactly the
/// hardcoded-chrome defect the rendering census reports.
bg_color_is_explicit: bool,
/// When `true`, renders as a rounded square instead of a circle.
square: bool,
/// Diameter (circle) or side length (square) in logical pixels.
size: u32,
/// Optional status badge at the lower-right corner.
badge: Option<AvatarBadge>,
/// Ring colour drawn around the disc; `None` derives it from the disc.
ring_color: Option<Color>,
/// Ring width in logical pixels; `0` draws no ring.
ring_width: u32,
/// Emitted when the avatar's disc is clicked.
pub avatar_clicked: GenericSignal,
}
impl Avatar {
/// Creates a new Avatar widget with the given geometry.
///
/// Defaults to a 40×40 circle with [`Color::PRIMARY`] background and empty initials.
/// If `geometry` has zero width or height, a default 40×40 size is used.
pub fn new(geometry: Rect) -> Self {
let sz = if geometry.width > 0 && geometry.height > 0 {
geometry.width.min(geometry.height)
} else {
40
};
Self {
base: BaseWidget::new(
WidgetKind::Avatar,
Rect::new(geometry.x, geometry.y, sz, sz),
"Avatar",
),
text: String::new(),
image_source: String::new(),
decoded_source: None,
decoded_source_key: None,
bg_color: Color::PRIMARY,
bg_color_is_explicit: false,
square: false,
size: sz,
badge: None,
ring_color: None,
ring_width: 0,
avatar_clicked: GenericSignal::new(),
}
}
/// Sets the initials text displayed inside the avatar.
///
/// A same-value write is a no-op: the initials are all it paints (WDGT-12).
pub fn set_text(&mut self, text: &str) {
if self.text == text {
return;
}
self.text = text.to_string();
self.base.request_redraw();
}
/// Returns the current initials text.
pub fn text(&self) -> &str {
&self.text
}
/// Returns the configured avatar image source, or an empty string when the
/// avatar falls back to its initials.
pub fn image_source(&self) -> &str {
&self.image_source
}
/// Sets the source path or URL of the avatar image.
///
/// An empty string clears the source and restores the initials fallback. Both the pixels and
/// the key they were produced for are dropped together: leaving the key would make the next
/// paint believe the cache belonged to the new source.
pub fn set_image_source(&mut self, source: &str) {
if self.image_source == source {
return;
}
self.image_source = source.to_string();
self.decoded_source = None;
self.decoded_source_key = None;
self.base.request_redraw();
}
/// Sets whether the avatar renders as a rounded square (instead of a circle).
pub fn set_square(&mut self, square: bool) {
if self.square == square {
return;
}
self.square = square;
self.base.request_redraw();
}
/// Returns `true` if the avatar renders as a rounded square.
pub fn is_square(&self) -> bool {
self.square
}
/// Sets the background fill color of the avatar.
///
/// An explicit colour wins over the active theme, so this overrides the
/// theme-resolved fill until it is set again.
pub fn set_bg_color(&mut self, color: Color) {
if self.bg_color_is_explicit && self.bg_color == color {
return;
}
self.bg_color = color;
self.bg_color_is_explicit = true;
self.base.request_redraw();
}
/// Returns the current background fill color.
pub fn bg_color(&self) -> Color {
self.bg_color
}
/// The colour the constructor seeds when the caller sets none.
///
/// Named rather than inlined so [`Draw`] can tell "the caller chose this colour"
/// (keep it, even across a theme switch) from "nothing chose it yet" (let the
/// theme decide).
fn default_bg() -> Color {
Color::PRIMARY
}
/// Sets the diameter (circle) or side length (square) of the avatar.
/// Updates the widget geometry to match.
pub fn set_size(&mut self, size: u32) {
self.size = size;
let rect = self.geometry();
self.set_geometry(Rect::new(rect.x, rect.y, size, size));
self.base.request_redraw();
}
/// Returns the current diameter or side length of the avatar.
pub fn size(&self) -> u32 {
self.size
}
/// The status badge, if one is installed.
pub fn badge(&self) -> Option<&AvatarBadge> {
self.badge.as_ref()
}
/// Installs or removes the status badge.
pub fn set_badge(&mut self, badge: Option<AvatarBadge>) {
if self.badge == badge {
return;
}
self.badge = badge;
self.base.request_redraw();
}
/// The ring colour, or `None` while it is derived from the disc.
pub fn ring_color(&self) -> Option<Color> {
self.ring_color
}
/// Sets the ring colour; `None` restores the derived contrast ring.
pub fn set_ring_color(&mut self, color: Option<Color>) {
if self.ring_color == color {
return;
}
self.ring_color = color;
self.base.request_redraw();
}
/// The ring width in logical pixels.
pub fn ring_width(&self) -> u32 {
self.ring_width
}
/// Sets the ring width; `0` removes the ring.
pub fn set_ring_width(&mut self, width: u32) {
if self.ring_width == width {
return;
}
self.ring_width = width;
self.base.request_redraw();
}
/// The disc actually painted, shared by draw and hit testing.
fn disc_rect(&self) -> Rect {
ControlMetrics::centered_square(self.geometry(), self.size_hint().width)
}
}
impl Widget for Avatar {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(dimensions::AVATAR_SIZE, dimensions::AVATAR_SIZE)
}
/// An avatar that emits `avatar_clicked` is an affordance, so it must be reachable without a
/// pointer. The kind's default answer is `false` for non-interaction kinds, which left the
/// control out of the tab order and therefore unable to receive the Enter/Space it handles.
fn is_focusable(&self) -> bool {
true
}
impl_draw_bridge!();
impl_widget_property_hooks!();
/// Resolves the avatar's published event to its signal.
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"avatar_clicked" => Some(EventSignalRef::unit("avatar_clicked", &self.avatar_clicked)),
_ => None,
}
}
}
/// `Avatar`'s property contract.
///
/// Read/write semantics are carried over unchanged from the centralised
/// `access_read_dialog.in.rs` / `access_write_dialog.in.rs` dispatch, so callers see
/// the same coercions and the same errors as before. `initials` reports the
/// widget's own placeholder text and `image_source` its configured image.
impl WidgetProperties for Avatar {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"initials" => Ok(CapabilityValue::String(self.text().to_string())),
"image_source" => Ok(CapabilityValue::String(self.image_source().to_string())),
"badge" => Ok(match self.badge() {
Some(badge) => CapabilityValue::String(format!(
"{}|{},{},{},{}",
badge.text, badge.color.r, badge.color.g, badge.color.b, badge.color.a
)),
None => CapabilityValue::Null,
}),
"ring_color" => Ok(match self.ring_color() {
Some(color) => CapabilityValue::Color(color),
None => CapabilityValue::Null,
}),
"ring_width" => Ok(CapabilityValue::UInt(u64::from(self.ring_width()))),
"square" => Ok(CapabilityValue::Bool(self.is_square())),
"size" => Ok(CapabilityValue::UInt(u64::from(self.size()))),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"initials" => {
self.set_text(&expect_string(value)?);
Ok(())
}
"image_source" => {
self.set_image_source(&expect_string(value)?);
Ok(())
}
"badge" => {
match value {
CapabilityValue::Null => self.set_badge(None),
other => {
let text = expect_string(other)?;
let (caption, color) = match text.split_once('|') {
Some((caption, spec)) => {
let mut parts = spec.split(',');
let r = parts.next().and_then(|v| v.parse().ok());
let g = parts.next().and_then(|v| v.parse().ok());
let b = parts.next().and_then(|v| v.parse().ok());
let a = parts.next().and_then(|v| v.parse().ok());
match (r, g, b, a) {
(Some(r), Some(g), Some(b), Some(a)) => {
(caption, Color::rgba(r, g, b, a))
}
_ => return Err(CapabilityAccessError::TypeMismatch),
}
}
None => (text.as_str(), Color::ERROR),
};
self.set_badge(Some(AvatarBadge::new(caption, color)));
}
}
Ok(())
}
"ring_color" => {
match value {
CapabilityValue::Null => self.set_ring_color(None),
CapabilityValue::Color(color) => self.set_ring_color(Some(color)),
_ => return Err(CapabilityAccessError::TypeMismatch),
}
Ok(())
}
"ring_width" => {
self.set_ring_width(expect_u32(value)?);
Ok(())
}
"square" => {
let square = crate::widget::capability::coercion::expect_bool(value)?;
self.set_square(square);
Ok(())
}
"size" => {
self.set_size(expect_u32(value)?);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"initials",
"image_source",
"badge",
"ring_color",
"ring_width",
"square",
"size",
BASE_PROPERTY_NAMES
]
}
}
impl Draw for Avatar {
fn draw(&mut self, context: &mut RenderContext) {
// ── The disc actually painted ──
//
// An avatar is a **fixed-size square** affordance centred in the area it was given.
// Deriving the disc from `rect`'s top-left corner drew it half-clipped at the left
// edge of any rectangle wider than the disc: the census hands this control a 240x120
// cell, so the old code drew `circle cx=60 cy=60 r=60`, running from x 0 to x 120 with
// the whole left half of the cell empty — a half-visible circle rather than a centred
// avatar. The height is `size_hint`'s, so the drawn shape and the reported size cannot
// disagree; `center_in` clamps *down* rather than up, so a control laid out smaller
// than its hint still paints inside the rectangle it was given.
let rect = self.disc_rect();
let size = rect.width.min(rect.height);
let center = Point::new(rect.x + (size as i32) / 2, rect.y + (size as i32) / 2);
// The disc is chrome: it resolves explicit style first, then the theme's resolved
// style for this control, and only then falls back to the constructor's brand
// colour. Without the theme step a light/dark switch would change nothing on
// screen, because the fill was previously hardcoded.
//
// The theme reads are separate manager locks, each taken and released inside
// `resolved_theme_style`, so none is held across the draw or across another
// accessor — the global manager's mutex is not re-entrant.
//
// The precedence itself lives in `disc_fill`, because the corner squares that mask a
// square picture over a circular avatar have to be painted in exactly this colour -- two
// copies would let a mask square differ from the disc it continues.
let disc_color = self.disc_fill();
// Draw the avatar shape (circle or rounded square)
if self.square {
let corner_radius = size / 4;
context.fill_rounded_rect(rect, corner_radius, disc_color);
} else {
context.fill_circle(center, size / 2, disc_color);
}
// Ring: a stroke just inside the disc's edge so it cannot overhang the control.
if self.ring_width > 0 && size > self.ring_width * 2 {
let ring = self.ring_color.unwrap_or_else(|| disc_color.contrast_color());
context.draw_circle_stroke(
center,
size / 2 - self.ring_width / 2,
ring,
self.ring_width,
);
}
// ── The picture, when the caller supplied one ──
//
// `image_source` was carried as state and published as a property, and the widget's own
// field comment recorded the gap honestly: "the widget has no image pipeline yet, so this is
// carried as state ... the initials remain the rendered fallback until a loader is wired
// in." The pipeline exists (`crate::image::decoder`), so the loader is wired in here.
//
// The picture is drawn over the disc rather than instead of it, so the disc is what shows
// through a picture with transparency -- and so a source that fails to load still leaves a
// shaped, coloured avatar rather than a hole. The initials are skipped when a picture is
// actually painted, because two identities on one disc read as neither.
let painted = self.paint_image_source(context, rect, center, size, disc_color);
// Draw centered initials text
if !painted && !self.text.is_empty() {
// Use a bold font sized relative to the avatar size
let font_size = (size as f32 * 0.45).max(8.0);
let font = Font::bold("Arial", font_size);
// The initials are bounded to the disc less a fixed inset on each side, so a long
// "placeholder" (a file name, a mis-set property) is ellipsised inside the shape
// instead of running out of the circle. `draw_text_fitted_cow` is the shared
// fitting primitive: it measures, truncates by cluster and centres inside the box,
// which is why this is one call rather than a measure-and-place pair. For a
// two-letter initial the text fits, so the pixels are the previous centred draw.
let inset = INITIALS_INSET.min(size / 4);
let budget = size.saturating_sub(inset * 2);
let line = context.text_line(rect, &font);
let bounds = Rect::new(
rect.x + ((rect.width as i32 - budget as i32) / 2).max(0),
line.y,
budget,
line.height,
);
context.draw_text_fitted_cow(
bounds,
&self.text,
&font,
disc_color.contrast_color(),
HorizontalAlignment::Center,
);
}
// ── Status badge ──
if let Some(badge) = &self.badge {
let radius = (size / 6).clamp(6, 12);
let badge_center = Point::new(
rect.x + rect.width as i32 - radius as i32,
rect.y + rect.height as i32 - radius as i32,
);
// A matching surface-coloured halo separates the badge from the picture behind it.
context.fill_circle(badge_center, radius + 1, disc_color);
context.fill_circle(badge_center, radius, badge.color);
if !badge.text.is_empty() {
let badge_font = Font::bold("Arial", (radius as f32 * 0.9).max(7.0));
let line = context.text_line(
Rect::new(
badge_center.x - radius as i32,
badge_center.y - radius as i32,
radius * 2,
radius * 2,
),
&badge_font,
);
context.draw_text_fitted_cow(
line,
badge.text.as_str(),
&badge_font,
badge.color.contrast_color(),
HorizontalAlignment::Center,
);
}
}
// ── Focus ring ──
//
// The keyboard path is only discoverable if focus is visible. Same predicate and same
// geometry helper as the crate's other controls (`BaseWidget::draws_focus_ring` records
// the *reason* focus arrived, so a pointer press focuses without painting a ring), and
// shaped like the disc itself so the ring reads as belonging to this control.
if self.base.draws_focus_ring() {
let radius = if self.square { size / 4 } else { size / 2 };
let ring = crate::widget::metrics::FocusRing::for_control(rect, radius);
if ring.is_drawable() {
let color = crate::widget::metrics::focus_ring_color(disc_color);
if self.square {
context.draw_rounded_rect_stroke(
ring.rect,
ring.radius,
color,
crate::widget::metrics::FOCUS_RING_WIDTH,
);
} else {
let ring_size = ring.rect.width.min(ring.rect.height);
context.draw_circle_stroke(
Point::new(
ring.rect.x + ring_size as i32 / 2,
ring.rect.y + ring_size as i32 / 2,
),
ring_size / 2,
color,
crate::widget::metrics::FOCUS_RING_WIDTH,
);
}
}
}
}
}
impl Avatar {
/// Draws the avatar's configured picture over its disc, and reports whether one was painted.
///
/// # What was dead, and what proves it is alive now
///
/// `image_source` was stored, published (getter, setter, schema row, round-trip test) and read
/// by nothing: `draw` never looked at it, so setting a source changed no pixel. The field's own
/// comment said so -- "the widget has no image pipeline yet" -- which made it read as a known
/// gap rather than as a broken promise.
///
/// # Why the load is cached in the field's own cell
///
/// Decoding is expensive and `draw` runs once a frame, so reading the file on every paint would
/// spend the whole budget on I/O for a picture that has not changed. The cache is keyed on the
/// *source string*, so a caller who sets a new path gets a new picture and one who re-sets the
/// same path does not re-read it. A failure is cached as `None` for the same reason: a missing
/// file must not be stat-ed sixty times a second.
///
/// # Why a failure draws the initials rather than an error
///
/// An avatar is a placeholder affordance -- that is what the initials exist for. A path that
/// does not resolve is the caller's mistake, and the honest rendering is the fallback the widget
/// already has, not a broken-image glyph in a 40 px disc. On desktop the failure is logged so
/// the caller can find it.
fn paint_image_source(
&mut self,
context: &mut RenderContext,
rect: Rect,
center: Point,
size: u32,
disc_color: Color,
) -> bool {
if self.image_source.is_empty() || size == 0 {
return false;
}
let Some(pixels) = self.resolved_image_source() else {
return false;
};
// The picture is clipped to the avatar's own shape before it is drawn, so a square bitmap
// does not put corners outside a circular avatar. The clip is a rectangle because that is
// the primitive the renderer has; a rounded-square avatar's own radius is close enough to
// the rectangle that clipping to it would only remove pixels the radius already rounds off.
context.push_clip(rect.x, rect.y, rect.width, rect.height);
context.draw_image(rect.x, rect.y, rect.width, rect.height, &pixels);
context.pop_clip();
// A square clip still leaves the corners of a picture outside a *circle*, so those four
// corner squares are repainted in the disc's colour. Four rectangles rather than a mask,
// because the renderer has no per-pixel mask and the corners are cheap.
if !self.square {
let radius = (size / 2) as i32;
let corner = (size as f32 * 0.1464).ceil() as u32;
// The disc colour is the caller's snapshot of `disc_fill`, not a second resolution:
// resolving it here re-ran the whole theme lookup chain for a value `draw` already
// had, and the two could only ever differ if the theme changed mid-frame.
for (dx, dy) in [(0i32, 0i32), (1, 0), (0, 1), (1, 1)] {
let x = center.x - radius + dx * (size as i32 - corner as i32);
let y = center.y - radius + dy * (size as i32 - corner as i32);
context.fill_rect(Rect::new(x, y, corner, corner), disc_color);
}
}
true
}
/// The RGBA8 pixels for the current [`Self::image_source`], shared process-wide.
///
/// Returns `None` when there is no source, when the file cannot be read, or when the bytes do
/// not decode -- see [`Self::paint_image_source`] for why that is not an error to render.
///
/// # Two layers, for two different questions
///
/// `decoded_source_key` answers "has *this avatar* already resolved this source", and it is a
/// string compare -- the cheapest possible check, which matters because this runs on every frame.
/// [`crate::image::cache`] answers "has *anybody in this process* already decoded these bytes",
/// and it is what makes a list of avatars sharing one picture decode it once instead of once per
/// row. The first is the fast path; the second is the reason the fast path is not the only thing
/// there is.
///
/// What is *stored* is now the shared [`Arc<Vec<u8>>`](alloc::sync::Arc) rather than a `Vec` this
/// control owns, so the pixels are the cache's allocation and a hit here hands back the same one
/// an `image_view` of the same file would get.
fn resolved_image_source(&mut self) -> Option<alloc::sync::Arc<Vec<u8>>> {
#[cfg(all(feature = "image", not(alloc_frugal)))]
{
if self.decoded_source_key.as_deref() != Some(self.image_source.as_str()) {
// One entry point for "a file's RGBA8 pixels", shared by every control that wants
// one -- so an avatar and an image view of the same file cannot disagree about what
// the file looks like, and cannot decode it twice.
self.decoded_source = crate::image::cache::file_rgba8_or_none(&self.image_source);
// The key is set whether or not the decode succeeded, so a bad path is not re-read
// every frame -- an avatar that cannot load must not make every frame a disk read.
self.decoded_source_key = Some(self.image_source.clone());
}
self.decoded_source.clone()
}
#[cfg(not(all(feature = "image", not(alloc_frugal))))]
{
// Without the image feature there is no decoder to call, so there is nothing to paint.
// An empty source means the same thing, so one arm states both rather than each reader
// re-deriving it.
None
}
}
/// The colour the disc is painted in, for the corner squares that mask a square picture.
///
/// One derivation, read by the disc fill *and* by the mask: two copies of the precedence would
/// leave mask squares a different colour from the disc they are meant to continue.
fn disc_fill(&self) -> Color {
let window_background = crate::style::resolved_theme_style_shared("avatar")
.as_ref()
.and_then(|theme| theme.background_color);
let raised = crate::style::resolved_theme_style_shared("button")
.and_then(|button| button.background_color);
let disc_background = self
.base
.style()
.background_color
.filter(|colour| Some(*colour) != window_background)
.or(raised)
.or(window_background)
.unwrap_or_else(Self::default_bg);
if self.bg_color_is_explicit {
self.bg_color
} else {
disc_background
}
}
}
impl EventHandler for Avatar {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button: 1, .. } => {
let disc = self.disc_rect();
let center =
Point::new(disc.x + disc.width as i32 / 2, disc.y + disc.height as i32 / 2);
let dx = (pos.x - center.x) as i64;
let dy = (pos.y - center.y) as i64;
let radius = (disc.width.min(disc.height) / 2) as i64;
if dx * dx + dy * dy <= radius * radius {
self.avatar_clicked.emit();
}
}
// The keyboard peer of the disc click: Enter and Space are what a button responds to,
// and an avatar is a button-shaped affordance (it emits `avatar_clicked`). Without
// this arm the control was pointer-only even though the base routed keys to it.
Event::KeyPress { key, .. }
if matches!(*key, key_codes::ENTER | key_codes::LINE_FEED | key_codes::SPACE) =>
{
self.avatar_clicked.emit();
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Point;
use crate::widget::WidgetKind;
/// A temporary path no other test can collide with.
///
/// Every test in this binary shares one process and one temp directory, and
/// [`crate::image::cache`] is process-wide, so two tests that write the *same path* with
/// *different bytes* read each other's file. `process::id()` does not help — it is the same
/// for all of them — so the uniqueness has to come from a counter plus the clock. The
/// timestamp follows `src/pdf/tests.rs`'s fixture, and the counter covers two calls inside one
/// nanosecond tick.
///
/// The caller removes the file it is given; leaving them behind would accumulate in the
/// system temp directory across runs.
fn avatar_test_path(tag: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
let seq = NEXT.fetch_add(1, Ordering::Relaxed);
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut path = std::env::temp_dir();
path.push(format!("rw_avatar_{tag}_{}_{seq}", stamp));
path
}
#[test]
fn avatar_default_state() {
let avatar = Avatar::new(Rect::new(0, 0, 40, 40));
assert_eq!(avatar.text(), "");
assert!(!avatar.is_square());
assert_eq!(avatar.bg_color(), Color::PRIMARY);
assert_eq!(avatar.size(), 40);
assert_eq!(avatar.kind(), WidgetKind::Avatar);
}
#[test]
fn avatar_default_size_when_zero_geometry() {
let avatar = Avatar::new(Rect::new(10, 20, 0, 0));
assert_eq!(avatar.size(), 40);
assert_eq!(avatar.geometry().width, 40);
assert_eq!(avatar.geometry().height, 40);
}
/// Enter and Space are the keyboard peer of the disc click (MISC-03).
#[test]
fn the_keyboard_emits_the_click() {
use std::sync::{Arc, Mutex};
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
let hits = Arc::new(Mutex::new(0u32));
let sink = Arc::clone(&hits);
avatar.avatar_clicked.connect(move || *sink.lock().unwrap() += 1);
avatar.handle_event(&Event::key_press(crate::event::key_codes::ENTER, 0));
avatar.handle_event(&Event::key_press(crate::event::key_codes::SPACE, 0));
assert_eq!(*hits.lock().unwrap(), 2);
// An unrelated key is not a click.
avatar.handle_event(&Event::key_press(crate::event::key_codes::LEFT, 0));
assert_eq!(*hits.lock().unwrap(), 2);
assert!(
crate::widget::Widget::is_focusable(&avatar),
"the keyboard path needs reachability"
);
}
/// The shape and the size are part of the published contract (MISC-02).
#[test]
fn the_shape_and_size_round_trip_through_the_property_contract() {
use crate::widget::capability::types::CapabilityValue;
use crate::widget::capability::WidgetProperties;
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
assert_eq!(avatar.get("square").unwrap(), CapabilityValue::Bool(false));
avatar.set("square", CapabilityValue::Bool(true)).unwrap();
assert!(avatar.is_square());
avatar.set("size", CapabilityValue::UInt(64)).unwrap();
assert_eq!(avatar.size(), 64);
assert_eq!(avatar.geometry().width, 64);
}
#[test]
fn avatar_set_text() {
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
avatar.set_text("JD");
assert_eq!(avatar.text(), "JD");
}
#[test]
fn avatar_set_text_clears() {
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
avatar.set_text("AB");
avatar.set_text("");
assert_eq!(avatar.text(), "");
}
#[test]
fn avatar_set_bg_color() {
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
let custom = Color::rgb(255, 0, 0);
avatar.set_bg_color(custom);
assert_eq!(avatar.bg_color(), custom);
}
#[test]
fn avatar_set_square() {
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
assert!(!avatar.is_square());
avatar.set_square(true);
assert!(avatar.is_square());
avatar.set_square(false);
assert!(!avatar.is_square());
}
#[test]
fn avatar_set_size() {
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
avatar.set_size(64);
assert_eq!(avatar.size(), 64);
assert_eq!(avatar.geometry().width, 64);
assert_eq!(avatar.geometry().height, 64);
}
#[test]
fn avatar_set_size_preserves_position() {
let mut avatar = Avatar::new(Rect::new(10, 20, 40, 40));
avatar.set_size(56);
assert_eq!(avatar.geometry().x, 10);
assert_eq!(avatar.geometry().y, 20);
assert_eq!(avatar.geometry().width, 56);
assert_eq!(avatar.geometry().height, 56);
}
#[test]
fn avatar_widget_trait_geometry() {
let mut avatar = Avatar::new(Rect::new(5, 10, 48, 48));
assert_eq!(avatar.geometry(), Rect::new(5, 10, 48, 48));
avatar.set_geometry(Rect::new(0, 0, 64, 64));
assert_eq!(avatar.geometry(), Rect::new(0, 0, 64, 64));
}
#[test]
fn avatar_widget_trait_kind() {
let avatar = Avatar::new(Rect::new(0, 0, 40, 40));
assert_eq!(avatar.kind(), WidgetKind::Avatar);
}
#[test]
fn avatar_handle_event_no_panic() {
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
// EventHandler should not panic for any event type
avatar.handle_event(&Event::MousePress {
pos: Point::new(10, 10),
button: 1,
modifiers: 0,
});
avatar.handle_event(&Event::MouseRelease { pos: Point::new(10, 10), button: 1 });
avatar.handle_event(&Event::MouseMove { pos: Point::new(20, 20) });
avatar.handle_event(&Event::KeyPress { key: 0x41, modifiers: 0 });
avatar.handle_event(&Event::KeyRelease { key: 0x41, modifiers: 0 });
}
#[test]
fn avatar_disabled_blocks_events() {
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
avatar.set_enabled(false);
// Should not panic
avatar.handle_event(&Event::MousePress {
pos: Point::new(10, 10),
button: 1,
modifiers: 0,
});
avatar.handle_event(&Event::MouseRelease { pos: Point::new(10, 10), button: 1 });
}
#[test]
fn avatar_svg_output() {
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
avatar.set_text("MW");
let svg = crate::widget::svg::render_to_svg(&mut avatar);
assert!(svg.starts_with("<svg"), "SVG output should start with <svg, got: {svg:.80}");
}
#[test]
fn avatar_square_svg_output() {
let mut avatar = Avatar::new(Rect::new(0, 0, 48, 48));
avatar.set_text("AB");
avatar.set_square(true);
let svg = crate::widget::svg::render_to_svg(&mut avatar);
assert!(
svg.starts_with("<svg"),
"Square SVG output should start with <svg, got: {svg:.80}"
);
}
#[test]
fn avatar_different_sizes() {
let small = Avatar::new(Rect::new(0, 0, 24, 24));
assert_eq!(small.size(), 24);
let large = Avatar::new(Rect::new(0, 0, 96, 96));
assert_eq!(large.size(), 96);
}
#[test]
fn avatar_size_min_of_width_and_height() {
let avatar = Avatar::new(Rect::new(0, 0, 60, 40));
// size should be min(60, 40) = 40
assert_eq!(avatar.size(), 40);
assert_eq!(avatar.geometry().width, 40);
assert_eq!(avatar.geometry().height, 40);
}
// ── `image_source` loads and paints a picture ──
/// A real PNG in the avatar's cell paints, and the initials stop being drawn.
///
/// # What was dead, and what proves it is alive now
///
/// `image_source` was stored, published (getter, setter, schema row, round-trip test) and read
/// by nothing. The widget's own field comment recorded it: "the widget has no image pipeline
/// yet, so this is carried as state ... the initials remain the rendered fallback until a loader
/// is wired in". A round-trip test therefore passed while a source changed no pixel.
///
/// # Why a real file rather than injected pixels
///
/// The claim is that *a source path becomes a picture*, so the test writes the smallest real
/// PNG it can and points the widget at it. Injecting RGBA bytes would test the draw and skip the
/// loader, which is the half that did not exist.
#[cfg(all(feature = "image", not(alloc_frugal)))]
#[test]
fn a_real_image_source_is_loaded_and_painted_over_the_disc() {
use crate::widget::svg::render_to_svg;
// A 2x2 PNG with one fully-opaque red pixel and three transparent ones, written by hand so
// the fixture does not depend on an encoder being compiled in.
//
// # Why the filename is unique per call
//
// [`crate::image::cache`] is process-wide and keyed on content, and every test in this
// binary shares one process, so two tests that write the *same path* with *different
// bytes* observe each other: the decoder reads whatever the other test's `write` left.
// That made `an_unloadable_source_falls_back_to_the_initials` pass alone and fail under
// the full suite — the earlier attempt at this fix described a per-test suffix in this
// comment but kept the shared literal filename, so the doc and the code disagreed and the
// race stayed. [`avatar_test_path`] is what actually makes each call's I/O its own.
let path = avatar_test_path("source_real_image.png");
std::fs::write(&path, MINIMAL_PNG).expect("the fixture is writable");
let source = path.to_str().expect("a UTF-8 temp path").to_string();
let initials_only = {
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
avatar.set_text("MW");
render_to_svg(&mut avatar)
};
let with_picture = {
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
avatar.set_text("MW");
avatar.set_image_source(&source);
render_to_svg(&mut avatar)
};
assert_ne!(
initials_only, with_picture,
"a real source must change the rendering -- that was the dead state"
);
// The picture replaces the initials rather than being drawn behind them: two identities on
// one disc read as neither. `text_ink_boxes` (not `text_subpath_count`) because the latter
// counts *every* path's subpaths, and the disc's own shapes are paths too.
let picture_runs = crate::widget::svg::text_ink_boxes(&with_picture).len();
let initials_runs = crate::widget::svg::text_ink_boxes(&initials_only).len();
assert!(
initials_runs > 0,
"the fixture must draw initials without a source, or this proves nothing"
);
assert_eq!(
picture_runs, 0,
"a painted picture must suppress the initials fallback ({picture_runs} text runs)"
);
// And the picture itself is reported as an `<image>` element, which is what the SVG backend
// emits for a non-empty `draw_image` -- not as glyphs and not as a placeholder rectangle.
assert!(
with_picture.contains("<image"),
"the decoded pixels must reach the document as an embedded image"
);
let _ = std::fs::remove_file(&path);
}
/// A source that cannot be loaded falls back to the initials instead of blanking the avatar.
///
/// The alternative -- painting nothing, or an error glyph -- would leave a hole where an
/// identity belongs, and the initials are exactly the fallback this widget exists to provide.
#[cfg(all(feature = "image", not(alloc_frugal)))]
#[test]
fn an_unloadable_source_falls_back_to_the_initials() {
// Holds the crate-wide theme guard: this test renders twice and compares the two frames,
// and a concurrent test that switches the active theme would otherwise change the accent
// colour between them — which made the comparison fail with two perfectly correct pictures
// that differed only in the disc's fill (`rgba(33,150,243)` vs `rgba(100,181,246)`).
// Same reason and same precedent as the render tests in `widget::svg`.
let _theme_guard = crate::style::theme_test_guard();
use crate::widget::svg::{render_to_svg, text_ink_boxes};
let build = |source: &str| {
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
avatar.set_text("MW");
if !source.is_empty() {
avatar.set_image_source(source);
}
render_to_svg(&mut avatar)
};
let missing = build("/definitely/not/a/real/avatar-file.png");
let no_source = build("");
assert_eq!(
missing, no_source,
"an unloadable source renders exactly the initials fallback, not a hole"
);
assert!(!text_ink_boxes(&missing).is_empty(), "the initials are still drawn");
// A file that exists but is not an image is the same case, so a caller who points at a text
// file gets the fallback rather than a panic or a blank disc.
let not_an_image = avatar_test_path("not_an_image.txt");
std::fs::write(¬_an_image, b"this is not a picture").expect("writable");
let garbage = build(not_an_image.to_str().expect("UTF-8 temp path"));
assert_eq!(garbage, no_source, "undecodable bytes fall back too");
let _ = std::fs::remove_file(¬_an_image);
}
/// Re-setting the same source does not re-read the file, and changing it drops the cached pixels.
///
/// The cache is what keeps `draw` from doing I/O once a frame; the invalidation is what keeps a
/// new source from painting the old picture, which would be worse than no cache at all.
#[cfg(all(feature = "image", not(alloc_frugal)))]
#[test]
fn the_decoded_source_is_cached_and_invalidated_by_the_source() {
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
assert!(avatar.decoded_source.is_none(), "nothing is decoded before a source is set");
let _theme_guard = crate::style::theme_test_guard();
let path = avatar_test_path("cache_test.png");
std::fs::write(&path, MINIMAL_PNG).expect("the fixture is writable");
let source = path.to_str().expect("a UTF-8 temp path").to_string();
avatar.set_image_source(&source);
let first = avatar.resolved_image_source();
assert!(first.is_some(), "the fixture decodes");
assert_eq!(
avatar.decoded_source_key.as_deref(),
Some(source.as_str()),
"the cache records which source its pixels belong to"
);
let pixels = avatar.decoded_source.clone();
// A second resolve with the same key returns the same pixels without touching the file.
assert_eq!(avatar.resolved_image_source(), pixels);
// A new source drops both halves of the cache, so the next resolve cannot answer with the
// picture the old one produced.
avatar.set_image_source("/somewhere/else.png");
assert!(avatar.decoded_source.is_none(), "pixels are dropped with the source");
assert!(avatar.decoded_source_key.is_none(), "and so is the key they belong to");
let _ = std::fs::remove_file(&path);
}
#[test]
fn badge_ring_and_click_are_observable() {
use crate::event::{Event, EventHandler};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let mut avatar = Avatar::new(Rect::new(0, 0, 40, 40));
assert!(avatar.badge().is_none());
avatar.set_badge(Some(AvatarBadge::new("3", Color::ERROR)));
avatar.set_ring_color(Some(Color::PRIMARY));
avatar.set_ring_width(2);
assert_eq!(avatar.ring_width(), 2);
assert_eq!(avatar.ring_color(), Some(Color::PRIMARY));
let svg = crate::widget::svg::render_to_svg(&mut avatar);
assert!(svg.starts_with("<svg"));
let hit = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&hit);
avatar.avatar_clicked.connect(move || flag.store(true, Ordering::SeqCst));
avatar.handle_event(&Event::MousePress {
pos: Point::new(20, 20),
button: 1,
modifiers: 0,
});
assert!(hit.load(Ordering::SeqCst), "a press on the disc reports the click");
assert!(avatar.event_signal_dyn("avatar_clicked").is_some());
}
/// The smallest valid PNG: a 2x2 truecolour-with-alpha image whose first pixel is opaque red.
///
/// Written out as bytes rather than produced by an encoder so the fixture works wherever the
/// `image` feature is on, including builds without the encoder side.
#[cfg(all(feature = "image", not(alloc_frugal)))]
const MINIMAL_PNG: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR, 13 data bytes
0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, // width 2, height 2
0x08, 0x06, 0x00, 0x00, 0x00, 0x72, 0xb6, 0x0d, // 8-bit, colour type 6 (RGBA), CRCs
0x24, 0x00, 0x00, 0x00, 0x0f, 0x49, 0x44, 0x41, // IDAT, 15 data bytes
0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0x00, // zlib stream
0x44, 0x48, 0x00, 0x00, 0x1e, 0xf3, 0x01, 0xff, // ...
0x6a, 0x37, 0x5d, 0xad, 0x00, 0x00, 0x00, 0x00, // IDAT CRC
0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, // IEND
];
}