use teksilo_canvas::{Canvas, Paint, Point, Rect, Size, SizeProposal, StrokeStyle};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::color_prop::ColorProp;
use teksilo_core::signal::Signal;
use teksilo_core::styles::{
AvatarCorner, AvatarPresence, AvatarShape, AvatarSize, AvatarStyle, AvatarStyleConfig,
};
use teksilo_core::widget::{
LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
};
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::{Color, CornerRadius};
pub const AVATAR_SIZE_SMALL: f32 = 24.0;
pub const AVATAR_SIZE_MEDIUM: f32 = 32.0;
pub const AVATAR_SIZE_LARGE: f32 = 48.0;
pub const AVATAR_SIZE_X_LARGE: f32 = 64.0;
pub const AVATAR_BORDER_DEFAULT: f32 = 2.0;
pub const AVATAR_PRESENCE_DIAMETER_RATIO: f32 = 0.28;
pub const AVATAR_PRESENCE_DIAMETER_MIN: f32 = 8.0;
pub const AVATAR_PRESENCE_DIAMETER_MAX: f32 = 20.0;
pub const AVATAR_PRESENCE_OUTLINE_WIDTH: f32 = 1.5;
pub const AVATAR_PRESENCE_INSET: f32 = 0.0;
pub const AVATAR_FONT_RATIO_1CHAR: f32 = 0.45;
pub const AVATAR_FONT_RATIO_2CHAR: f32 = 0.40;
pub const AVATAR_ROUNDED_RADIUS_RATIO: f32 = 0.25;
pub fn avatar_pixel_size(size: AvatarSize) -> f32 {
match size {
AvatarSize::Small => AVATAR_SIZE_SMALL,
AvatarSize::Medium => AVATAR_SIZE_MEDIUM,
AvatarSize::Large => AVATAR_SIZE_LARGE,
AvatarSize::XLarge => AVATAR_SIZE_X_LARGE,
AvatarSize::Custom(px) => px.max(1.0),
}
}
pub fn fnv1a_64(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
pub fn hash_pick_palette_color(seed: &str, theme: &teksilo_core::Theme) -> Color {
let palette = &theme.colors.chart_palette;
if palette.is_empty() {
return Color::from_rgb(0.5, 0.5, 0.5);
}
let h = fnv1a_64(seed.as_bytes());
let idx = (h as usize) % palette.len();
palette[idx]
}
pub fn auto_contrast_text(bg: Color) -> Color {
if bg.relative_luminance() < 0.5 {
Color::WHITE
} else {
Color::from_rgb(0.121, 0.121, 0.121)
}
}
pub fn paint_focus_ring(
canvas: &mut Canvas,
bounds: Rect,
shape: AvatarShape,
rounded_radius_ratio: f32,
offset: f32,
width: f32,
color: Color,
) {
let outset = offset + width / 2.0;
let outer = Rect::new(
bounds.x - outset,
bounds.y - outset,
bounds.width + outset * 2.0,
bounds.height + outset * 2.0,
);
match shape {
AvatarShape::Circle => {
let radius = outer.width.min(outer.height) / 2.0;
let center = Point::new(outer.x + outer.width / 2.0, outer.y + outer.height / 2.0);
canvas.stroke_circle(
center,
radius,
Paint::from(color),
StrokeStyle::solid(width),
);
}
AvatarShape::RoundedSquare => {
let r = bounds.width.min(bounds.height) * rounded_radius_ratio + outset;
canvas.stroke_rounded_rect(
outer,
CornerRadius::uniform(r),
color,
StrokeStyle::solid(width),
);
}
AvatarShape::Square => {
canvas.stroke_rounded_rect(
outer,
CornerRadius::uniform(0.0),
color,
StrokeStyle::solid(width),
);
}
}
}
pub fn paint_border(
canvas: &mut Canvas,
bounds: Rect,
shape: AvatarShape,
rounded_radius_ratio: f32,
width: f32,
color: Color,
) {
let half = width / 2.0;
let inner = Rect::new(
bounds.x + half,
bounds.y + half,
(bounds.width - width).max(0.0),
(bounds.height - width).max(0.0),
);
match shape {
AvatarShape::Circle => {
let radius = inner.width.min(inner.height) / 2.0;
let center = Point::new(inner.x + inner.width / 2.0, inner.y + inner.height / 2.0);
canvas.stroke_circle(
center,
radius,
Paint::from(color),
StrokeStyle::solid(width),
);
}
AvatarShape::RoundedSquare => {
let r = inner.width.min(inner.height) * rounded_radius_ratio;
canvas.stroke_rounded_rect(
inner,
CornerRadius::uniform(r),
color,
StrokeStyle::solid(width),
);
}
AvatarShape::Square => {
canvas.stroke_rounded_rect(
inner,
CornerRadius::uniform(0.0),
color,
StrokeStyle::solid(width),
);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AvatarRecipe {
pub size_small: f32,
pub size_medium: f32,
pub size_large: f32,
pub size_x_large: f32,
pub border_default: f32,
pub presence_diameter_ratio: f32,
pub presence_diameter_min: f32,
pub presence_diameter_max: f32,
pub presence_outline_width: f32,
pub presence_inset: f32,
pub font_ratio_1char: f32,
pub font_ratio_2char: f32,
pub rounded_radius_ratio: f32,
}
impl Default for AvatarRecipe {
fn default() -> Self {
Self {
size_small: AVATAR_SIZE_SMALL,
size_medium: AVATAR_SIZE_MEDIUM,
size_large: AVATAR_SIZE_LARGE,
size_x_large: AVATAR_SIZE_X_LARGE,
border_default: AVATAR_BORDER_DEFAULT,
presence_diameter_ratio: AVATAR_PRESENCE_DIAMETER_RATIO,
presence_diameter_min: AVATAR_PRESENCE_DIAMETER_MIN,
presence_diameter_max: AVATAR_PRESENCE_DIAMETER_MAX,
presence_outline_width: AVATAR_PRESENCE_OUTLINE_WIDTH,
presence_inset: AVATAR_PRESENCE_INSET,
font_ratio_1char: AVATAR_FONT_RATIO_1CHAR,
font_ratio_2char: AVATAR_FONT_RATIO_2CHAR,
rounded_radius_ratio: AVATAR_ROUNDED_RADIUS_RATIO,
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RecipeAvatarStyle {
pub recipe: AvatarRecipe,
}
impl RecipeAvatarStyle {
pub fn new(recipe: AvatarRecipe) -> Self {
Self { recipe }
}
}
impl AvatarStyle for RecipeAvatarStyle {
fn make_body(&self, cfg: &AvatarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
ctx.add(AvatarChromeFrame {
child_id: None,
pending_child: Some(PendingChild::Id(cfg.content)),
shape: cfg.shape,
presence: cfg.presence.clone(),
presence_corner: cfg.presence_corner,
is_focused: cfg.is_focused.clone(),
background: cfg.background_override.clone(),
border_color: cfg.border_color_override.clone(),
border_width: cfg.border_width_override,
seed: cfg.seed.clone(),
recipe: self.recipe,
})
}
}
struct AvatarChromeFrame {
child_id: Option<WidgetId>,
pending_child: Option<PendingChild>,
shape: AvatarShape,
presence: Option<AvatarPresence>,
presence_corner: AvatarCorner,
is_focused: Signal<bool>,
background: Option<ColorProp>,
border_color: Option<ColorProp>,
border_width: Option<f32>,
seed: String,
recipe: AvatarRecipe,
}
impl std::fmt::Debug for AvatarChromeFrame {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AvatarChromeFrame")
.field("shape", &self.shape)
.finish()
}
}
impl Widget for AvatarChromeFrame {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
if let Some(pending) = self.pending_child.take() {
self.child_id = Some(match pending {
PendingChild::Id(id) => id,
PendingChild::Deferred(w) => ctx.add_boxed(w),
});
}
let id = ctx.self_id();
let registry = ctx.binding_registry();
self.is_focused
.bind_to(id, registry, BindingLevel::RepaintOnly);
self.child_id.into_iter().collect()
}
fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
Size::new(
proposal.width.unwrap_or(0.0),
proposal.height.unwrap_or(0.0),
)
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
let theme = ctx.theme;
let bg = match &self.background {
Some(prop) => prop.resolve(theme, ctx.effective_enabled),
None => hash_pick_palette_color(&self.seed, theme),
};
match self.shape {
AvatarShape::Circle => {
let radius = bounds.width.min(bounds.height) / 2.0;
let center = Point::new(
bounds.x + bounds.width / 2.0,
bounds.y + bounds.height / 2.0,
);
canvas.fill_circle(center, radius, bg);
}
AvatarShape::RoundedSquare => {
let r = bounds.width.min(bounds.height) * self.recipe.rounded_radius_ratio;
canvas.fill_rounded_rect(bounds, CornerRadius::uniform(r), bg);
}
AvatarShape::Square => {
canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), bg);
}
}
if let Some(width) = self.border_width
&& width > 0.0
{
let color = match &self.border_color {
Some(prop) => prop.resolve(theme, ctx.effective_enabled),
None => theme.colors.surface_main,
};
paint_border(
canvas,
bounds,
self.shape,
self.recipe.rounded_radius_ratio,
width,
color,
);
}
if self.is_focused.get() {
paint_focus_ring(
canvas,
bounds,
self.shape,
self.recipe.rounded_radius_ratio,
theme.shape.focus_ring_offset,
theme.shape.focus_ring_width,
theme.colors.focus_ring,
);
}
if let Some(presence) = &self.presence {
let color = presence.color(theme);
let dot_diameter =
(bounds.width.min(bounds.height) * self.recipe.presence_diameter_ratio).clamp(
self.recipe.presence_diameter_min,
self.recipe.presence_diameter_max,
);
let dot_radius = dot_diameter / 2.0;
let (xf, yf) = self.presence_corner.offset();
let cx = if xf < 0.0 {
bounds.x + dot_radius + self.recipe.presence_inset
} else {
bounds.x + bounds.width - dot_radius - self.recipe.presence_inset
};
let cy = if yf < 0.0 {
bounds.y + dot_radius + self.recipe.presence_inset
} else {
bounds.y + bounds.height - dot_radius - self.recipe.presence_inset
};
let center = Point::new(cx, cy);
let outline_radius = dot_radius + self.recipe.presence_outline_width;
canvas.fill_circle(center, outline_radius, theme.colors.surface_main);
canvas.fill_circle(center, dot_radius, color);
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_hidden();
}
fn children(&self) -> Vec<WidgetId> {
self.child_id.into_iter().collect()
}
}