chattyness/crates/chattyness-user-ui/src/components/avatar_canvas.rs

1008 lines
36 KiB
Rust

//! Individual avatar canvas component for per-user rendering.
//!
//! Each avatar gets its own canvas element positioned via CSS transforms.
//! This enables efficient updates: position changes only update CSS (no redraw),
//! while appearance changes (emotion, skin) redraw only that avatar's canvas.
use leptos::prelude::*;
use uuid::Uuid;
use chattyness_db::models::ChannelMemberWithAvatar;
use super::chat_types::{ActiveBubble, emotion_bubble_colors};
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
const BASE_TEXT_SCALE: f64 = 1.4;
/// Estimate bubble height based on actual text content.
/// Returns the total height including padding, tail, and gap.
fn estimate_bubble_height(text: &str, text_scale: f64) -> f64 {
let max_bubble_width = 200.0 * text_scale;
let padding = 8.0 * text_scale;
let line_height = 16.0 * text_scale;
let tail_size = 8.0 * text_scale;
let gap = 5.0 * text_scale;
// Estimate chars per line: roughly 6 pixels per char at default scale
let chars_per_line = ((max_bubble_width - padding * 2.0) / (6.0 * text_scale)).floor() as usize;
let chars_per_line = chars_per_line.max(10); // minimum 10 chars per line
let char_count = text.len();
// Simple heuristic: estimate lines based on character count
let estimated_lines = ((char_count as f64) / (chars_per_line as f64)).ceil() as usize;
let estimated_lines = estimated_lines.clamp(1, 4); // 1-4 lines max
let bubble_height = (estimated_lines as f64) * line_height + padding * 2.0;
bubble_height + tail_size + gap
}
/// Content bounds for a 3x3 avatar grid.
/// Tracks which rows/columns contain actual content for centering calculations.
struct ContentBounds {
min_col: usize,
max_col: usize,
min_row: usize,
max_row: usize,
}
impl ContentBounds {
/// Calculate content bounds from 4 layers (9 positions each).
fn from_layers(
skin: &[Option<String>; 9],
clothes: &[Option<String>; 9],
accessories: &[Option<String>; 9],
emotion: &[Option<String>; 9],
) -> Self {
let has_content_at = |pos: usize| -> bool {
skin[pos].is_some()
|| clothes[pos].is_some()
|| accessories[pos].is_some()
|| emotion[pos].is_some()
};
// Columns: 0 (left), 1 (middle), 2 (right)
let left_col = [0, 3, 6].iter().any(|&p| has_content_at(p));
let mid_col = [1, 4, 7].iter().any(|&p| has_content_at(p));
let right_col = [2, 5, 8].iter().any(|&p| has_content_at(p));
let min_col = if left_col {
0
} else if mid_col {
1
} else {
2
};
let max_col = if right_col {
2
} else if mid_col {
1
} else {
0
};
// Rows: 0 (top), 1 (middle), 2 (bottom)
let top_row = [0, 1, 2].iter().any(|&p| has_content_at(p));
let mid_row = [3, 4, 5].iter().any(|&p| has_content_at(p));
let bot_row = [6, 7, 8].iter().any(|&p| has_content_at(p));
let min_row = if top_row {
0
} else if mid_row {
1
} else {
2
};
let max_row = if bot_row {
2
} else if mid_row {
1
} else {
0
};
Self {
min_col,
max_col,
min_row,
max_row,
}
}
/// Content center column (0.0 to 2.0, grid center is 1.0).
fn center_col(&self) -> f64 {
(self.min_col + self.max_col) as f64 / 2.0
}
/// Content center row (0.0 to 2.0, grid center is 1.0).
fn center_row(&self) -> f64 {
(self.min_row + self.max_row) as f64 / 2.0
}
/// X offset from grid center to content center.
fn x_offset(&self, cell_size: f64) -> f64 {
(self.center_col() - 1.0) * cell_size
}
/// Y offset from grid center to content center.
fn y_offset(&self, cell_size: f64) -> f64 {
(self.center_row() - 1.0) * cell_size
}
/// Number of empty rows at the bottom (for name positioning).
fn empty_bottom_rows(&self) -> usize {
2 - self.max_row
}
/// Number of empty rows at the top (for bubble positioning).
fn empty_top_rows(&self) -> usize {
self.min_row
}
/// Width of actual content in pixels.
fn content_width(&self, cell_size: f64) -> f64 {
(self.max_col - self.min_col + 1) as f64 * cell_size
}
/// Height of actual content in pixels.
fn content_height(&self, cell_size: f64) -> f64 {
(self.max_row - self.min_row + 1) as f64 * cell_size
}
}
/// Computed boundaries for visual clamping in screen space.
#[derive(Clone, Copy)]
struct ScreenBoundaries {
/// Left edge of drawable area (= offset_x)
min_x: f64,
/// Right edge (= offset_x + scene_width * scale_x)
max_x: f64,
/// Top edge (= offset_y)
min_y: f64,
/// Bottom edge (= offset_y + scene_height * scale_y)
max_y: f64,
}
impl ScreenBoundaries {
fn from_transform(
scene_width: f64,
scene_height: f64,
scale_x: f64,
scale_y: f64,
offset_x: f64,
offset_y: f64,
) -> Self {
Self {
min_x: offset_x,
max_x: offset_x + (scene_width * scale_x),
min_y: offset_y,
max_y: offset_y + (scene_height * scale_y),
}
}
/// Clamp avatar center so visual bounds stay within screen boundaries.
fn clamp_avatar_center(
&self,
center_x: f64,
center_y: f64,
half_width: f64,
half_height: f64,
) -> (f64, f64) {
let clamped_x = center_x
.max(self.min_x + half_width)
.min(self.max_x - half_width);
let clamped_y = center_y
.max(self.min_y + half_height)
.min(self.max_y - half_height);
(clamped_x, clamped_y)
}
}
/// Position of speech bubble relative to avatar.
#[derive(Clone, Copy, PartialEq)]
enum BubblePosition {
/// Default: bubble above avatar
Above,
/// When near top edge: bubble below avatar
Below,
}
/// Determine bubble position based on available space above avatar.
fn determine_bubble_position(
avatar_screen_y: f64,
avatar_half_height: f64,
bubble_height: f64,
tail_size: f64,
gap: f64,
min_y: f64,
) -> BubblePosition {
let space_needed = bubble_height + tail_size + gap;
let avatar_top = avatar_screen_y - avatar_half_height;
let space_available = avatar_top - min_y;
if space_available < space_needed {
BubblePosition::Below
} else {
BubblePosition::Above
}
}
/// Get a unique key for a member (for Leptos For keying).
pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) {
(m.member.user_id, m.member.guest_session_id)
}
/// Individual avatar canvas component.
///
/// Renders a single avatar with:
/// - CSS transform for position (GPU-accelerated, no redraw on move)
/// - Canvas for avatar sprite (redraws only on appearance change)
/// - Optional speech bubble above the avatar
#[component]
pub fn AvatarCanvas(
/// The member data for this avatar (as a signal for reactive updates).
member: Signal<ChannelMemberWithAvatar>,
/// X scale factor for coordinate conversion.
scale_x: Signal<f64>,
/// Y scale factor for coordinate conversion.
scale_y: Signal<f64>,
/// X offset for coordinate conversion.
offset_x: Signal<f64>,
/// Y offset for coordinate conversion.
offset_y: Signal<f64>,
/// Size of the avatar in pixels.
prop_size: Signal<f64>,
/// Z-index for stacking order (higher = on top).
z_index: i32,
/// Active speech bubble for this user (if any).
active_bubble: Signal<Option<ActiveBubble>>,
/// Text size multiplier for display names, chat bubbles, and badges.
#[prop(default = 1.0.into())]
text_em_size: Signal<f64>,
/// Opacity for fade-out animation (0.0 to 1.0, default 1.0).
#[prop(default = 1.0)]
opacity: f64,
/// Scene width in scene coordinates (for boundary calculations).
#[prop(optional)]
scene_width: Option<Signal<f64>>,
/// Scene height in scene coordinates (for boundary calculations).
#[prop(optional)]
scene_height: Option<Signal<f64>>,
) -> impl IntoView {
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
// Disable pointer-events when fading (opacity < 1.0) so fading avatars aren't clickable
let pointer_events = if opacity < 1.0 { "none" } else { "auto" };
// Reactive style for CSS positioning (GPU-accelerated transforms)
// This closure re-runs when position, scale, offset, or prop_size changes
let style = move || {
let m = member.get();
let ps = prop_size.get();
let sx = scale_x.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
let te = text_em_size.get();
let bubble = active_bubble.get();
// Calculate content bounds for centering on actual content
let content_bounds = ContentBounds::from_layers(
&m.avatar.skin_layer,
&m.avatar.clothes_layer,
&m.avatar.accessories_layer,
&m.avatar.emotion_layer,
);
// Get offsets from grid center to content center
let x_content_offset = content_bounds.x_offset(ps);
let y_content_offset = content_bounds.y_offset(ps);
// Avatar is a 3x3 grid of props, each prop is prop_size
let avatar_size = ps * 3.0;
// Get scene dimensions (use large defaults if not provided)
let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0);
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0);
// Compute screen boundaries for avatar clamping
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
// Calculate raw avatar screen position
let avatar_screen_x = m.member.position_x * sx + ox;
let avatar_screen_y = m.member.position_y * sy + oy;
// Clamp avatar center so visual bounds stay within screen boundaries
// Use actual content extent rather than full 3x3 grid
let content_half_width = content_bounds.content_width(ps) / 2.0;
let content_half_height = content_bounds.content_height(ps) / 2.0;
let (clamped_x, clamped_y) = boundaries.clamp_avatar_center(
avatar_screen_x,
avatar_screen_y,
content_half_width,
content_half_height,
);
// Calculate canvas position from clamped screen coordinates, adjusted for content bounds
let canvas_x = clamped_x - avatar_size / 2.0 - x_content_offset;
let canvas_y = clamped_y - avatar_size / 2.0 - y_content_offset;
// Fixed text dimensions (independent of prop_size/zoom)
let text_scale = te * BASE_TEXT_SCALE;
let fixed_bubble_height = if bubble.is_some() {
(4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale
} else {
0.0
};
let fixed_name_height = 20.0 * text_scale;
let fixed_text_width = 200.0 * text_scale;
// Determine bubble position based on available space above avatar
// This must match the logic in the Effect that draws the bubble
let bubble_position = if bubble.is_some() {
// Use clamped avatar screen position for bubble calculation
let avatar_half_height = avatar_size / 2.0 + y_content_offset;
// Calculate bubble height using actual content (includes tail + gap)
let estimated_bubble_height = bubble
.as_ref()
.map(|b| estimate_bubble_height(&b.message.content, text_scale))
.unwrap_or(0.0);
determine_bubble_position(
clamped_y,
avatar_half_height,
estimated_bubble_height,
0.0, // Already included in estimate_bubble_height
0.0, // Already included in estimate_bubble_height
boundaries.min_y,
)
} else {
BubblePosition::Above // Default when no bubble
};
// Canvas must fit avatar, text, AND bubble (positioned based on bubble location)
let canvas_width = avatar_size.max(fixed_text_width);
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
// Adjust position based on bubble position
// When bubble is above: offset canvas upward to make room at top
// When bubble is below: no upward offset, bubble goes below avatar
let adjusted_y = match bubble_position {
BubblePosition::Above => canvas_y - fixed_bubble_height,
BubblePosition::Below => canvas_y,
};
format!(
"position: absolute; \
left: 0; top: 0; \
transform: translate({}px, {}px); \
z-index: {}; \
pointer-events: {}; \
width: {}px; \
height: {}px; \
opacity: {};",
canvas_x - (canvas_width - avatar_size) / 2.0,
adjusted_y,
z_index,
pointer_events,
canvas_width,
canvas_height,
opacity
)
};
// Store references for the effect
#[cfg(feature = "hydrate")]
{
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use wasm_bindgen::JsCast;
use wasm_bindgen::closure::Closure;
// Image cache for this avatar (persists across re-renders)
let image_cache: Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>> =
Rc::new(RefCell::new(HashMap::new()));
// Redraw trigger - incremented when images load to cause Effect to re-run
let (redraw_trigger, set_redraw_trigger) = signal(0u32);
// Effect to draw the avatar when canvas is ready or appearance changes
Effect::new(move |_| {
// Subscribe to redraw trigger so this effect re-runs when images load
let _ = redraw_trigger.get();
// Get current values from signals
let m = member.get();
let ps = prop_size.get();
let te = text_em_size.get();
let bubble = active_bubble.get();
let Some(canvas) = canvas_ref.get() else {
return;
};
// Calculate dimensions (same as in style closure)
let content_bounds = ContentBounds::from_layers(
&m.avatar.skin_layer,
&m.avatar.clothes_layer,
&m.avatar.accessories_layer,
&m.avatar.emotion_layer,
);
let avatar_size = ps * 3.0;
let text_scale = te * BASE_TEXT_SCALE;
let fixed_bubble_height = if bubble.is_some() {
(4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale
} else {
0.0
};
let fixed_name_height = 20.0 * text_scale;
let fixed_text_width = 200.0 * text_scale;
// Determine bubble position early so we can position the avatar correctly
let y_content_offset = content_bounds.y_offset(ps);
let bubble_position = if bubble.is_some() {
let sx = scale_x.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
// Get scene dimensions (use large defaults if not provided)
let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0);
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0);
// Compute screen boundaries
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
// Calculate avatar's screen position
let avatar_screen_y = m.member.position_y * sy + oy;
let avatar_half_height = avatar_size / 2.0 + y_content_offset;
// Calculate bubble height using actual content (includes tail + gap)
let estimated_bubble_height = bubble
.as_ref()
.map(|b| estimate_bubble_height(&b.message.content, text_scale))
.unwrap_or(0.0);
determine_bubble_position(
avatar_screen_y,
avatar_half_height,
estimated_bubble_height,
0.0, // Already included in estimate_bubble_height
0.0, // Already included in estimate_bubble_height
boundaries.min_y,
)
} else {
BubblePosition::Above
};
let canvas_width = avatar_size.max(fixed_text_width);
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
// Set canvas resolution
canvas_el.set_width(canvas_width as u32);
canvas_el.set_height(canvas_height as u32);
let Ok(Some(ctx)) = canvas_el.get_context("2d") else {
return;
};
let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap();
// Clear canvas
ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height);
// Avatar center position within the canvas
// When bubble is above: avatar is below the bubble space
// When bubble is below: avatar is at the top, bubble space is below
let avatar_cx = canvas_width / 2.0;
let avatar_cy = match bubble_position {
BubblePosition::Above => fixed_bubble_height + avatar_size / 2.0,
BubblePosition::Below => avatar_size / 2.0,
};
// Helper to load and draw an image
// Images are cached; when loaded, triggers a redraw via signal
let draw_image = |path: &str,
cache: &Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>>,
ctx: &web_sys::CanvasRenderingContext2d,
x: f64,
y: f64,
size: f64| {
let normalized_path = normalize_asset_path(path);
let mut cache_borrow = cache.borrow_mut();
if let Some(img) = cache_borrow.get(&normalized_path) {
// Image is in cache - draw if loaded
if img.complete() && img.natural_width() > 0 {
let _ = ctx.draw_image_with_html_image_element_and_dw_and_dh(
img,
x - size / 2.0,
y - size / 2.0,
size,
size,
);
}
// If not complete, onload handler will trigger redraw
} else {
// Not in cache - create and start loading
let img = web_sys::HtmlImageElement::new().unwrap();
// Set onload handler to trigger redraw when image loads
let trigger = set_redraw_trigger;
let onload = Closure::once(Box::new(move || {
trigger.update(|v| *v += 1);
}) as Box<dyn FnOnce()>);
img.set_onload(Some(onload.as_ref().unchecked_ref()));
onload.forget();
img.set_src(&normalized_path);
cache_borrow.insert(normalized_path, img);
}
};
// Draw all 9 positions of the avatar grid (3x3 layout)
let cell_size = ps;
let grid_origin_x = avatar_cx - avatar_size / 2.0;
let grid_origin_y = avatar_cy - avatar_size / 2.0;
// Draw skin layer for all 9 positions
for pos in 0..9 {
if let Some(ref skin_path) = m.avatar.skin_layer[pos] {
let col = pos % 3;
let row = pos / 3;
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size;
draw_image(skin_path, &image_cache, &ctx, cell_cx, cell_cy, cell_size);
}
}
// Draw clothes layer for all 9 positions
for pos in 0..9 {
if let Some(ref clothes_path) = m.avatar.clothes_layer[pos] {
let col = pos % 3;
let row = pos / 3;
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size;
draw_image(
clothes_path,
&image_cache,
&ctx,
cell_cx,
cell_cy,
cell_size,
);
}
}
// Draw accessories layer for all 9 positions
for pos in 0..9 {
if let Some(ref accessories_path) = m.avatar.accessories_layer[pos] {
let col = pos % 3;
let row = pos / 3;
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size;
draw_image(
accessories_path,
&image_cache,
&ctx,
cell_cx,
cell_cy,
cell_size,
);
}
}
// Draw emotion overlay for all 9 positions
for pos in 0..9 {
if let Some(ref emotion_path) = m.avatar.emotion_layer[pos] {
let col = pos % 3;
let row = pos / 3;
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size;
draw_image(
emotion_path,
&image_cache,
&ctx,
cell_cx,
cell_cy,
cell_size,
);
}
}
// Draw emotion badge if non-neutral
let current_emotion = m.member.current_emotion;
if current_emotion > 0 {
let badge_size = 16.0 * text_scale;
let badge_x = avatar_cx + avatar_size / 2.0 - badge_size / 2.0;
let badge_y = avatar_cy - avatar_size / 2.0 - badge_size / 2.0;
ctx.begin_path();
let _ = ctx.arc(
badge_x,
badge_y,
badge_size / 2.0,
0.0,
std::f64::consts::PI * 2.0,
);
ctx.set_fill_style_str("#f59e0b");
ctx.fill();
ctx.set_fill_style_str("#000");
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * text_scale));
ctx.set_text_align("center");
ctx.set_text_baseline("middle");
let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y);
}
// Calculate content bounds for name positioning
let name_x = avatar_cx + content_bounds.x_offset(cell_size);
let empty_bottom_rows = content_bounds.empty_bottom_rows();
// Draw display name below avatar (with black outline for readability)
let display_name = &m.member.display_name;
ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale));
ctx.set_text_align("center");
ctx.set_text_baseline("alphabetic");
let name_y = avatar_cy + avatar_size / 2.0 - (empty_bottom_rows as f64 * cell_size)
+ 15.0 * text_scale;
// Black outline
ctx.set_stroke_style_str("#000");
ctx.set_line_width(3.0);
let _ = ctx.stroke_text(display_name, name_x, name_y);
// White fill
ctx.set_fill_style_str("#fff");
let _ = ctx.fill_text(display_name, name_x, name_y);
// Draw speech bubble if active
if let Some(ref b) = bubble {
let current_time = js_sys::Date::now() as i64;
if b.expires_at >= current_time {
let content_x_offset = content_bounds.x_offset(cell_size);
let content_top_adjustment = content_bounds.empty_top_rows() as f64 * cell_size;
// Get screen boundaries for horizontal clamping
let sx = scale_x.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0);
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0);
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
// Avatar top and bottom Y within the canvas
let avatar_top_y = avatar_cy - avatar_size / 2.0;
let avatar_bottom_y = avatar_cy + avatar_size / 2.0;
// Use the pre-calculated bubble_position from earlier
draw_bubble(
&ctx,
b,
avatar_cx,
avatar_top_y,
avatar_bottom_y,
content_x_offset,
content_top_adjustment,
te,
bubble_position,
Some(&boundaries),
);
}
}
});
}
// Compute data-member-id reactively
let data_member_id = move || {
let m = member.get();
m.member
.user_id
.map(|u| u.to_string())
.or_else(|| m.member.guest_session_id.map(|g| g.to_string()))
.unwrap_or_default()
};
view! {
<canvas
node_ref=canvas_ref
style=style
data-member-id=data_member_id
/>
}
}
/// Normalize an asset path to be absolute, prefixing with /static/ if needed.
#[cfg(feature = "hydrate")]
fn normalize_asset_path(path: &str) -> String {
if path.starts_with('/') {
path.to_string()
} else {
format!("/static/{}", path)
}
}
/// Draw a speech bubble relative to the avatar with boundary awareness.
///
/// # Arguments
/// * `ctx` - Canvas rendering context
/// * `bubble` - The active bubble data
/// * `center_x` - Avatar center X in canvas coordinates
/// * `top_y` - Avatar top edge Y in canvas coordinates
/// * `bottom_y` - Avatar bottom edge Y in canvas coordinates
/// * `content_x_offset` - X offset to center on content
/// * `content_top_adjustment` - Y adjustment for empty top rows
/// * `text_em_size` - Text size multiplier
/// * `position` - Whether to render above or below the avatar
/// * `boundaries` - Screen boundaries for horizontal clamping (optional)
#[cfg(feature = "hydrate")]
fn draw_bubble(
ctx: &web_sys::CanvasRenderingContext2d,
bubble: &ActiveBubble,
center_x: f64,
top_y: f64,
bottom_y: f64,
content_x_offset: f64,
content_top_adjustment: f64,
text_em_size: f64,
position: BubblePosition,
boundaries: Option<&ScreenBoundaries>,
) {
// Text scale independent of zoom - only affected by user's text_em_size setting
let text_scale = text_em_size * BASE_TEXT_SCALE;
let max_bubble_width = 200.0 * text_scale;
let padding = 8.0 * text_scale;
let font_size = 12.0 * text_scale;
let line_height = 16.0 * text_scale;
let tail_size = 8.0 * text_scale;
let border_radius = 8.0 * text_scale;
let gap = 5.0 * text_scale;
let (bg_color, border_color, text_color) = emotion_bubble_colors(&bubble.message.emotion);
// Use italic font for whispers
let font_style = if bubble.message.is_whisper {
"italic "
} else {
""
};
// Measure and wrap text
ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size));
let lines = wrap_text(
ctx,
&bubble.message.content,
max_bubble_width - padding * 2.0,
);
// Calculate bubble dimensions
let bubble_width = lines
.iter()
.map(|line| ctx.measure_text(line).map(|m| m.width()).unwrap_or(0.0))
.fold(0.0_f64, |a, b| a.max(b))
+ padding * 2.0;
let bubble_width = bubble_width.max(60.0 * text_scale);
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
// Center bubble horizontally on content (not grid center)
let content_center_x = center_x + content_x_offset;
// Calculate initial bubble X position (centered on content)
let mut bubble_x = content_center_x - bubble_width / 2.0;
// Clamp bubble horizontally to stay within drawable area
if let Some(bounds) = boundaries {
let bubble_left = bubble_x;
let bubble_right = bubble_x + bubble_width;
if bubble_left < bounds.min_x {
// Shift right to stay within left edge
bubble_x = bounds.min_x;
} else if bubble_right > bounds.max_x {
// Shift left to stay within right edge
bubble_x = bounds.max_x - bubble_width;
}
}
// Calculate tail center - point toward content center but stay within bubble bounds
let tail_center_x = content_center_x
.max(bubble_x + tail_size + border_radius)
.min(bubble_x + bubble_width - tail_size - border_radius);
// Calculate vertical position based on bubble position
let bubble_y = match position {
BubblePosition::Above => {
// Position vertically closer to content when top rows are empty
let adjusted_top_y = top_y + content_top_adjustment;
adjusted_top_y - bubble_height - tail_size - gap
}
BubblePosition::Below => {
// Position below avatar with gap for tail
bottom_y + tail_size + gap
}
};
// Draw bubble background
draw_rounded_rect(
ctx,
bubble_x,
bubble_y,
bubble_width,
bubble_height,
border_radius,
);
ctx.set_fill_style_str(bg_color);
ctx.fill();
ctx.set_stroke_style_str(border_color);
ctx.set_line_width(2.0);
ctx.stroke();
// Draw tail pointing to content center
ctx.begin_path();
match position {
BubblePosition::Above => {
// Tail points DOWN toward avatar
ctx.move_to(tail_center_x - tail_size, bubble_y + bubble_height);
ctx.line_to(tail_center_x, bubble_y + bubble_height + tail_size);
ctx.line_to(tail_center_x + tail_size, bubble_y + bubble_height);
}
BubblePosition::Below => {
// Tail points UP toward avatar
ctx.move_to(tail_center_x - tail_size, bubble_y);
ctx.line_to(tail_center_x, bubble_y - tail_size);
ctx.line_to(tail_center_x + tail_size, bubble_y);
}
}
ctx.close_path();
ctx.set_fill_style_str(bg_color);
ctx.fill();
ctx.set_stroke_style_str(border_color);
ctx.stroke();
// Draw text (re-set font in case canvas state changed)
ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size));
ctx.set_fill_style_str(text_color);
ctx.set_text_align("left");
ctx.set_text_baseline("top");
for (i, line) in lines.iter().enumerate() {
let _ = ctx.fill_text(
line,
bubble_x + padding,
bubble_y + padding + (i as f64) * line_height,
);
}
}
/// Wrap text to fit within max_width.
#[cfg(feature = "hydrate")]
fn wrap_text(ctx: &web_sys::CanvasRenderingContext2d, text: &str, max_width: f64) -> Vec<String> {
let words: Vec<&str> = text.split_whitespace().collect();
let mut lines = Vec::new();
let mut current_line = String::new();
for word in words {
let test_line = if current_line.is_empty() {
word.to_string()
} else {
format!("{} {}", current_line, word)
};
let width = ctx
.measure_text(&test_line)
.map(|m| m.width())
.unwrap_or(0.0);
if width > max_width && !current_line.is_empty() {
lines.push(current_line);
current_line = word.to_string();
} else {
current_line = test_line;
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
// Limit to 4 lines
if lines.len() > 4 {
lines.truncate(3);
if let Some(last) = lines.last_mut() {
last.push_str("...");
}
}
if lines.is_empty() {
lines.push(text.to_string());
}
lines
}
/// Test if a click at the given client coordinates hits a non-transparent pixel.
///
/// Returns true if the alpha channel at the clicked pixel is > 0.
/// This enables pixel-perfect hit detection on avatar canvases.
#[cfg(feature = "hydrate")]
pub fn hit_test_canvas(canvas: &web_sys::HtmlCanvasElement, client_x: f64, client_y: f64) -> bool {
use wasm_bindgen::JsCast;
// Get the canvas bounding rect to transform client coords to canvas coords
let rect = canvas.get_bounding_client_rect();
// Calculate click position relative to the canvas element
let relative_x = client_x - rect.left();
let relative_y = client_y - rect.top();
// Check if click is within canvas bounds
if relative_x < 0.0
|| relative_y < 0.0
|| relative_x >= rect.width()
|| relative_y >= rect.height()
{
return false;
}
// Transform to canvas pixel coordinates (accounting for CSS scaling)
let canvas_width = canvas.width() as f64;
let canvas_height = canvas.height() as f64;
// Avoid division by zero
if rect.width() == 0.0 || rect.height() == 0.0 {
return false;
}
let scale_x = canvas_width / rect.width();
let scale_y = canvas_height / rect.height();
let pixel_x = (relative_x * scale_x) as f64;
let pixel_y = (relative_y * scale_y) as f64;
// Get the 2D context and read the pixel data using JavaScript interop
if let Ok(Some(ctx)) = canvas.get_context("2d") {
let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap();
// Use web_sys::CanvasRenderingContext2d::get_image_data with proper error handling
match ctx.get_image_data(pixel_x, pixel_y, 1.0, 1.0) {
Ok(image_data) => {
// Get the pixel data as Clamped<Vec<u8>>
let data = image_data.data();
// Alpha channel is the 4th value (index 3)
if data.len() >= 4 {
return data[3] > 0;
}
}
Err(_) => {
// Security error or other issue with getImageData - assume no hit
return false;
}
}
}
false
}
/// Draw a rounded rectangle path.
#[cfg(feature = "hydrate")]
fn draw_rounded_rect(
ctx: &web_sys::CanvasRenderingContext2d,
x: f64,
y: f64,
width: f64,
height: f64,
radius: f64,
) {
ctx.begin_path();
ctx.move_to(x + radius, y);
ctx.line_to(x + width - radius, y);
ctx.quadratic_curve_to(x + width, y, x + width, y + radius);
ctx.line_to(x + width, y + height - radius);
ctx.quadratic_curve_to(x + width, y + height, x + width - radius, y + height);
ctx.line_to(x + radius, y + height);
ctx.quadratic_curve_to(x, y + height, x, y + height - radius);
ctx.line_to(x, y + radius);
ctx.quadratic_curve_to(x, y, x + radius, y);
ctx.close_path();
}