use std::cell::{OnceCell, RefCell};
use blinc_core::Color;
use blinc_layout::element::RenderProps;
use blinc_layout::prelude::*;
use blinc_layout::text::Text as TextElement;
use blinc_layout::tree::{LayoutNodeId, LayoutTree};
use blinc_theme::{ColorToken, RadiusToken, ThemeState};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AvatarSize {
ExtraSmall,
Small,
#[default]
Medium,
Large,
ExtraLarge,
}
impl AvatarSize {
pub fn pixels(&self) -> f32 {
match self {
AvatarSize::ExtraSmall => 24.0,
AvatarSize::Small => 32.0,
AvatarSize::Medium => 40.0,
AvatarSize::Large => 48.0,
AvatarSize::ExtraLarge => 64.0,
}
}
fn font_size(&self) -> f32 {
match self {
AvatarSize::ExtraSmall => 10.0,
AvatarSize::Small => 12.0,
AvatarSize::Medium => 14.0,
AvatarSize::Large => 18.0,
AvatarSize::ExtraLarge => 24.0,
}
}
fn status_size(&self) -> f32 {
match self {
AvatarSize::ExtraSmall => 6.0,
AvatarSize::Small => 8.0,
AvatarSize::Medium => 10.0,
AvatarSize::Large => 12.0,
AvatarSize::ExtraLarge => 14.0,
}
}
fn status_offset(&self) -> f32 {
match self {
AvatarSize::ExtraSmall => 0.0,
AvatarSize::Small => 0.0,
AvatarSize::Medium => 1.0,
AvatarSize::Large => 2.0,
AvatarSize::ExtraLarge => 3.0,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AvatarShape {
#[default]
Circle,
Square,
}
impl AvatarShape {
fn border_radius(&self, size: f32, theme: &ThemeState) -> f32 {
match self {
AvatarShape::Circle => size / 2.0,
AvatarShape::Square => theme.radius(RadiusToken::Md),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AvatarStatus {
Online,
Offline,
Away,
Busy,
}
impl AvatarStatus {
fn color(&self, theme: &ThemeState) -> Color {
match self {
AvatarStatus::Online => theme.color(ColorToken::Success),
AvatarStatus::Offline => theme.color(ColorToken::TextTertiary),
AvatarStatus::Away => theme.color(ColorToken::Warning),
AvatarStatus::Busy => theme.color(ColorToken::Error),
}
}
}
#[derive(Default)]
struct AvatarConfig {
src: Option<String>,
alt: Option<String>,
fallback: Option<String>,
size: AvatarSize,
shape: AvatarShape,
status: Option<AvatarStatus>,
fallback_bg: Option<Color>,
fallback_color: Option<Color>,
classes: Vec<String>,
user_id: Option<String>,
}
struct BuiltAvatar {
inner: Box<dyn ElementBuilder>,
}
impl BuiltAvatar {
fn from_config(config: AvatarConfig) -> Self {
let theme = ThemeState::get();
let size_px = config.size.pixels();
let radius = config.shape.border_radius(size_px, theme);
let (background, content) = if let Some(ref src) = config.src {
let image = img(src).size(size_px, size_px).cover().rounded(radius);
(None, AvatarContent::Image(image))
} else if let Some(ref fallback_text) = config.fallback {
let bg = config
.fallback_bg
.unwrap_or_else(|| theme.color(ColorToken::Surface));
let fg = config
.fallback_color
.unwrap_or_else(|| theme.color(ColorToken::TextPrimary));
let initials = text(fallback_text)
.size(config.size.font_size())
.weight(FontWeight::Medium)
.color(fg)
.no_wrap();
(Some(bg), AvatarContent::Initials(initials))
} else {
let bg = config
.fallback_bg
.unwrap_or_else(|| theme.color(ColorToken::Surface));
let fg = config
.fallback_color
.unwrap_or_else(|| theme.color(ColorToken::TextTertiary));
let placeholder = text("?")
.size(config.size.font_size())
.weight(FontWeight::Medium)
.color(fg)
.no_wrap();
(Some(bg), AvatarContent::Initials(placeholder))
};
let mut inner = div()
.class("cn-avatar")
.w(size_px)
.h(size_px)
.rounded(radius)
.overflow_clip()
.flex_row()
.items_center()
.justify_center();
if config.shape == AvatarShape::Square {
inner = inner.class("cn-avatar--square");
}
if let Some(bg) = background {
inner = inner.bg(bg);
}
match content {
AvatarContent::Image(image) => {
inner = inner.child(image);
}
AvatarContent::Initials(text_el) => {
inner = inner.child(text_el);
}
}
for c in &config.classes {
inner = inner.class(c);
}
if let Some(ref id) = config.user_id {
inner = inner.id(id);
}
let container = if let Some(status) = config.status {
let status_size = config.size.status_size();
let status_offset = config.size.status_offset();
let status_color = status.color(theme);
let border_color = theme.color(ColorToken::Background);
let status_indicator = div()
.w(status_size)
.h(status_size)
.rounded_full()
.bg(status_color)
.shadow_sm()
.absolute()
.bottom(status_offset)
.right(status_offset);
Box::new(
stack()
.w(size_px)
.h(size_px)
.overflow_visible()
.child(inner)
.child(status_indicator),
) as Box<dyn ElementBuilder>
} else {
Box::new(inner) as Box<dyn ElementBuilder>
};
Self { inner: container }
}
}
enum AvatarContent {
Image(Image),
Initials(TextElement),
}
pub struct Avatar {
inner: Box<dyn ElementBuilder>,
}
impl ElementBuilder for Avatar {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.inner.build(tree)
}
fn render_props(&self) -> RenderProps {
self.inner.render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.inner.children_builders()
}
fn element_classes(&self) -> &[String] {
self.inner.element_classes()
}
fn element_id(&self) -> Option<&str> {
self.inner.element_id()
}
}
pub struct AvatarBuilder {
config: RefCell<AvatarConfig>,
built: OnceCell<Avatar>,
}
impl AvatarBuilder {
pub fn new() -> Self {
Self {
config: RefCell::new(AvatarConfig::default()),
built: OnceCell::new(),
}
}
fn get_or_build(&self) -> &Avatar {
self.built.get_or_init(|| {
let config = self.config.take();
let built = BuiltAvatar::from_config(config);
Avatar { inner: built.inner }
})
}
pub fn src(self, src: impl Into<String>) -> Self {
self.config.borrow_mut().src = Some(src.into());
self
}
pub fn alt(self, alt: impl Into<String>) -> Self {
self.config.borrow_mut().alt = Some(alt.into());
self
}
pub fn fallback(self, text: impl Into<String>) -> Self {
self.config.borrow_mut().fallback = Some(text.into());
self
}
pub fn size(self, size: AvatarSize) -> Self {
self.config.borrow_mut().size = size;
self
}
pub fn shape(self, shape: AvatarShape) -> Self {
self.config.borrow_mut().shape = shape;
self
}
pub fn status(self, status: AvatarStatus) -> Self {
self.config.borrow_mut().status = Some(status);
self
}
pub fn fallback_bg(self, color: impl Into<Color>) -> Self {
self.config.borrow_mut().fallback_bg = Some(color.into());
self
}
pub fn fallback_color(self, color: impl Into<Color>) -> Self {
self.config.borrow_mut().fallback_color = Some(color.into());
self
}
pub fn class(self, name: impl Into<String>) -> Self {
self.config.borrow_mut().classes.push(name.into());
self
}
pub fn id(self, id: &str) -> Self {
self.config.borrow_mut().user_id = Some(id.to_string());
self
}
pub fn xs(self) -> Self {
self.size(AvatarSize::ExtraSmall)
}
pub fn sm(self) -> Self {
self.size(AvatarSize::Small)
}
pub fn md(self) -> Self {
self.size(AvatarSize::Medium)
}
pub fn lg(self) -> Self {
self.size(AvatarSize::Large)
}
pub fn xl(self) -> Self {
self.size(AvatarSize::ExtraLarge)
}
pub fn circle(self) -> Self {
self.shape(AvatarShape::Circle)
}
pub fn square(self) -> Self {
self.shape(AvatarShape::Square)
}
pub fn online(self) -> Self {
self.status(AvatarStatus::Online)
}
pub fn offline(self) -> Self {
self.status(AvatarStatus::Offline)
}
pub fn away(self) -> Self {
self.status(AvatarStatus::Away)
}
pub fn busy(self) -> Self {
self.status(AvatarStatus::Busy)
}
pub fn build_final(self) -> Avatar {
let config = self.config.into_inner();
let built = BuiltAvatar::from_config(config);
Avatar { inner: built.inner }
}
}
impl Default for AvatarBuilder {
fn default() -> Self {
Self::new()
}
}
impl ElementBuilder for AvatarBuilder {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.get_or_build().build(tree)
}
fn render_props(&self) -> RenderProps {
self.get_or_build().render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.get_or_build().children_builders()
}
fn element_classes(&self) -> &[String] {
self.get_or_build().element_classes()
}
fn element_id(&self) -> Option<&str> {
self.get_or_build().element_id()
}
}
pub fn avatar() -> AvatarBuilder {
AvatarBuilder::new()
}
pub fn avatar_group() -> AvatarGroupBuilder {
AvatarGroupBuilder::new()
}
struct AvatarGroupConfig {
avatars: Vec<Box<dyn ElementBuilder>>,
size: AvatarSize,
max: Option<usize>,
overlap: f32,
classes: Vec<String>,
user_id: Option<String>,
}
impl Default for AvatarGroupConfig {
fn default() -> Self {
Self {
avatars: Vec::new(),
size: AvatarSize::default(),
max: None,
overlap: 8.0,
classes: Vec::new(),
user_id: None,
}
}
}
struct BuiltAvatarGroup {
inner: Div,
}
impl BuiltAvatarGroup {
fn from_config(config: AvatarGroupConfig) -> Self {
let theme = ThemeState::get();
let size_px = config.size.pixels();
let overlap = config.overlap;
let overlap_units = -overlap / 4.0;
let mut container = div().flex_row().items_center();
let total = config.avatars.len();
let visible_count = config.max.unwrap_or(total).min(total);
let remaining = total.saturating_sub(visible_count);
let wrapper_size = size_px + 4.0;
let wrapper_radius = wrapper_size / 2.0;
for (i, avatar) in config.avatars.into_iter().take(visible_count).enumerate() {
let mut avatar_wrapper = div()
.w(wrapper_size)
.h(wrapper_size)
.rounded(wrapper_radius)
.border(2.0, theme.color(ColorToken::Background))
.flex_row()
.items_center()
.justify_center()
.child_box(avatar);
if i > 0 {
avatar_wrapper = avatar_wrapper.ml(overlap_units);
}
container = container.child(avatar_wrapper);
}
if remaining > 0 {
let remaining_indicator = div()
.w(wrapper_size)
.h(wrapper_size)
.rounded(wrapper_radius)
.bg(theme.color(ColorToken::Surface))
.border(2.0, theme.color(ColorToken::Background))
.flex_row()
.items_center()
.justify_center()
.ml(overlap_units)
.child(
text(format!("+{}", remaining))
.size(config.size.font_size())
.weight(FontWeight::Medium)
.color(theme.color(ColorToken::TextTertiary))
.no_wrap(),
);
container = container.child(remaining_indicator);
}
for c in &config.classes {
container = container.class(c);
}
if let Some(ref id) = config.user_id {
container = container.id(id);
}
Self { inner: container }
}
}
pub struct AvatarGroup {
inner: Div,
}
impl ElementBuilder for AvatarGroup {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.inner.build(tree)
}
fn render_props(&self) -> RenderProps {
self.inner.render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.inner.children_builders()
}
fn element_classes(&self) -> &[String] {
self.inner.element_classes()
}
fn element_id(&self) -> Option<&str> {
self.inner.element_id()
}
}
pub struct AvatarGroupBuilder {
config: RefCell<AvatarGroupConfig>,
built: OnceCell<AvatarGroup>,
}
impl AvatarGroupBuilder {
pub fn new() -> Self {
Self {
config: RefCell::new(AvatarGroupConfig::default()),
built: OnceCell::new(),
}
}
fn get_or_build(&self) -> &AvatarGroup {
self.built.get_or_init(|| {
let config = self.config.take();
let built = BuiltAvatarGroup::from_config(config);
AvatarGroup { inner: built.inner }
})
}
pub fn child(self, avatar: impl ElementBuilder + 'static) -> Self {
self.config.borrow_mut().avatars.push(Box::new(avatar));
self
}
pub fn size(self, size: AvatarSize) -> Self {
self.config.borrow_mut().size = size;
self
}
pub fn max(self, count: usize) -> Self {
self.config.borrow_mut().max = Some(count);
self
}
pub fn overlap(self, pixels: f32) -> Self {
self.config.borrow_mut().overlap = pixels;
self
}
pub fn class(self, name: impl Into<String>) -> Self {
self.config.borrow_mut().classes.push(name.into());
self
}
pub fn id(self, id: &str) -> Self {
self.config.borrow_mut().user_id = Some(id.to_string());
self
}
pub fn build_final(self) -> AvatarGroup {
let config = self.config.into_inner();
let built = BuiltAvatarGroup::from_config(config);
AvatarGroup { inner: built.inner }
}
}
impl Default for AvatarGroupBuilder {
fn default() -> Self {
Self::new()
}
}
impl ElementBuilder for AvatarGroupBuilder {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.get_or_build().build(tree)
}
fn render_props(&self) -> RenderProps {
self.get_or_build().render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.get_or_build().children_builders()
}
fn element_classes(&self) -> &[String] {
self.get_or_build().element_classes()
}
fn element_id(&self) -> Option<&str> {
self.get_or_build().element_id()
}
}
#[cfg(test)]
mod tests {
use super::*;
use blinc_theme::ThemeState;
fn init_theme() {
let _ = ThemeState::try_get().unwrap_or_else(|| {
ThemeState::init_default();
ThemeState::get()
});
}
#[test]
fn test_avatar_size_pixels() {
assert_eq!(AvatarSize::ExtraSmall.pixels(), 24.0);
assert_eq!(AvatarSize::Small.pixels(), 32.0);
assert_eq!(AvatarSize::Medium.pixels(), 40.0);
assert_eq!(AvatarSize::Large.pixels(), 48.0);
assert_eq!(AvatarSize::ExtraLarge.pixels(), 64.0);
}
#[test]
fn test_avatar_builder_config() {
init_theme();
let builder = avatar()
.fallback("JD")
.size(AvatarSize::Large)
.shape(AvatarShape::Square);
let config = builder.config.borrow();
assert_eq!(config.fallback, Some("JD".to_string()));
assert_eq!(config.size, AvatarSize::Large);
assert_eq!(config.shape, AvatarShape::Square);
}
#[test]
fn test_avatar_with_status() {
init_theme();
let builder = avatar().fallback("AB").status(AvatarStatus::Online);
let config = builder.config.borrow();
assert_eq!(config.status, Some(AvatarStatus::Online));
}
#[test]
fn test_avatar_convenience_methods() {
init_theme();
let builder = avatar().fallback("X").lg().square().busy();
let config = builder.config.borrow();
assert_eq!(config.size, AvatarSize::Large);
assert_eq!(config.shape, AvatarShape::Square);
assert_eq!(config.status, Some(AvatarStatus::Busy));
}
#[test]
fn test_avatar_group_config() {
init_theme();
let builder = avatar_group()
.child(avatar().fallback("A"))
.child(avatar().fallback("B"))
.max(3);
let config = builder.config.borrow();
assert_eq!(config.avatars.len(), 2);
assert_eq!(config.max, Some(3));
}
}