fix: visual clipping of avatar and speach bubbles

This commit is contained in:
Evan Carroll 2026-01-18 15:28:36 -06:00
parent 22cc0fdc38
commit fe1c1d3655
2 changed files with 301 additions and 16 deletions

View file

@ -14,6 +14,28 @@ use super::chat_types::{emotion_bubble_colors, ActiveBubble};
/// 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 {
@ -86,6 +108,89 @@ impl ContentBounds {
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).
@ -123,6 +228,12 @@ pub fn AvatarCanvas(
/// 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();
@ -156,9 +267,31 @@ pub fn AvatarCanvas(
// Avatar is a 3x3 grid of props, each prop is prop_size
let avatar_size = ps * 3.0;
// Calculate canvas position from scene coordinates, adjusted for content bounds
let canvas_x = m.member.position_x * sx + ox - avatar_size / 2.0 - x_content_offset;
let canvas_y = m.member.position_y * sy + oy - avatar_size / 2.0 - y_content_offset;
// 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;
@ -170,12 +303,40 @@ pub fn AvatarCanvas(
let fixed_name_height = 20.0 * text_scale;
let fixed_text_width = 200.0 * text_scale;
// Canvas must fit both avatar AND fixed-size text
// 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 to account for extra space above avatar
let adjusted_y = canvas_y - fixed_bubble_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; \
@ -245,6 +406,42 @@ pub fn AvatarCanvas(
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;
@ -263,8 +460,13 @@ pub fn AvatarCanvas(
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 = fixed_bubble_height + avatar_size / 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
@ -393,14 +595,32 @@ pub fn AvatarCanvas(
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_cy - avatar_size / 2.0,
avatar_top_y,
avatar_bottom_y,
content_x_offset,
content_top_adjustment,
te,
bubble_position,
Some(&boundaries),
);
}
}
@ -434,16 +654,31 @@ fn normalize_asset_path(path: &str) -> String {
}
}
/// Draw a speech bubble above the avatar.
/// 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;
@ -453,6 +688,7 @@ fn draw_bubble(
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);
@ -474,11 +710,41 @@ fn draw_bubble(
// Center bubble horizontally on content (not grid center)
let content_center_x = center_x + content_x_offset;
let bubble_x = content_center_x - bubble_width / 2.0;
// Position vertically closer to content when top rows are empty
let adjusted_top_y = top_y + content_top_adjustment;
let bubble_y = adjusted_top_y - bubble_height - tail_size - 5.0 * text_scale;
// 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);
@ -490,9 +756,20 @@ fn draw_bubble(
// Draw tail pointing to content center
ctx.begin_path();
ctx.move_to(content_center_x - tail_size, bubble_y + bubble_height);
ctx.line_to(content_center_x, bubble_y + bubble_height + tail_size);
ctx.line_to(content_center_x + tail_size, bubble_y + bubble_height);
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();