fix: finally got prop scaling, text scaling, canvas scaling, and bubble drawing to work

This commit is contained in:
Evan Carroll 2026-01-18 18:06:21 -06:00
parent af1c767f5f
commit 44b322371c

View file

@ -226,6 +226,235 @@ fn determine_bubble_position(
}
}
/// Unified layout context for avatar canvas rendering.
///
/// This struct computes all derived layout values once from the inputs,
/// providing a single source of truth for:
/// - Canvas dimensions and position
/// - Avatar positioning within the canvas
/// - Coordinate transformations between canvas-local and screen space
/// - Bubble positioning and clamping
///
/// By centralizing these calculations, we avoid scattered, duplicated logic
/// and ensure the style closure, Effect, and draw_bubble all use consistent values.
#[derive(Clone, Copy)]
#[allow(dead_code)] // Some fields kept for potential future use
struct CanvasLayout {
// Core dimensions
prop_size: f64,
avatar_size: f64,
// Content offset from grid center
content_x_offset: f64,
content_y_offset: f64,
// Text scaling
text_scale: f64,
bubble_max_width: f64,
// Canvas dimensions
canvas_width: f64,
canvas_height: f64,
// Canvas position in screen space
canvas_screen_x: f64,
canvas_screen_y: f64,
// Avatar center within canvas (canvas-local coordinates)
avatar_cx: f64,
avatar_cy: f64,
// Scene boundaries for clamping
boundaries: ScreenBoundaries,
// Bubble state
bubble_position: BubblePosition,
bubble_height_reserved: f64,
// Content row info for positioning
empty_top_rows: usize,
empty_bottom_rows: usize,
}
impl CanvasLayout {
/// Create a new layout from all input parameters.
fn new(
content_bounds: &ContentBounds,
prop_size: f64,
text_em_size: f64,
avatar_screen_x: f64,
avatar_screen_y: f64,
boundaries: ScreenBoundaries,
has_bubble: bool,
bubble_text: Option<&str>,
) -> Self {
let avatar_size = prop_size * 3.0;
let text_scale = text_em_size * BASE_TEXT_SCALE;
let bubble_max_width = 200.0 * text_scale;
// Content offsets from grid center
let content_x_offset = content_bounds.x_offset(prop_size);
let content_y_offset = content_bounds.y_offset(prop_size);
// Empty rows for positioning elements relative to content
let empty_top_rows = content_bounds.empty_top_rows();
let empty_bottom_rows = content_bounds.empty_bottom_rows();
// Content dimensions for clamping
let content_half_width = content_bounds.content_width(prop_size) / 2.0;
let content_half_height = content_bounds.content_height(prop_size) / 2.0;
// Clamp avatar so content stays within scene
let (clamped_x, clamped_y) = boundaries.clamp_avatar_center(
avatar_screen_x,
avatar_screen_y,
content_half_width,
content_half_height,
);
// Calculate bubble height and position
let bubble_height_reserved = if has_bubble {
(4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale
} else {
0.0
};
let name_height = 20.0 * text_scale;
// Determine bubble position (above or below)
// Use actual content height, not full 3x3 grid size
let bubble_position = if has_bubble {
let estimated_height = bubble_text
.map(|t| estimate_bubble_height(t, text_scale))
.unwrap_or(0.0);
// clamped_y is the content center, so use content_half_height
// to find the actual top of the visible avatar content
determine_bubble_position(
clamped_y,
content_half_height,
estimated_height,
0.0,
0.0,
boundaries.min_y,
)
} else {
BubblePosition::Above
};
// Canvas dimensions - wide enough to fit shifted bubble
let extra_margin = if has_bubble { bubble_max_width } else { 0.0 };
let canvas_width = avatar_size.max(bubble_max_width) + extra_margin;
let canvas_height = avatar_size + bubble_height_reserved + name_height;
// Canvas position in screen space
// The avatar grid center maps to canvas_width/2, but we need to account
// for the content offset so the visible content aligns with clamped_x/y
let canvas_x = clamped_x - avatar_size / 2.0 - content_x_offset;
let canvas_screen_x = canvas_x - (canvas_width - avatar_size) / 2.0;
let canvas_y = clamped_y - avatar_size / 2.0 - content_y_offset;
let canvas_screen_y = match bubble_position {
BubblePosition::Above => canvas_y - bubble_height_reserved,
BubblePosition::Below => canvas_y,
};
// Avatar center within canvas
let avatar_cx = canvas_width / 2.0;
let avatar_cy = match bubble_position {
BubblePosition::Above => bubble_height_reserved + avatar_size / 2.0,
BubblePosition::Below => avatar_size / 2.0,
};
Self {
prop_size,
avatar_size,
content_x_offset,
content_y_offset,
text_scale,
bubble_max_width,
canvas_width,
canvas_height,
canvas_screen_x,
canvas_screen_y,
avatar_cx,
avatar_cy,
boundaries,
bubble_position,
bubble_height_reserved,
empty_top_rows,
empty_bottom_rows,
}
}
/// CSS style string for positioning the canvas element.
fn css_style(&self, z_index: i32, pointer_events: &str, opacity: f64) -> String {
format!(
"position: absolute; \
left: 0; top: 0; \
transform: translate({}px, {}px); \
z-index: {}; \
pointer-events: {}; \
width: {}px; \
height: {}px; \
opacity: {};",
self.canvas_screen_x,
self.canvas_screen_y,
z_index,
pointer_events,
self.canvas_width,
self.canvas_height,
opacity
)
}
/// Content center X in canvas-local coordinates.
fn content_center_x(&self) -> f64 {
self.avatar_cx + self.content_x_offset
}
/// Top of avatar in canvas-local coordinates.
fn avatar_top_y(&self) -> f64 {
self.avatar_cy - self.avatar_size / 2.0
}
/// Bottom of avatar in canvas-local coordinates.
fn avatar_bottom_y(&self) -> f64 {
self.avatar_cy + self.avatar_size / 2.0
}
/// Convert canvas-local X to screen X.
fn canvas_to_screen_x(&self, x: f64) -> f64 {
self.canvas_screen_x + x
}
/// Clamp a bubble's X position to stay within scene boundaries.
/// Takes and returns canvas-local coordinates.
fn clamp_bubble_x(&self, bubble_x: f64, bubble_width: f64) -> f64 {
// Convert to screen space
let screen_left = self.canvas_to_screen_x(bubble_x);
let screen_right = screen_left + bubble_width;
// Calculate shifts needed to stay within bounds
let shift_right = (self.boundaries.min_x - screen_left).max(0.0);
let shift_left = (screen_right - self.boundaries.max_x).max(0.0);
// Apply shift and clamp to canvas bounds
let shifted = bubble_x + shift_right - shift_left;
shifted.max(0.0).min(self.canvas_width - bubble_width)
}
/// Adjustment for bubble Y position based on empty rows at top.
/// Returns the distance in pixels from grid top to content top.
fn content_top_adjustment(&self) -> f64 {
self.empty_top_rows as f64 * self.prop_size
}
/// Adjustment for name Y position based on empty rows at bottom.
/// Returns the distance in pixels from grid bottom to content bottom.
fn content_bottom_adjustment(&self) -> f64 {
self.empty_bottom_rows as f64 * self.prop_size
}
}
/// 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)
@ -293,102 +522,29 @@ pub fn AvatarCanvas(
&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
// Compute screen boundaries and avatar screen position
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(
// Create unified layout - all calculations happen in one place
let layout = CanvasLayout::new(
&content_bounds,
ps,
te,
avatar_screen_x,
avatar_screen_y,
content_half_width,
content_half_height,
boundaries,
bubble.is_some(),
bubble.as_ref().map(|b| b.message.content.as_str()),
);
// 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
)
// Generate CSS style from layout
layout.css_style(z_index, pointer_events, opacity)
};
// Store references for the effect
@ -422,7 +578,7 @@ pub fn AvatarCanvas(
return;
};
// Calculate dimensions (same as in style closure)
// Calculate content bounds for the avatar
let content_bounds = ContentBounds::from_layers(
&m.avatar.skin_layer,
&m.avatar.clothes_layer,
@ -430,61 +586,35 @@ pub fn AvatarCanvas(
&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() {
// Get scene dimensions and transform parameters
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
// Create unified layout - same calculation as style closure
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
// Calculate avatar's screen position
let avatar_screen_x = m.member.position_x * sx + ox;
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(
let layout = CanvasLayout::new(
&content_bounds,
ps,
te,
avatar_screen_x,
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;
boundaries,
bubble.is_some(),
bubble.as_ref().map(|b| b.message.content.as_str()),
);
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);
// Set canvas resolution from layout
canvas_el.set_width(layout.canvas_width as u32);
canvas_el.set_height(layout.canvas_height as u32);
let Ok(Some(ctx)) = canvas_el.get_context("2d") else {
return;
@ -492,16 +622,7 @@ pub fn AvatarCanvas(
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,
};
ctx.clear_rect(0.0, 0.0, layout.canvas_width, layout.canvas_height);
// Helper to load and draw an image
// Images are cached; when loaded, triggers a redraw via signal
@ -544,9 +665,9 @@ pub fn AvatarCanvas(
};
// 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;
let cell_size = layout.prop_size;
let grid_origin_x = layout.avatar_cx - layout.avatar_size / 2.0;
let grid_origin_y = layout.avatar_cy - layout.avatar_size / 2.0;
// Draw skin layer for all 9 positions
for pos in 0..9 {
@ -616,9 +737,9 @@ pub fn AvatarCanvas(
// 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;
let badge_size = 16.0 * layout.text_scale;
let badge_x = layout.avatar_cx + layout.avatar_size / 2.0 - badge_size / 2.0;
let badge_y = layout.avatar_cy - layout.avatar_size / 2.0 - badge_size / 2.0;
ctx.begin_path();
let _ = ctx.arc(
@ -632,23 +753,21 @@ pub fn AvatarCanvas(
ctx.fill();
ctx.set_fill_style_str("#000");
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * text_scale));
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * layout.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 name_x = layout.content_center_x();
let name_y = layout.avatar_bottom_y() - layout.content_bottom_adjustment()
+ 15.0 * layout.text_scale;
let display_name = &m.member.display_name;
ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale));
ctx.set_font(&format!("{}px sans-serif", 12.0 * layout.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);
@ -661,35 +780,7 @@ pub fn AvatarCanvas(
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),
);
draw_bubble_with_layout(&ctx, b, &layout, te);
}
}
});
@ -724,33 +815,18 @@ fn normalize_asset_path(path: &str) -> String {
}
}
/// Draw a speech bubble relative to the avatar with boundary awareness.
/// Draw a speech bubble using the unified CanvasLayout.
///
/// # 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)
/// This is the preferred method for drawing bubbles - it uses the layout's
/// coordinate transformation and clamping methods, ensuring consistency
/// with the canvas positioning.
#[cfg(feature = "hydrate")]
fn draw_bubble(
fn draw_bubble_with_layout(
ctx: &web_sys::CanvasRenderingContext2d,
bubble: &ActiveBubble,
center_x: f64,
top_y: f64,
bottom_y: f64,
content_x_offset: f64,
content_top_adjustment: f64,
layout: &CanvasLayout,
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;
@ -771,11 +847,7 @@ fn draw_bubble(
// 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,
);
let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0);
// Calculate bubble dimensions
let bubble_width = lines
@ -786,25 +858,14 @@ fn draw_bubble(
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;
// Get content center from layout
let content_center_x = layout.content_center_x();
// Calculate initial bubble X position (centered on content)
let mut bubble_x = content_center_x - bubble_width / 2.0;
// Calculate initial bubble X (centered on content)
let initial_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;
}
}
// Use layout's clamping method - this handles coordinate transformation correctly
let bubble_x = layout.clamp_bubble_x(initial_bubble_x, bubble_width);
// Calculate tail center - point toward content center but stay within bubble bounds
let tail_center_x = content_center_x
@ -812,27 +873,20 @@ fn draw_bubble(
.min(bubble_x + bubble_width - tail_size - border_radius);
// Calculate vertical position based on bubble position
let bubble_y = match position {
let bubble_y = match layout.bubble_position {
BubblePosition::Above => {
// Position vertically closer to content when top rows are empty
let adjusted_top_y = top_y + content_top_adjustment;
let adjusted_top_y = layout.avatar_top_y() + layout.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
layout.avatar_bottom_y() + tail_size + gap
}
};
// Draw bubble background
draw_rounded_rect(
ctx,
bubble_x,
bubble_y,
bubble_width,
bubble_height,
border_radius,
);
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);
@ -841,7 +895,7 @@ fn draw_bubble(
// Draw tail pointing to content center
ctx.begin_path();
match position {
match layout.bubble_position {
BubblePosition::Above => {
// Tail points DOWN toward avatar
ctx.move_to(tail_center_x - tail_size, bubble_y + bubble_height);
@ -861,7 +915,7 @@ fn draw_bubble(
ctx.set_stroke_style_str(border_color);
ctx.stroke();
// Draw text (re-set font in case canvas state changed)
// Draw text
ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size));
ctx.set_fill_style_str(text_color);
ctx.set_text_align("left");