use alloc::string::String;
use alloc::vec::Vec;
use denise::{Point, Rect, Role, Size};
use denise_render::Canvas;
use denise_text::TextStyle;
use crate::widget::{PaintCtx, Widget};
use crate::widgets::image::{Fit, Image};
use crate::widgets::style::{Align, draw_aligned, interactive_pair};
const PALETTE: [Role; 6] = [
Role::Primary,
Role::Secondary,
Role::Accent,
Role::Info,
Role::Success,
Role::Error,
];
const DOT_INSET_PERCENT: i32 = 68;
const DOT_PERCENT: i32 = 26;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Presence {
Online,
Offline,
Busy,
}
impl Presence {
const fn role(self) -> Role {
match self {
Presence::Online => Role::Success,
Presence::Offline => Role::Base300,
Presence::Busy => Role::Warning,
}
}
}
#[derive(Clone, Debug)]
pub struct Avatar {
picture: Option<Image>,
initials: String,
role: Option<Role>,
radius: Option<i32>,
ring: Option<Role>,
presence: Option<Presence>,
style: TextStyle,
}
impl Avatar {
pub fn new(pixels: Vec<u32>, size: Size) -> Self {
Self {
picture: Some(Image::new(pixels, size).with_fit(Fit::Cover)),
initials: String::new(),
role: None,
radius: None,
ring: None,
presence: None,
style: TextStyle::built_in(16),
}
}
pub fn initials(name: &str) -> Self {
Self {
picture: None,
initials: initials_of(name),
role: None,
radius: None,
ring: None,
presence: None,
style: TextStyle::built_in(16),
}
}
pub fn with_initials(mut self, name: &str) -> Self {
self.initials = initials_of(name);
self
}
pub fn with_role(mut self, role: Role) -> Self {
self.role = Some(role);
self
}
pub fn with_corner_radius(mut self, radius: i32) -> Self {
self.radius = Some(radius.max(0));
self
}
pub fn with_ring(mut self, role: Role) -> Self {
self.ring = Some(role);
self
}
pub fn with_presence(mut self, presence: Presence) -> Self {
self.presence = Some(presence);
self
}
pub fn with_style(mut self, style: TextStyle) -> Self {
self.style = style;
self
}
#[inline]
pub fn initials_text(&self) -> &str {
&self.initials
}
pub fn set_picture(&mut self, pixels: Vec<u32>, size: Size) {
self.picture = Some(Image::new(pixels, size).with_fit(Fit::Cover));
}
pub fn clear_picture(&mut self) {
self.picture = None;
}
pub fn disc_role(&self) -> Role {
self.role.unwrap_or_else(|| role_for(&self.initials))
}
fn square(&self, bounds: Rect) -> (Rect, i32) {
let side = bounds.width.min(bounds.height).max(0);
let square = Rect::new(
bounds.x + (bounds.width - side) / 2,
bounds.y + (bounds.height - side) / 2,
side,
side,
);
let radius = self.radius.unwrap_or(side / 2).min(side / 2);
(square, radius)
}
}
pub fn initials_of(name: &str) -> String {
let mut words = name
.split(|c: char| c.is_whitespace() || c == ',' || c == '-' || c == '_' || c == '.')
.filter_map(|word| word.chars().find(|c| c.is_alphanumeric()));
let mut out = String::new();
let first = words.next();
let last = words.next_back();
for c in first.into_iter().chain(last) {
out.extend(c.to_uppercase());
}
out
}
fn dot_at(square: Rect) -> Option<(Point, i32)> {
let r = (square.width / 2).max(1);
let dot = (r * DOT_PERCENT / 100).max(1);
let inset = r * DOT_INSET_PERCENT / 100;
let centre = Point::new(square.x + r + inset, square.y + r + inset);
let extent = Rect::new(centre.x - dot, centre.y - dot, dot * 2, dot * 2);
(inset > 0 && square.contains_rect(&extent)).then_some((centre, dot))
}
fn role_for(initials: &str) -> Role {
let sum = initials.chars().enumerate().fold(0u32, |acc, (i, c)| {
acc.wrapping_add((c as u32).wrapping_mul(31 + i as u32))
});
PALETTE[(sum as usize) % PALETTE.len()]
}
impl<M: 'static> Widget<M> for Avatar {
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Canvas<'_>) {
let (square, radius) = self.square(ctx.bounds);
if square.is_empty() {
return;
}
let drew_picture = match &self.picture {
Some(picture) if picture.is_drawable() => {
picture.paint_at(square, radius, canvas);
true
}
_ => false,
};
if !drew_picture {
let (disc, content) = interactive_pair(ctx.theme, self.disc_role(), ctx.state);
canvas.fill_rounded_rect(square, radius, disc);
if !self.initials.is_empty() {
draw_aligned(
canvas,
ctx.text,
self.style,
square,
(Align::Center, Align::Center),
&self.initials,
content,
);
}
}
if let Some(role) = self.ring {
let (color, _) = interactive_pair(ctx.theme, role, ctx.state);
canvas.stroke_rounded_rect(square, radius, ctx.theme.metrics.border.max(1), color);
}
if let Some(presence) = self.presence
&& let Some((centre, dot)) = dot_at(square)
{
let (color, _) = interactive_pair(ctx.theme, presence.role(), ctx.state);
canvas.fill_circle(centre, dot, ctx.theme.color(Role::Base100));
canvas.fill_circle(centre, (dot - ctx.theme.metrics.border).max(1), color);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn initials_come_from_the_first_and_last_word() {
assert_eq!(initials_of("Ola Nordmann"), "ON");
assert_eq!(initials_of("Kari"), "K");
assert_eq!(initials_of("Ola Kristian Nordmann"), "ON");
assert_eq!(initials_of("Nordmann, Ola"), "NO");
assert_eq!(initials_of("anne-marie"), "AM");
}
#[test]
fn initials_are_uppercased_including_the_norwegian_letters() {
assert_eq!(initials_of("øystein åsen"), "ØÅ");
assert_eq!(initials_of("ærlig"), "Æ");
}
#[test]
fn names_that_are_not_names_still_produce_something_or_nothing() {
assert_eq!(initials_of(""), "");
assert_eq!(initials_of(" "), "");
assert_eq!(initials_of("!!! ???"), "", "no letters at all");
assert_eq!(initials_of("ola@example.com"), "OC", "split on the dot");
assert_eq!(initials_of("3M"), "3", "a digit is a usable initial");
assert_eq!(initials_of("🙂 Ola"), "O", "the emoji is skipped");
}
#[test]
fn the_same_name_always_gets_the_same_colour() {
for name in ["Ola Nordmann", "Kari", "øystein åsen", ""] {
let once = Avatar::initials(name).disc_role();
let again = Avatar::initials(name).disc_role();
assert_eq!(once, again, "{name}");
}
assert_eq!(
Avatar::initials("Ola Nordmann").disc_role(),
Avatar::initials("Olav Nilsen").disc_role()
);
}
#[test]
fn different_names_spread_across_the_palette() {
let names = [
"Ola Nordmann",
"Kari Traa",
"Per Hansen",
"Ida Berg",
"Nils Aas",
"Eva Lund",
"Tor Dahl",
"Siv Moen",
"Jon Vik",
"Ann Ruud",
"Leif Rud",
"Mia Sund",
];
let mut seen = Vec::new();
for name in names {
let role = Avatar::initials(name).disc_role();
if !seen.contains(&role) {
seen.push(role);
}
}
assert!(
seen.len() >= 4,
"twelve names landed on only {} of {} colours",
seen.len(),
PALETTE.len()
);
}
#[test]
fn an_explicit_role_overrides_the_derivation() {
let a = Avatar::initials("Ola Nordmann").with_role(Role::Neutral);
assert_eq!(a.disc_role(), Role::Neutral);
}
#[test]
fn the_avatar_squares_itself_inside_any_rectangle() {
for bounds in [
Rect::new(0, 0, 100, 40),
Rect::new(10, 20, 40, 100),
Rect::new(-5, -5, 60, 60),
Rect::new(0, 0, 1, 1),
Rect::new(0, 0, 0, 50),
] {
let (square, radius) = Avatar::initials("ON").square(bounds);
assert_eq!(square.width, square.height, "{bounds:?} is not square");
assert_eq!(
square.width,
bounds.width.min(bounds.height).max(0),
"{bounds:?}"
);
assert!(bounds.contains_rect(&square), "{bounds:?}: escaped");
assert!(radius <= square.width / 2, "{bounds:?}: radius too large");
}
}
#[test]
fn a_circle_is_the_full_radius_and_never_more() {
let bounds = Rect::new(0, 0, 40, 40);
let (_, circle) = Avatar::initials("ON").square(bounds);
assert_eq!(circle, 20);
let (_, asked) = Avatar::initials("ON")
.with_corner_radius(9_999)
.square(bounds);
assert_eq!(asked, 20, "a huge radius is still just a circle");
let (_, rounded) = Avatar::initials("ON").with_corner_radius(6).square(bounds);
assert_eq!(rounded, 6);
}
#[test]
fn the_presence_dot_never_leaves_the_avatar() {
for side in 1..400 {
let square = Rect::new(10, 20, side, side);
let Some((centre, dot)) = dot_at(square) else {
assert!(side < 4, "side {side} refused a dot it had room for");
continue;
};
let box_of_dot = Rect::new(centre.x - dot, centre.y - dot, dot * 2, dot * 2);
assert!(
square.contains_rect(&box_of_dot),
"side {side}: the dot {box_of_dot:?} left the avatar {square:?}"
);
assert!(
centre.x > square.x + side / 2,
"side {side}: the dot is not at the lower right"
);
}
assert!(
dot_at(Rect::new(0, 0, 24, 24)).is_some(),
"an ordinary size"
);
}
#[test]
fn initials_are_readable_on_their_disc_in_every_theme() {
use crate::widget::VisualState;
use denise::Theme;
use denise::theme::{AA_LARGE, contrast_x100};
for theme in Theme::BUILT_IN {
for role in PALETTE {
for state in [VisualState::NONE, VisualState::DISABLED] {
let (disc, content) = interactive_pair(&theme, role, state);
let ratio = contrast_x100(disc, content);
assert!(
ratio >= AA_LARGE,
"{} {role:?} {state:?}: initials are {ratio}, floor is {AA_LARGE}",
theme.name
);
}
}
}
}
}