""" Chat UI Renderer for Text Story module. Creates iMessage-style floating chat card. """ import os import logging from PIL import Image, ImageDraw, ImageFont from typing import List, Tuple, Optional import random logger = logging.getLogger(__name__) # Canvas dimensions (9:16 vertical) CANVAS_WIDTH = 1080 CANVAS_HEIGHT = 1920 # ============================================ # POSITIONING (Matched to Facebook Messenger) # ============================================ # Floating card centered on gameplay background # Reference screenshot shows: # - Floating rounded card # - Header with back arrow, avatar, name, status, icons # - White chat area with gray/blue bubbles # - Small avatar next to other person's messages LAYOUT = { "top_margin": 500, # 26% from top (moved up 8-10% from original 35%) "side_margin": 53, # +3 pixel more side margin (left/right) "chat_box_width": 974, # Slightly narrower card (1080 - 53*2) "chat_box_radius": 35, # Rounded corners "header_height": 100, # Header with avatar + name + status "max_chat_height": 1100, # Maximum height of chat box } # Colors (Facebook Messenger - exact from screenshot) COLORS = { # Header "header_bg": (255, 255, 255), # White header "header_border": (230, 230, 230), # Light gray separator line # Bubbles "bubble_other": (241, 241, 241), # Very light gray for other person "bubble_user": (0, 132, 255), # Messenger blue (#0084FF) # Text "text_white": (255, 255, 255), # White text on blue bubble "text_black": (5, 5, 5), # Black text on gray bubble "text_gray": (101, 103, 107), # Secondary text / timestamps "text_blue": (0, 132, 255), # Blue accent (back arrow, icons) "status_green": (49, 167, 75), # Active Now green dot # Background "chat_bg": (255, 255, 255), # White chat area "avatar_bg": (200, 200, 205), # Avatar placeholder gray } # UI Measurements (Messenger style) UI = { "bubble_max_width_ratio": 0.72, # 72% of chat box width "bubble_padding_h": 14, # Horizontal padding "bubble_padding_v": 10, # Vertical padding "bubble_radius": 20, # Rounded corners on bubbles "bubble_gap": 6, # Gap between messages "font_size": 30, # Main text size "header_font_size": 26, # Header name size "small_font_size": 20, # Timestamps, status "header_avatar_size": 48, # Avatar in header "msg_avatar_size": 28, # Small avatar next to messages "max_visible_messages": 8, } class ChatRenderer: """ Renders iMessage-style floating chat card. Positioned with margins matching reference image. """ def __init__(self, person_a_name: str = "You", person_b_name: str = "Unknown", person_b_avatar: str = None): self.person_a_name = person_a_name self.person_b_name = person_b_name self.person_b_avatar = person_b_avatar or person_b_name[0].upper() # Load fonts self.font = self._load_font(UI["font_size"]) self.font_header = self._load_font(UI["header_font_size"]) self.font_small = self._load_font(UI["small_font_size"]) self.font_avatar = self._load_font(24) def _load_font(self, size: int) -> ImageFont.FreeTypeFont: """Load font with fallback.""" font_paths = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", "C:/Windows/Fonts/arial.ttf", "/System/Library/Fonts/SFNS.ttf", ] for path in font_paths: if os.path.exists(path): try: return ImageFont.truetype(path, size) except: continue return ImageFont.load_default() def _wrap_text(self, text: str, max_width: int) -> List[str]: """Wrap text to fit within max width.""" words = text.split() lines = [] current_line = [] for word in words: test_line = " ".join(current_line + [word]) bbox = self.font.getbbox(test_line) width = bbox[2] - bbox[0] if width <= max_width: current_line.append(word) else: if current_line: lines.append(" ".join(current_line)) current_line = [word] if current_line: lines.append(" ".join(current_line)) return lines if lines else [text] def _calculate_bubble_size(self, text: str) -> Tuple[int, int, List[str]]: """Calculate bubble size based on text.""" max_text_width = int(LAYOUT["chat_box_width"] * UI["bubble_max_width_ratio"]) - UI["bubble_padding_h"] * 2 lines = self._wrap_text(text, max_text_width) line_height = self.font.getbbox("Ay")[3] + 4 text_height = line_height * len(lines) max_line_width = 0 for line in lines: bbox = self.font.getbbox(line) max_line_width = max(max_line_width, bbox[2] - bbox[0]) bubble_width = max_line_width + UI["bubble_padding_h"] * 2 bubble_height = text_height + UI["bubble_padding_v"] * 2 return bubble_width, bubble_height, lines def _draw_chat_box_background(self, draw: ImageDraw.Draw, height: int): """Draw the floating chat card background with rounded corners.""" x1 = LAYOUT["side_margin"] y1 = LAYOUT["top_margin"] x2 = x1 + LAYOUT["chat_box_width"] y2 = y1 + height # Draw rounded rectangle for entire chat box draw.rounded_rectangle( [x1, y1, x2, y2], radius=LAYOUT["chat_box_radius"], fill=COLORS["chat_bg"] ) def _draw_header(self, draw: ImageDraw.Draw): """Draw Facebook Messenger style header.""" x_offset = LAYOUT["side_margin"] y_start = LAYOUT["top_margin"] # Header background header_y2 = y_start + LAYOUT["header_height"] draw.rounded_rectangle( [x_offset, y_start, x_offset + LAYOUT["chat_box_width"], header_y2], radius=LAYOUT["chat_box_radius"], fill=COLORS["header_bg"] ) # Fill bottom part (not rounded) draw.rectangle( [x_offset, y_start + 40, x_offset + LAYOUT["chat_box_width"], header_y2], fill=COLORS["header_bg"] ) # Header separator line draw.line( [(x_offset + 10, header_y2 - 1), (x_offset + LAYOUT["chat_box_width"] - 10, header_y2 - 1)], fill=COLORS.get("header_border", (230, 230, 230)), width=1 ) # Back arrow (left) - ← draw.text( (x_offset + 18, y_start + 30), "←", fill=COLORS["text_blue"], font=self.font_header ) # Avatar (left side, after back arrow) avatar_x = x_offset + 70 avatar_y = y_start + LAYOUT["header_height"] // 2 avatar_r = UI["header_avatar_size"] // 2 # Avatar circle (gray placeholder or image) draw.ellipse( [avatar_x - avatar_r, avatar_y - avatar_r, avatar_x + avatar_r, avatar_y + avatar_r], fill=COLORS.get("avatar_bg", (200, 200, 205)) ) # Avatar letter letter = self.person_b_avatar[:1].upper() bbox = self.font_avatar.getbbox(letter) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] draw.text( (avatar_x - text_w // 2, avatar_y - text_h // 2 - 2), letter, fill=COLORS["text_white"], font=self.font_avatar ) # Active Now green dot (bottom right of avatar) dot_r = 6 dot_x = avatar_x + avatar_r - 5 dot_y = avatar_y + avatar_r - 5 draw.ellipse( [dot_x - dot_r, dot_y - dot_r, dot_x + dot_r, dot_y + dot_r], fill=COLORS.get("status_green", (49, 167, 75)) ) # White border around dot draw.ellipse( [dot_x - dot_r - 2, dot_y - dot_r - 2, dot_x + dot_r + 2, dot_y + dot_r + 2], outline=COLORS["header_bg"], width=2 ) draw.ellipse( [dot_x - dot_r, dot_y - dot_r, dot_x + dot_r, dot_y + dot_r], fill=COLORS.get("status_green", (49, 167, 75)) ) # Name (beside avatar) name_x = avatar_x + avatar_r + 15 name_y = y_start + 28 draw.text( (name_x, name_y), self.person_b_name, fill=COLORS["text_black"], font=self.font_header ) # Status "Active Now" below name status_y = name_y + 30 draw.text( (name_x, status_y), "Active Now", fill=COLORS["text_gray"], font=self.font_small ) # Icons on right (phone, video, info) - using Unicode symbols right_x = x_offset + LAYOUT["chat_box_width"] - 100 icon_y = y_start + 35 # Use filled circles as icon placeholders (compatible with all fonts) draw.ellipse([right_x, icon_y + 5, right_x + 22, icon_y + 27], fill=COLORS["text_blue"]) draw.ellipse([right_x + 35, icon_y + 5, right_x + 57, icon_y + 27], fill=COLORS["text_blue"]) draw.ellipse([right_x + 70, icon_y + 5, right_x + 92, icon_y + 27], fill=COLORS["text_blue"]) def _draw_bubble(self, draw: ImageDraw.Draw, x: int, y: int, width: int, height: int, lines: List[str], is_user: bool) -> int: """Draw a chat bubble. Returns bottom Y position.""" color = COLORS["bubble_user"] if is_user else COLORS["bubble_other"] # Draw rounded rectangle draw.rounded_rectangle( [x, y, x + width, y + height], radius=UI["bubble_radius"], fill=color ) # Draw text (white on blue, black on gray) text_color = COLORS["text_white"] if is_user else COLORS.get("text_black", (5, 5, 5)) text_x = x + UI["bubble_padding_h"] text_y = y + UI["bubble_padding_v"] line_height = self.font.getbbox("Ay")[3] + 4 for line in lines: draw.text((text_x, text_y), line, fill=text_color, font=self.font) text_y += line_height return y + height def render_frame(self, messages: List[dict], show_typing: bool = False) -> Image.Image: """ Render a single frame with current messages. Args: messages: List of {"sender": "A"/"B", "text": "..."} dicts show_typing: Whether to show typing indicator Returns: PIL Image of the frame """ # Create transparent image img = Image.new("RGBA", (CANVAS_WIDTH, CANVAS_HEIGHT), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) # Calculate message heights message_heights = [] visible_messages = messages[-UI["max_visible_messages"]:] for msg in visible_messages: _, height, _ = self._calculate_bubble_size(msg["text"]) message_heights.append(height + UI["bubble_gap"]) total_msg_height = sum(message_heights) # Calculate total chat box height chat_box_height = LAYOUT["header_height"] + total_msg_height + 30 # 30px bottom padding chat_box_height = min(chat_box_height, LAYOUT["max_chat_height"]) # Draw chat box background self._draw_chat_box_background(draw, chat_box_height) # Draw header self._draw_header(draw) # Draw messages x_base = LAYOUT["side_margin"] current_y = LAYOUT["top_margin"] + LAYOUT["header_height"] + 10 bubble_area_width = LAYOUT["chat_box_width"] for i, msg in enumerate(visible_messages): width, height, lines = self._calculate_bubble_size(msg["text"]) # Position: A (user) = right, B (other) = left with avatar space if msg["sender"] == "A": x = x_base + bubble_area_width - width - 15 # Right aligned with padding else: # Leave space for small avatar avatar_space = UI.get("msg_avatar_size", 28) + 8 x = x_base + avatar_space + 10 # Draw small avatar for other person (only on last consecutive B message or last message) is_last_b = (i == len(visible_messages) - 1) or (visible_messages[i+1]["sender"] != "B") if is_last_b: avatar_r = UI.get("msg_avatar_size", 28) // 2 avatar_x = x_base + 10 + avatar_r avatar_y = current_y + height - avatar_r - 2 # Avatar circle draw.ellipse( [avatar_x - avatar_r, avatar_y - avatar_r, avatar_x + avatar_r, avatar_y + avatar_r], fill=COLORS.get("avatar_bg", (200, 200, 205)) ) # Avatar letter letter = self.person_b_avatar[:1].upper() bbox = self.font_small.getbbox(letter) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] draw.text( (avatar_x - text_w // 2, avatar_y - text_h // 2 - 1), letter, fill=COLORS["text_white"], font=self.font_small ) current_y = self._draw_bubble(draw, x, current_y, width, height, lines, msg["sender"] == "A") current_y += UI["bubble_gap"] # Draw typing indicator if needed if show_typing: self._draw_typing_indicator(draw, current_y) return img def _draw_typing_indicator(self, draw: ImageDraw.Draw, y: int): """Draw typing indicator (●●●) with avatar space.""" avatar_space = UI.get("msg_avatar_size", 28) + 8 x = LAYOUT["side_margin"] + avatar_space + 10 bubble_width = 65 bubble_height = 32 draw.rounded_rectangle( [x, y, x + bubble_width, y + bubble_height], radius=16, fill=COLORS["bubble_other"] ) # Three animated dots (●●●) dot_y = y + bubble_height // 2 for dx in [16, 32, 48]: draw.ellipse( [x + dx - 4, dot_y - 4, x + dx + 4, dot_y + 4], fill=COLORS["text_gray"] ) def get_ui_height(self, messages: List[dict]) -> int: """Calculate the height of the chat UI.""" message_heights = [] visible_messages = messages[-UI["max_visible_messages"]:] for msg in visible_messages: _, height, _ = self._calculate_bubble_size(msg["text"]) message_heights.append(height + UI["bubble_gap"]) total = LAYOUT["header_height"] + sum(message_heights) + 30 return min(LAYOUT["top_margin"] + total, LAYOUT["top_margin"] + LAYOUT["max_chat_height"])