use egui::{
Color32, FontId, Rect, Response, Sense, Stroke, StrokeKind, TextureId, Ui, Vec2, Widget, vec2,
};
use super::alpha;
use crate::{Icon, palette_of};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AvatarSize {
Xs,
Sm,
#[default]
Md,
Lg,
Xl,
}
impl AvatarSize {
pub fn diameter(self) -> f32 {
match self {
Self::Xs => 20.0,
Self::Sm => 24.0,
Self::Md => 32.0,
Self::Lg => 40.0,
Self::Xl => 56.0,
}
}
fn font_size(self) -> f32 {
self.diameter() * 0.42
}
fn icon_size(self) -> f32 {
self.diameter() * 0.55
}
}
#[derive(Debug, Clone)]
enum AvatarContent {
Initials(String),
Icon(Icon),
Image(TextureId),
}
pub struct Avatar<'a> {
content: AvatarContent,
size: AvatarSize,
background: Option<Color32>,
status_color: Option<Color32>,
tooltip: Option<&'a str>,
}
impl<'a> Avatar<'a> {
pub fn initials(name: &str) -> Self {
Self {
content: AvatarContent::Initials(initials_of(name)),
size: AvatarSize::Md,
background: None,
status_color: None,
tooltip: None,
}
}
pub fn icon(icon: Icon) -> Self {
Self {
content: AvatarContent::Icon(icon),
size: AvatarSize::Md,
background: None,
status_color: None,
tooltip: None,
}
}
pub fn image(texture: TextureId) -> Self {
Self {
content: AvatarContent::Image(texture),
size: AvatarSize::Md,
background: None,
status_color: None,
tooltip: None,
}
}
pub fn size(mut self, size: AvatarSize) -> Self {
self.size = size;
self
}
pub fn background(mut self, color: Color32) -> Self {
self.background = Some(color);
self
}
pub fn status(mut self, color: Color32) -> Self {
self.status_color = Some(color);
self
}
pub fn tooltip(mut self, text: &'a str) -> Self {
self.tooltip = Some(text);
self
}
}
impl<'a> Widget for Avatar<'a> {
fn ui(self, ui: &mut Ui) -> Response {
let palette = palette_of(ui.ctx());
let d = self.size.diameter();
let (rect, response) = ui.allocate_exact_size(vec2(d, d), Sense::hover());
let center = rect.center();
let radius = d / 2.0;
let bg = self.background.unwrap_or_else(|| match &self.content {
AvatarContent::Initials(s) => background_for(s.as_str(), &palette),
AvatarContent::Icon(_) => alpha(palette.brand_default, 0.18),
AvatarContent::Image(_) => palette.bg_surface_alt,
});
ui.painter().circle_filled(center, radius, bg);
match &self.content {
AvatarContent::Initials(s) => {
let fg = readable_on(bg, &palette);
ui.painter().text(
center,
egui::Align2::CENTER_CENTER,
s.clone(),
FontId::new(self.size.font_size(), egui::FontFamily::Proportional),
fg,
);
}
AvatarContent::Icon(icon) => {
let icon_rect = Rect::from_center_size(center, Vec2::splat(self.size.icon_size()));
icon.paint(ui.painter(), icon_rect, palette.brand_default);
}
AvatarContent::Image(tex) => {
let img_rect = Rect::from_center_size(center, Vec2::splat(d));
ui.painter().add(egui::epaint::Shape::Mesh({
let mut mesh = egui::epaint::Mesh::with_texture(*tex);
mesh.add_rect_with_uv(
img_rect,
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
mesh.into()
}));
ui.painter().circle_stroke(
center,
radius,
Stroke::new(1.0, alpha(palette.text_primary, 0.04)),
);
}
}
ui.painter().circle_stroke(
center,
radius,
Stroke::new(1.0, alpha(palette.text_primary, 0.08)),
);
if let Some(color) = self.status_color {
let dot_r = (d * 0.18).max(3.0);
let offset = radius - dot_r * 0.7;
let dot_center = egui::pos2(center.x + offset, center.y + offset);
ui.painter()
.circle_filled(dot_center, dot_r + 1.5, palette.bg_surface);
ui.painter().circle_filled(dot_center, dot_r, color);
}
if let Some(tip) = self.tooltip {
return crate::components::tooltip(&response, tip);
}
let _ = StrokeKind::Inside;
response
}
}
pub struct AvatarGroup<'a> {
avatars: Vec<Avatar<'a>>,
max: Option<usize>,
size: AvatarSize,
}
impl<'a> Default for AvatarGroup<'a> {
fn default() -> Self {
Self::new()
}
}
impl<'a> AvatarGroup<'a> {
pub fn new() -> Self {
Self {
avatars: Vec::new(),
max: None,
size: AvatarSize::Md,
}
}
pub fn max_visible(mut self, max: usize) -> Self {
self.max = Some(max);
self
}
pub fn size(mut self, size: AvatarSize) -> Self {
self.size = size;
self
}
#[must_use]
pub fn push(mut self, avatar: Avatar<'a>) -> Self {
self.avatars.push(avatar);
self
}
pub fn show(self, ui: &mut Ui) -> Response {
let palette = palette_of(ui.ctx());
let d = self.size.diameter();
let overlap = d * 0.30;
let total = self.avatars.len();
let visible = self.max.unwrap_or(total).min(total);
let overflow = total.saturating_sub(visible);
let count = visible + usize::from(overflow > 0);
let width = if count == 0 {
0.0
} else {
d + (count as f32 - 1.0) * (d - overlap)
};
let (rect, response) = ui.allocate_exact_size(vec2(width, d), Sense::hover());
let mut x = rect.left();
for av in self.avatars.into_iter().take(visible) {
let slot = Rect::from_min_size(egui::pos2(x, rect.top()), Vec2::splat(d));
let mut child_ui =
ui.new_child(egui::UiBuilder::new().max_rect(slot).layout(*ui.layout()));
child_ui.add(av.size(self.size));
x += d - overlap;
}
if overflow > 0 {
let slot = Rect::from_min_size(egui::pos2(x, rect.top()), Vec2::splat(d));
let center = slot.center();
ui.painter()
.circle_filled(center, d / 2.0, palette.bg_surface_alt);
ui.painter()
.circle_stroke(center, d / 2.0, Stroke::new(1.0, palette.border_default));
ui.painter().text(
center,
egui::Align2::CENTER_CENTER,
format!("+{overflow}"),
FontId::new(self.size.font_size(), egui::FontFamily::Proportional),
palette.text_secondary,
);
}
response
}
}
fn initials_of(name: &str) -> String {
let mut out = String::new();
for word in name.split_whitespace().take(2) {
if let Some(c) = word.chars().next() {
out.push(c.to_ascii_uppercase());
}
}
if out.is_empty() {
out.push('?');
}
out
}
fn background_for(seed: &str, p: &crate::Palette) -> Color32 {
let h = seed.bytes().fold(0u32, |acc, b| {
acc.wrapping_mul(31).wrapping_add(u32::from(b))
});
let palette = [
alpha(p.brand_default, 0.22),
alpha(p.success, 0.22),
alpha(p.info, 0.22),
alpha(p.warning, 0.22),
alpha(p.error, 0.18),
alpha(p.text_secondary, 0.18),
];
palette[(h as usize) % palette.len()]
}
fn readable_on(bg: Color32, p: &crate::Palette) -> Color32 {
let [r, g, b, _] = bg.to_array();
let lum = 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
if lum > 0.5 {
p.text_primary
} else {
p.text_on_brand
}
}
fn srgb(v: u8) -> f32 {
let v = f32::from(v) / 255.0;
if v <= 0.039_28 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}