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 /// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
const BASE_TEXT_SCALE: f64 = 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. /// Content bounds for a 3x3 avatar grid.
/// Tracks which rows/columns contain actual content for centering calculations. /// Tracks which rows/columns contain actual content for centering calculations.
struct ContentBounds { struct ContentBounds {
@ -86,6 +108,89 @@ impl ContentBounds {
fn empty_top_rows(&self) -> usize { fn empty_top_rows(&self) -> usize {
self.min_row 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). /// 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). /// Opacity for fade-out animation (0.0 to 1.0, default 1.0).
#[prop(default = 1.0)] #[prop(default = 1.0)]
opacity: f64, 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 { ) -> impl IntoView {
let canvas_ref = NodeRef::<leptos::html::Canvas>::new(); 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 // Avatar is a 3x3 grid of props, each prop is prop_size
let avatar_size = ps * 3.0; let avatar_size = ps * 3.0;
// Calculate canvas position from scene coordinates, adjusted for content bounds // Get scene dimensions (use large defaults if not provided)
let canvas_x = m.member.position_x * sx + ox - avatar_size / 2.0 - x_content_offset; let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0);
let canvas_y = m.member.position_y * sy + oy - avatar_size / 2.0 - y_content_offset; 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) // Fixed text dimensions (independent of prop_size/zoom)
let text_scale = te * BASE_TEXT_SCALE; let text_scale = te * BASE_TEXT_SCALE;
@ -170,12 +303,40 @@ pub fn AvatarCanvas(
let fixed_name_height = 20.0 * text_scale; let fixed_name_height = 20.0 * text_scale;
let fixed_text_width = 200.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_width = avatar_size.max(fixed_text_width);
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height; let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
// Adjust position to account for extra space above avatar // Adjust position based on bubble position
let adjusted_y = canvas_y - fixed_bubble_height; // 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!( format!(
"position: absolute; \ "position: absolute; \
@ -245,6 +406,42 @@ pub fn AvatarCanvas(
let fixed_name_height = 20.0 * text_scale; let fixed_name_height = 20.0 * text_scale;
let fixed_text_width = 200.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_width = avatar_size.max(fixed_text_width);
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height; 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); ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height);
// Avatar center position within the canvas // 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_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 // 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
@ -393,14 +595,32 @@ pub fn AvatarCanvas(
if b.expires_at >= current_time { if b.expires_at >= current_time {
let content_x_offset = content_bounds.x_offset(cell_size); let content_x_offset = content_bounds.x_offset(cell_size);
let content_top_adjustment = content_bounds.empty_top_rows() as f64 * 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( draw_bubble(
&ctx, &ctx,
b, b,
avatar_cx, avatar_cx,
avatar_cy - avatar_size / 2.0, avatar_top_y,
avatar_bottom_y,
content_x_offset, content_x_offset,
content_top_adjustment, content_top_adjustment,
te, 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")] #[cfg(feature = "hydrate")]
fn draw_bubble( fn draw_bubble(
ctx: &web_sys::CanvasRenderingContext2d, ctx: &web_sys::CanvasRenderingContext2d,
bubble: &ActiveBubble, bubble: &ActiveBubble,
center_x: f64, center_x: f64,
top_y: f64, top_y: f64,
bottom_y: f64,
content_x_offset: f64, content_x_offset: f64,
content_top_adjustment: 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 // 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;
@ -453,6 +688,7 @@ fn draw_bubble(
let line_height = 16.0 * text_scale; let line_height = 16.0 * text_scale;
let tail_size = 8.0 * text_scale; let tail_size = 8.0 * text_scale;
let border_radius = 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); 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) // Center bubble horizontally on content (not grid center)
let content_center_x = center_x + content_x_offset; 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 // Calculate initial bubble X position (centered on content)
let adjusted_top_y = top_y + content_top_adjustment; let mut bubble_x = content_center_x - bubble_width / 2.0;
let bubble_y = adjusted_top_y - bubble_height - tail_size - 5.0 * text_scale;
// 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 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);
@ -490,9 +756,20 @@ fn draw_bubble(
// Draw tail pointing to content center // Draw tail pointing to content center
ctx.begin_path(); ctx.begin_path();
ctx.move_to(content_center_x - tail_size, bubble_y + bubble_height); match position {
ctx.line_to(content_center_x, bubble_y + bubble_height + tail_size); BubblePosition::Above => {
ctx.line_to(content_center_x + tail_size, bubble_y + bubble_height); // 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.close_path();
ctx.set_fill_style_str(bg_color); ctx.set_fill_style_str(bg_color);
ctx.fill(); ctx.fill();

View file

@ -854,6 +854,10 @@ pub fn RealmSceneViewer(
let offset_x_signal = Signal::derive(move || offset_x.get()); let offset_x_signal = Signal::derive(move || offset_x.get());
let offset_y_signal = Signal::derive(move || offset_y.get()); let offset_y_signal = Signal::derive(move || offset_y.get());
// Create signals for scene dimensions to pass to AvatarCanvas for boundary awareness
let scene_width_signal = Signal::derive(move || scene_width_f);
let scene_height_signal = Signal::derive(move || scene_height_f);
// Create a map of members by key for efficient lookup // Create a map of members by key for efficient lookup
let members_by_key = Signal::derive(move || { let members_by_key = Signal::derive(move || {
use std::collections::HashMap; use std::collections::HashMap;
@ -942,6 +946,8 @@ pub fn RealmSceneViewer(
z_index=z z_index=z
active_bubble=bubble_signal active_bubble=bubble_signal
text_em_size=text_em_size text_em_size=text_em_size
scene_width=scene_width_signal
scene_height=scene_height_signal
/> />
} }
}).collect_view() }).collect_view()
@ -982,6 +988,8 @@ pub fn RealmSceneViewer(
active_bubble=bubble_signal active_bubble=bubble_signal
text_em_size=text_em_size text_em_size=text_em_size
opacity=opacity opacity=opacity
scene_width=scene_width_signal
scene_height=scene_height_signal
/> />
}) })
} else { } else {