diff --git a/crates/chattyness-user-ui/src/components/avatar_canvas.rs b/crates/chattyness-user-ui/src/components/avatar_canvas.rs index ab5a0a9..71772f9 100644 --- a/crates/chattyness-user-ui/src/components/avatar_canvas.rs +++ b/crates/chattyness-user-ui/src/components/avatar_canvas.rs @@ -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>, + /// Scene height in scene coordinates (for boundary calculations). + #[prop(optional)] + scene_height: Option>, ) -> impl IntoView { let canvas_ref = NodeRef::::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(); diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index 7f0d338..4be0cae 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -854,6 +854,10 @@ pub fn RealmSceneViewer( let offset_x_signal = Signal::derive(move || offset_x.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 let members_by_key = Signal::derive(move || { use std::collections::HashMap; @@ -942,6 +946,8 @@ pub fn RealmSceneViewer( z_index=z active_bubble=bubble_signal text_em_size=text_em_size + scene_width=scene_width_signal + scene_height=scene_height_signal /> } }).collect_view() @@ -982,6 +988,8 @@ pub fn RealmSceneViewer( active_bubble=bubble_signal text_em_size=text_em_size opacity=opacity + scene_width=scene_width_signal + scene_height=scene_height_signal /> }) } else {