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). /// Get a unique key for a member (for Leptos For keying).
pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) { pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) {
(m.member.user_id, m.member.guest_session_id) (m.member.user_id, m.member.guest_session_id)
@ -293,102 +522,29 @@ pub fn AvatarCanvas(
&m.avatar.emotion_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) // Get scene dimensions (use large defaults if not provided)
let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0); 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 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); 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_x = m.member.position_x * sx + ox;
let avatar_screen_y = m.member.position_y * sy + oy; let avatar_screen_y = m.member.position_y * sy + oy;
// Clamp avatar center so visual bounds stay within screen boundaries // Create unified layout - all calculations happen in one place
// Use actual content extent rather than full 3x3 grid let layout = CanvasLayout::new(
let content_half_width = content_bounds.content_width(ps) / 2.0; &content_bounds,
let content_half_height = content_bounds.content_height(ps) / 2.0; ps,
let (clamped_x, clamped_y) = boundaries.clamp_avatar_center( te,
avatar_screen_x, avatar_screen_x,
avatar_screen_y, avatar_screen_y,
content_half_width, boundaries,
content_half_height, bubble.is_some(),
bubble.as_ref().map(|b| b.message.content.as_str()),
); );
// Calculate canvas position from clamped screen coordinates, adjusted for content bounds // Generate CSS style from layout
let canvas_x = clamped_x - avatar_size / 2.0 - x_content_offset; layout.css_style(z_index, pointer_events, opacity)
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 // Store references for the effect
@ -422,7 +578,7 @@ pub fn AvatarCanvas(
return; return;
}; };
// Calculate dimensions (same as in style closure) // Calculate content bounds for the avatar
let content_bounds = ContentBounds::from_layers( let content_bounds = ContentBounds::from_layers(
&m.avatar.skin_layer, &m.avatar.skin_layer,
&m.avatar.clothes_layer, &m.avatar.clothes_layer,
@ -430,61 +586,35 @@ pub fn AvatarCanvas(
&m.avatar.emotion_layer, &m.avatar.emotion_layer,
); );
let avatar_size = ps * 3.0; // Get scene dimensions and transform parameters
let text_scale = te * BASE_TEXT_SCALE; let sx = scale_x.get();
let fixed_bubble_height = if bubble.is_some() { let sy = scale_y.get();
(4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale let ox = offset_x.get();
} else { let oy = offset_y.get();
0.0 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 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 // Create unified layout - same calculation as style closure
let y_content_offset = content_bounds.y_offset(ps); let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy);
let bubble_position = if bubble.is_some() { let avatar_screen_x = m.member.position_x * sx + ox;
let sx = scale_x.get(); let avatar_screen_y = m.member.position_y * sy + oy;
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 layout = CanvasLayout::new(
let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0); &content_bounds,
let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0); ps,
te,
// Compute screen boundaries avatar_screen_x,
let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); avatar_screen_y,
boundaries,
// Calculate avatar's screen position bubble.is_some(),
let avatar_screen_y = m.member.position_y * sy + oy; bubble.as_ref().map(|b| b.message.content.as_str()),
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; let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
// Set canvas resolution // Set canvas resolution from layout
canvas_el.set_width(canvas_width as u32); canvas_el.set_width(layout.canvas_width as u32);
canvas_el.set_height(canvas_height as u32); canvas_el.set_height(layout.canvas_height as u32);
let Ok(Some(ctx)) = canvas_el.get_context("2d") else { let Ok(Some(ctx)) = canvas_el.get_context("2d") else {
return; return;
@ -492,16 +622,7 @@ pub fn AvatarCanvas(
let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap();
// Clear canvas // Clear canvas
ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height); ctx.clear_rect(0.0, 0.0, layout.canvas_width, layout.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 // Helper to load and draw an image
// Images are cached; when loaded, triggers a redraw via signal // 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) // Draw all 9 positions of the avatar grid (3x3 layout)
let cell_size = ps; let cell_size = layout.prop_size;
let grid_origin_x = avatar_cx - avatar_size / 2.0; let grid_origin_x = layout.avatar_cx - layout.avatar_size / 2.0;
let grid_origin_y = avatar_cy - avatar_size / 2.0; let grid_origin_y = layout.avatar_cy - layout.avatar_size / 2.0;
// Draw skin layer for all 9 positions // Draw skin layer for all 9 positions
for pos in 0..9 { for pos in 0..9 {
@ -616,9 +737,9 @@ pub fn AvatarCanvas(
// Draw emotion badge if non-neutral // Draw emotion badge if non-neutral
let current_emotion = m.member.current_emotion; let current_emotion = m.member.current_emotion;
if current_emotion > 0 { if current_emotion > 0 {
let badge_size = 16.0 * text_scale; let badge_size = 16.0 * layout.text_scale;
let badge_x = avatar_cx + avatar_size / 2.0 - badge_size / 2.0; let badge_x = layout.avatar_cx + layout.avatar_size / 2.0 - badge_size / 2.0;
let badge_y = avatar_cy - 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(); ctx.begin_path();
let _ = ctx.arc( let _ = ctx.arc(
@ -632,23 +753,21 @@ pub fn AvatarCanvas(
ctx.fill(); ctx.fill();
ctx.set_fill_style_str("#000"); 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_align("center");
ctx.set_text_baseline("middle"); ctx.set_text_baseline("middle");
let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y); 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) // 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; 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_align("center");
ctx.set_text_baseline("alphabetic"); 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 // Black outline
ctx.set_stroke_style_str("#000"); ctx.set_stroke_style_str("#000");
ctx.set_line_width(3.0); ctx.set_line_width(3.0);
@ -661,35 +780,7 @@ pub fn AvatarCanvas(
if let Some(ref b) = bubble { if let Some(ref b) = bubble {
let current_time = js_sys::Date::now() as i64; let current_time = js_sys::Date::now() as i64;
if b.expires_at >= current_time { if b.expires_at >= current_time {
let content_x_offset = content_bounds.x_offset(cell_size); draw_bubble_with_layout(&ctx, b, &layout, te);
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),
);
} }
} }
}); });
@ -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 /// This is the preferred method for drawing bubbles - it uses the layout's
/// * `ctx` - Canvas rendering context /// coordinate transformation and clamping methods, ensuring consistency
/// * `bubble` - The active bubble data /// with the canvas positioning.
/// * `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")] #[cfg(feature = "hydrate")]
fn draw_bubble( fn draw_bubble_with_layout(
ctx: &web_sys::CanvasRenderingContext2d, ctx: &web_sys::CanvasRenderingContext2d,
bubble: &ActiveBubble, bubble: &ActiveBubble,
center_x: f64, layout: &CanvasLayout,
top_y: f64,
bottom_y: f64,
content_x_offset: f64,
content_top_adjustment: f64,
text_em_size: 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 text_scale = text_em_size * BASE_TEXT_SCALE;
let max_bubble_width = 200.0 * text_scale; let max_bubble_width = 200.0 * text_scale;
let padding = 8.0 * text_scale; let padding = 8.0 * text_scale;
@ -771,11 +847,7 @@ fn draw_bubble(
// Measure and wrap text // Measure and wrap text
ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size)); ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size));
let lines = wrap_text( let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0);
ctx,
&bubble.message.content,
max_bubble_width - padding * 2.0,
);
// Calculate bubble dimensions // Calculate bubble dimensions
let bubble_width = lines let bubble_width = lines
@ -786,25 +858,14 @@ fn draw_bubble(
let bubble_width = bubble_width.max(60.0 * text_scale); let bubble_width = bubble_width.max(60.0 * text_scale);
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0; let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
// Center bubble horizontally on content (not grid center) // Get content center from layout
let content_center_x = center_x + content_x_offset; let content_center_x = layout.content_center_x();
// Calculate initial bubble X position (centered on content) // Calculate initial bubble X (centered on content)
let mut bubble_x = content_center_x - bubble_width / 2.0; let initial_bubble_x = content_center_x - bubble_width / 2.0;
// Clamp bubble horizontally to stay within drawable area // Use layout's clamping method - this handles coordinate transformation correctly
if let Some(bounds) = boundaries { let bubble_x = layout.clamp_bubble_x(initial_bubble_x, bubble_width);
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 // Calculate tail center - point toward content center but stay within bubble bounds
let tail_center_x = content_center_x let tail_center_x = content_center_x
@ -812,27 +873,20 @@ fn draw_bubble(
.min(bubble_x + bubble_width - tail_size - border_radius); .min(bubble_x + bubble_width - tail_size - border_radius);
// Calculate vertical position based on bubble position // Calculate vertical position based on bubble position
let bubble_y = match position { let bubble_y = match layout.bubble_position {
BubblePosition::Above => { BubblePosition::Above => {
// Position vertically closer to content when top rows are empty // 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 adjusted_top_y - bubble_height - tail_size - gap
} }
BubblePosition::Below => { BubblePosition::Below => {
// Position below avatar with gap for tail // Position below avatar with gap for tail
bottom_y + tail_size + gap layout.avatar_bottom_y() + tail_size + gap
} }
}; };
// Draw bubble background // Draw bubble background
draw_rounded_rect( draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
ctx,
bubble_x,
bubble_y,
bubble_width,
bubble_height,
border_radius,
);
ctx.set_fill_style_str(bg_color); ctx.set_fill_style_str(bg_color);
ctx.fill(); ctx.fill();
ctx.set_stroke_style_str(border_color); ctx.set_stroke_style_str(border_color);
@ -841,7 +895,7 @@ fn draw_bubble(
// Draw tail pointing to content center // Draw tail pointing to content center
ctx.begin_path(); ctx.begin_path();
match position { match layout.bubble_position {
BubblePosition::Above => { BubblePosition::Above => {
// Tail points DOWN toward avatar // Tail points DOWN toward avatar
ctx.move_to(tail_center_x - tail_size, bubble_y + bubble_height); 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.set_stroke_style_str(border_color);
ctx.stroke(); 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_font(&format!("{}{}px sans-serif", font_style, font_size));
ctx.set_fill_style_str(text_color); ctx.set_fill_style_str(text_color);
ctx.set_text_align("left"); ctx.set_text_align("left");