ismdrobiul489 commited on
Commit
30fb462
·
1 Parent(s): e46d7f1

feat: New dating-app style chat UI with gradient header, avatar, Online status

Browse files
modules/text_story/services/renderer.py CHANGED
@@ -1,12 +1,13 @@
1
  """
2
  Chat UI Renderer for Text Story module.
3
- Creates iMessage-style chat bubbles and UI.
4
  """
5
 
6
  import os
7
  import logging
8
  from PIL import Image, ImageDraw, ImageFont
9
  from typing import List, Tuple, Optional
 
10
 
11
  logger = logging.getLogger(__name__)
12
 
@@ -14,35 +15,48 @@ logger = logging.getLogger(__name__)
14
  CANVAS_WIDTH = 1080
15
  CANVAS_HEIGHT = 1920
16
 
17
- # Colors (iMessage style)
18
  COLORS = {
19
- "header_bg": (28, 28, 30), # #1C1C1E - Dark header
20
- "bubble_user": (0, 122, 255), # #007AFF - Blue (Person A/right)
21
- "bubble_other": (58, 58, 60), # #3A3A3C - Gray (Person B/left)
22
- "text_white": (255, 255, 255), # White text
23
- "text_gray": (142, 142, 147), # #8E8E93 - Secondary text
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
 
26
  # UI Measurements
27
  UI = {
28
- "header_height": 100,
29
- "margin_side": 30,
30
- "bubble_max_width_ratio": 0.75, # 75% of screen
31
- "bubble_padding_h": 16,
32
- "bubble_padding_v": 12,
33
- "bubble_radius": 20,
34
- "bubble_gap": 10,
35
- "font_size": 34,
36
- "header_font_size": 22,
37
- "avatar_size": 50,
 
38
  "max_visible_messages": 7,
 
39
  }
40
 
41
 
42
  class ChatRenderer:
43
  """
44
- Renders iMessage-style chat UI frames.
45
- Handles dynamic box sizing and message bubbles.
46
  """
47
 
48
  def __init__(self,
@@ -55,11 +69,13 @@ class ChatRenderer:
55
 
56
  # Load fonts
57
  self.font = self._load_font(UI["font_size"])
58
- self.font_small = self._load_font(UI["header_font_size"])
59
- self.font_avatar = self._load_font(28)
 
60
 
61
- # Track visible messages for scroll behavior
62
- self.visible_messages: List[dict] = []
 
63
 
64
  def _load_font(self, size: int) -> ImageFont.FreeTypeFont:
65
  """Load font with fallback."""
@@ -77,7 +93,6 @@ class ChatRenderer:
77
  except:
78
  continue
79
 
80
- # Fallback to default
81
  return ImageFont.load_default()
82
 
83
  def _wrap_text(self, text: str, max_width: int) -> List[str]:
@@ -103,13 +118,18 @@ class ChatRenderer:
103
 
104
  return lines if lines else [text]
105
 
 
 
 
 
 
 
106
  def _calculate_bubble_size(self, text: str) -> Tuple[int, int, List[str]]:
107
  """Calculate bubble size based on text."""
108
  max_text_width = int(CANVAS_WIDTH * UI["bubble_max_width_ratio"]) - UI["bubble_padding_h"] * 2
109
  lines = self._wrap_text(text, max_text_width)
110
 
111
- # Calculate text dimensions
112
- line_height = self.font.getbbox("Ay")[3] + 4
113
  text_height = line_height * len(lines)
114
 
115
  max_line_width = 0
@@ -117,89 +137,129 @@ class ChatRenderer:
117
  bbox = self.font.getbbox(line)
118
  max_line_width = max(max_line_width, bbox[2] - bbox[0])
119
 
120
- # Add padding
121
- bubble_width = max_line_width + UI["bubble_padding_h"] * 2
122
  bubble_height = text_height + UI["bubble_padding_v"] * 2
123
 
124
  return bubble_width, bubble_height, lines
125
 
126
- def _draw_header(self, draw: ImageDraw.Draw, img: Image.Image):
127
- """Draw iMessage-style header."""
128
- # Header background
129
- draw.rectangle(
130
- [0, 0, CANVAS_WIDTH, UI["header_height"]],
131
- fill=COLORS["header_bg"]
132
- )
133
-
134
- # Avatar circle
135
- avatar_x = CANVAS_WIDTH // 2
136
- avatar_y = 35
 
 
137
  avatar_r = UI["avatar_size"] // 2
138
 
 
 
 
 
 
 
 
 
 
139
  draw.ellipse(
140
  [avatar_x - avatar_r, avatar_y - avatar_r,
141
  avatar_x + avatar_r, avatar_y + avatar_r],
142
- fill=(100, 100, 105) # Gray circle
143
  )
144
 
145
  # Avatar letter
146
- bbox = self.font_avatar.getbbox(self.person_b_avatar)
 
147
  text_w = bbox[2] - bbox[0]
148
  text_h = bbox[3] - bbox[1]
149
  draw.text(
150
- (avatar_x - text_w // 2, avatar_y - text_h // 2 - 2),
151
- self.person_b_avatar,
152
  fill=COLORS["text_white"],
153
- font=self.font_avatar
154
  )
155
 
156
- # Name below avatar
157
- name_bbox = self.font_small.getbbox(self.person_b_name)
158
- name_w = name_bbox[2] - name_bbox[0]
159
  draw.text(
160
- (avatar_x - name_w // 2, avatar_y + avatar_r + 8),
161
  self.person_b_name,
162
  fill=COLORS["text_white"],
163
- font=self.font_small
164
  )
165
 
166
- # Left chevron (back button)
167
- draw.text((20, 30), "‹", fill=(0, 122, 255), font=self.font)
 
 
 
 
 
 
168
 
169
- # Right video icon
170
- draw.text((CANVAS_WIDTH - 50, 30), "📹", fill=(0, 122, 255), font=self.font_small)
 
 
 
 
 
 
 
171
 
172
  def _draw_bubble(self, draw: ImageDraw.Draw,
173
  x: int, y: int,
174
  width: int, height: int,
175
  lines: List[str],
 
176
  is_user: bool) -> int:
177
  """
178
- Draw a chat bubble.
179
 
180
  Returns:
181
  Bottom Y position of bubble
182
  """
183
  # Bubble color
184
  color = COLORS["bubble_user"] if is_user else COLORS["bubble_other"]
 
 
 
 
 
 
 
 
 
185
 
186
- # Draw rounded rectangle
187
- radius = UI["bubble_radius"]
188
  draw.rounded_rectangle(
189
  [x, y, x + width, y + height],
190
- radius=radius,
191
  fill=color
192
  )
193
 
194
  # Draw text
195
  text_x = x + UI["bubble_padding_h"]
196
  text_y = y + UI["bubble_padding_v"]
197
- line_height = self.font.getbbox("Ay")[3] + 4
198
 
199
  for line in lines:
200
- draw.text((text_x, text_y), line, fill=COLORS["text_white"], font=self.font)
201
  text_y += line_height
202
 
 
 
 
 
 
 
 
203
  return y + height
204
 
205
  def render_frame(self, messages: List[dict], show_typing: bool = False) -> Image.Image:
@@ -226,25 +286,31 @@ class ChatRenderer:
226
  total_msg_height = sum(message_heights)
227
 
228
  # Calculate UI box height (dynamic)
229
- ui_height = UI["header_height"] + total_msg_height + 20 # 20px bottom padding
230
 
231
- # Draw semi-transparent black background for chat area
 
 
 
 
232
  draw.rectangle(
233
- [0, 0, CANVAS_WIDTH, ui_height],
234
- fill=(0, 0, 0, 220) # Semi-transparent black
235
  )
236
 
237
- # Draw header
238
- self._draw_header(draw, img)
239
 
240
- # Draw messages
241
- current_y = UI["header_height"] + 15
242
 
243
  # Only show last N messages if too many
244
  visible_messages = messages[-UI["max_visible_messages"]:]
 
245
 
246
- for msg in visible_messages:
247
  width, height, lines = self._calculate_bubble_size(msg["text"])
 
248
 
249
  # Position: A (user) = right, B (other) = left
250
  if msg["sender"] == "A":
@@ -252,7 +318,7 @@ class ChatRenderer:
252
  else:
253
  x = UI["margin_side"]
254
 
255
- current_y = self._draw_bubble(draw, x, current_y, width, height, lines, msg["sender"] == "A")
256
  current_y += UI["bubble_gap"]
257
 
258
  # Draw typing indicator if needed
@@ -266,20 +332,20 @@ class ChatRenderer:
266
  """Draw typing indicator (●●●)."""
267
  x = UI["margin_side"]
268
 
269
- # Background bubble
270
- bubble_width = 70
271
- bubble_height = 40
272
  draw.rounded_rectangle(
273
  [x, y, x + bubble_width, y + bubble_height],
274
- radius=15,
275
  fill=COLORS["bubble_other"]
276
  )
277
 
278
  # Three dots
279
  dot_y = y + bubble_height // 2
280
- for i, dx in enumerate([20, 35, 50]):
281
  draw.ellipse(
282
- [x + dx - 4, dot_y - 4, x + dx + 4, dot_y + 4],
283
  fill=COLORS["text_gray"]
284
  )
285
 
@@ -292,4 +358,4 @@ class ChatRenderer:
292
  _, height, _ = self._calculate_bubble_size(msg["text"])
293
  message_heights.append(height + UI["bubble_gap"])
294
 
295
- return UI["header_height"] + sum(message_heights) + 20
 
1
  """
2
  Chat UI Renderer for Text Story module.
3
+ Creates dating-app style chat bubbles and UI.
4
  """
5
 
6
  import os
7
  import logging
8
  from PIL import Image, ImageDraw, ImageFont
9
  from typing import List, Tuple, Optional
10
+ import random
11
 
12
  logger = logging.getLogger(__name__)
13
 
 
15
  CANVAS_WIDTH = 1080
16
  CANVAS_HEIGHT = 1920
17
 
18
+ # Colors (Dating app style)
19
  COLORS = {
20
+ # Header gradient (purple/blue)
21
+ "header_start": (138, 43, 226), # Blue-violet
22
+ "header_end": (75, 0, 130), # Indigo
23
+
24
+ # Bubbles
25
+ "bubble_other": (245, 240, 235), # Cream/beige for left (other person)
26
+ "bubble_user": (255, 255, 255), # White for right (user)
27
+
28
+ # Text
29
+ "text_dark": (30, 30, 30), # Dark text on light bubbles
30
+ "text_white": (255, 255, 255), # White text on header
31
+ "text_gray": (120, 120, 120), # Gray for timestamps
32
+ "text_online": (50, 205, 50), # Green for "Online"
33
+
34
+ # Background
35
+ "chat_bg": (240, 240, 245), # Light gray chat area
36
  }
37
 
38
  # UI Measurements
39
  UI = {
40
+ "header_height": 180, # Taller header for avatar
41
+ "margin_side": 40,
42
+ "bubble_max_width_ratio": 0.72,
43
+ "bubble_padding_h": 20,
44
+ "bubble_padding_v": 14,
45
+ "bubble_radius": 24,
46
+ "bubble_gap": 20,
47
+ "font_size": 36,
48
+ "header_name_size": 48,
49
+ "timestamp_size": 22,
50
+ "avatar_size": 90,
51
  "max_visible_messages": 7,
52
+ "chat_area_top": 220, # Where chat area starts (below header)
53
  }
54
 
55
 
56
  class ChatRenderer:
57
  """
58
+ Renders dating-app style chat UI frames.
59
+ Fixed header with gradient, scrolling messages.
60
  """
61
 
62
  def __init__(self,
 
69
 
70
  # Load fonts
71
  self.font = self._load_font(UI["font_size"])
72
+ self.font_name = self._load_font(UI["header_name_size"])
73
+ self.font_timestamp = self._load_font(UI["timestamp_size"])
74
+ self.font_small = self._load_font(24)
75
 
76
+ # Current timestamp (random for realism)
77
+ self.base_hour = random.randint(10, 22)
78
+ self.base_minute = random.randint(10, 45)
79
 
80
  def _load_font(self, size: int) -> ImageFont.FreeTypeFont:
81
  """Load font with fallback."""
 
93
  except:
94
  continue
95
 
 
96
  return ImageFont.load_default()
97
 
98
  def _wrap_text(self, text: str, max_width: int) -> List[str]:
 
118
 
119
  return lines if lines else [text]
120
 
121
+ def _get_timestamp(self, msg_index: int) -> str:
122
+ """Generate realistic timestamp."""
123
+ minute = (self.base_minute + msg_index * 2) % 60
124
+ hour = self.base_hour + ((self.base_minute + msg_index * 2) // 60)
125
+ return f"{hour % 24:02d}:{minute:02d}"
126
+
127
  def _calculate_bubble_size(self, text: str) -> Tuple[int, int, List[str]]:
128
  """Calculate bubble size based on text."""
129
  max_text_width = int(CANVAS_WIDTH * UI["bubble_max_width_ratio"]) - UI["bubble_padding_h"] * 2
130
  lines = self._wrap_text(text, max_text_width)
131
 
132
+ line_height = self.font.getbbox("Ay")[3] + 6
 
133
  text_height = line_height * len(lines)
134
 
135
  max_line_width = 0
 
137
  bbox = self.font.getbbox(line)
138
  max_line_width = max(max_line_width, bbox[2] - bbox[0])
139
 
140
+ # Add padding + space for timestamp
141
+ bubble_width = max_line_width + UI["bubble_padding_h"] * 2 + 80 # Extra for timestamp
142
  bubble_height = text_height + UI["bubble_padding_v"] * 2
143
 
144
  return bubble_width, bubble_height, lines
145
 
146
+ def _draw_gradient_header(self, img: Image.Image, draw: ImageDraw.Draw):
147
+ """Draw gradient header with avatar and name."""
148
+ # Create gradient
149
+ for y in range(UI["header_height"]):
150
+ ratio = y / UI["header_height"]
151
+ r = int(COLORS["header_start"][0] * (1 - ratio) + COLORS["header_end"][0] * ratio)
152
+ g = int(COLORS["header_start"][1] * (1 - ratio) + COLORS["header_end"][1] * ratio)
153
+ b = int(COLORS["header_start"][2] * (1 - ratio) + COLORS["header_end"][2] * ratio)
154
+ draw.line([(0, y), (CANVAS_WIDTH, y)], fill=(r, g, b))
155
+
156
+ # Avatar circle (left side)
157
+ avatar_x = 80
158
+ avatar_y = UI["header_height"] // 2
159
  avatar_r = UI["avatar_size"] // 2
160
 
161
+ # White circle border
162
+ draw.ellipse(
163
+ [avatar_x - avatar_r - 4, avatar_y - avatar_r - 4,
164
+ avatar_x + avatar_r + 4, avatar_y + avatar_r + 4],
165
+ fill=(255, 255, 255)
166
+ )
167
+
168
+ # Avatar inner circle (random color)
169
+ avatar_color = (random.randint(100, 200), random.randint(80, 150), random.randint(80, 150))
170
  draw.ellipse(
171
  [avatar_x - avatar_r, avatar_y - avatar_r,
172
  avatar_x + avatar_r, avatar_y + avatar_r],
173
+ fill=avatar_color
174
  )
175
 
176
  # Avatar letter
177
+ letter = self.person_b_avatar[:1].upper()
178
+ bbox = self.font_name.getbbox(letter)
179
  text_w = bbox[2] - bbox[0]
180
  text_h = bbox[3] - bbox[1]
181
  draw.text(
182
+ (avatar_x - text_w // 2, avatar_y - text_h // 2 - 5),
183
+ letter,
184
  fill=COLORS["text_white"],
185
+ font=self.font_name
186
  )
187
 
188
+ # Name (to the right of avatar)
189
+ name_x = avatar_x + avatar_r + 30
190
+ name_y = avatar_y - 25
191
  draw.text(
192
+ (name_x, name_y),
193
  self.person_b_name,
194
  fill=COLORS["text_white"],
195
+ font=self.font_name
196
  )
197
 
198
+ # "Online" status (below name)
199
+ online_y = name_y + 50
200
+ draw.text(
201
+ (name_x, online_y),
202
+ "Online",
203
+ fill=COLORS["text_online"],
204
+ font=self.font_small
205
+ )
206
 
207
+ # Three dots menu (right side)
208
+ dots_x = CANVAS_WIDTH - 60
209
+ dots_y = avatar_y
210
+ for i in range(3):
211
+ y = dots_y - 25 + i * 25
212
+ draw.ellipse(
213
+ [dots_x - 5, y - 5, dots_x + 5, y + 5],
214
+ fill=COLORS["text_white"]
215
+ )
216
 
217
  def _draw_bubble(self, draw: ImageDraw.Draw,
218
  x: int, y: int,
219
  width: int, height: int,
220
  lines: List[str],
221
+ timestamp: str,
222
  is_user: bool) -> int:
223
  """
224
+ Draw a chat bubble with timestamp.
225
 
226
  Returns:
227
  Bottom Y position of bubble
228
  """
229
  # Bubble color
230
  color = COLORS["bubble_user"] if is_user else COLORS["bubble_other"]
231
+ text_color = COLORS["text_dark"] # Dark text on light bubbles
232
+
233
+ # Draw rounded rectangle with shadow
234
+ shadow_offset = 3
235
+ draw.rounded_rectangle(
236
+ [x + shadow_offset, y + shadow_offset, x + width + shadow_offset, y + height + shadow_offset],
237
+ radius=UI["bubble_radius"],
238
+ fill=(200, 200, 200, 100) # Light shadow
239
+ )
240
 
 
 
241
  draw.rounded_rectangle(
242
  [x, y, x + width, y + height],
243
+ radius=UI["bubble_radius"],
244
  fill=color
245
  )
246
 
247
  # Draw text
248
  text_x = x + UI["bubble_padding_h"]
249
  text_y = y + UI["bubble_padding_v"]
250
+ line_height = self.font.getbbox("Ay")[3] + 6
251
 
252
  for line in lines:
253
+ draw.text((text_x, text_y), line, fill=text_color, font=self.font)
254
  text_y += line_height
255
 
256
+ # Draw timestamp (bottom right of bubble)
257
+ ts_bbox = self.font_timestamp.getbbox(timestamp)
258
+ ts_w = ts_bbox[2] - ts_bbox[0]
259
+ ts_x = x + width - ts_w - 15
260
+ ts_y = y + height - 30
261
+ draw.text((ts_x, ts_y), timestamp, fill=COLORS["text_gray"], font=self.font_timestamp)
262
+
263
  return y + height
264
 
265
  def render_frame(self, messages: List[dict], show_typing: bool = False) -> Image.Image:
 
286
  total_msg_height = sum(message_heights)
287
 
288
  # Calculate UI box height (dynamic)
289
+ ui_height = UI["chat_area_top"] + total_msg_height + 30
290
 
291
+ # Limit maximum height
292
+ max_ui_height = 1400
293
+ ui_height = min(ui_height, max_ui_height)
294
+
295
+ # Draw light gray chat background
296
  draw.rectangle(
297
+ [0, UI["header_height"], CANVAS_WIDTH, ui_height],
298
+ fill=COLORS["chat_bg"]
299
  )
300
 
301
+ # Draw gradient header (fixed on top)
302
+ self._draw_gradient_header(img, draw)
303
 
304
+ # Draw messages starting below header
305
+ current_y = UI["chat_area_top"]
306
 
307
  # Only show last N messages if too many
308
  visible_messages = messages[-UI["max_visible_messages"]:]
309
+ start_index = len(messages) - len(visible_messages)
310
 
311
+ for i, msg in enumerate(visible_messages):
312
  width, height, lines = self._calculate_bubble_size(msg["text"])
313
+ timestamp = self._get_timestamp(start_index + i)
314
 
315
  # Position: A (user) = right, B (other) = left
316
  if msg["sender"] == "A":
 
318
  else:
319
  x = UI["margin_side"]
320
 
321
+ current_y = self._draw_bubble(draw, x, current_y, width, height, lines, timestamp, msg["sender"] == "A")
322
  current_y += UI["bubble_gap"]
323
 
324
  # Draw typing indicator if needed
 
332
  """Draw typing indicator (●●●)."""
333
  x = UI["margin_side"]
334
 
335
+ # Background bubble (cream color like other messages)
336
+ bubble_width = 80
337
+ bubble_height = 45
338
  draw.rounded_rectangle(
339
  [x, y, x + bubble_width, y + bubble_height],
340
+ radius=18,
341
  fill=COLORS["bubble_other"]
342
  )
343
 
344
  # Three dots
345
  dot_y = y + bubble_height // 2
346
+ for i, dx in enumerate([22, 40, 58]):
347
  draw.ellipse(
348
+ [x + dx - 5, dot_y - 5, x + dx + 5, dot_y + 5],
349
  fill=COLORS["text_gray"]
350
  )
351
 
 
358
  _, height, _ = self._calculate_bubble_size(msg["text"])
359
  message_heights.append(height + UI["bubble_gap"])
360
 
361
+ return min(UI["chat_area_top"] + sum(message_heights) + 30, 1400)