A Bluesky Playdate client
1const pdapi = @import("playdate_api_definitions.zig");
2const std = @import("std");
3const pdtools = @import("pdtools/index.zig");
4const DrawableText = @import("pdtools/DrawableText.zig").DrawableText;
5const fonts = @import("fonts.zig");
6
7// Memory size constants
8pub const AUTHOR_BUFFER_SIZE = 32;
9pub const CONTENT_BUFFER_SIZE = 512;
10pub const ALT_TEXT_BUFFER_SIZE = 256;
11pub const MAX_POSTS = 50;
12
13pub const CURSOR_BUFFER_SIZE = 256;
14
15pub const PostType = enum {
16 normal,
17 repost,
18 reply,
19};
20
21pub const BskyPost = struct {
22 author: [AUTHOR_BUFFER_SIZE]u8,
23 author_len: usize,
24 content: [CONTENT_BUFFER_SIZE]u8,
25 content_len: usize,
26 alt_text: [ALT_TEXT_BUFFER_SIZE]u8,
27 alt_text_len: usize,
28
29 // Repost/Reply metadata
30 post_type: PostType,
31 repost_author: [AUTHOR_BUFFER_SIZE]u8,
32 repost_author_len: usize,
33 reply_author: [AUTHOR_BUFFER_SIZE]u8,
34 reply_author_len: usize,
35};
36
37/// Clean up text content by removing multiple consecutive newlines
38fn cleanupTextContent(buffer: []u8, content_len: usize) usize {
39 if (content_len == 0) return 0;
40
41 var write_pos: usize = 0;
42 var read_pos: usize = 0;
43 var consecutive_newlines: usize = 0;
44
45 while (read_pos < content_len) {
46 const char = buffer[read_pos];
47
48 if (char == '\n' or char == '\r') {
49 consecutive_newlines += 1;
50 // Allow max 2 consecutive newlines (one blank line)
51 if (consecutive_newlines <= 2) {
52 buffer[write_pos] = '\n'; // Normalize to \n
53 write_pos += 1;
54 }
55 } else {
56 consecutive_newlines = 0;
57 buffer[write_pos] = char;
58 write_pos += 1;
59 }
60
61 read_pos += 1;
62 }
63
64 return write_pos;
65}
66
67pub var g_playdate: *pdapi.PlaydateAPI = undefined;
68
69var g_posts_out: []BskyPost = undefined;
70var g_post_count: *usize = undefined;
71var g_post_max: usize = undefined;
72var g_parsing_feed: bool = false;
73var g_current_feed_index: i32 = -1;
74
75fn onDecodeError(decoder: ?*pdapi.JSONDecoder, err: ?[*:0]const u8, linenum: c_int) callconv(.C) void {
76 _ = decoder; // Unused in this context
77
78 const pd = g_playdate;
79 pd.system.logToConsole("JSON decoding error at line %d: %s", linenum, err);
80
81 // Log parsing context
82 pd.system.logToConsole("Parsing context: posts found so far = %d", @as(c_int, @intCast(g_post_count.*)));
83 pd.system.logToConsole("Current feed index: %d", g_current_feed_index);
84}
85
86fn debugDidDecodeSublist(decoder: ?*pdapi.JSONDecoder, name: ?[*:0]const u8, json_type: pdapi.JSONValueType) callconv(.C) ?*anyopaque {
87 _ = decoder;
88 _ = name;
89 _ = json_type;
90 return null;
91}
92
93fn author_didDecodeSublist(decoder: ?*pdapi.JSONDecoder, name: ?[*:0]const u8, json_type: pdapi.JSONValueType) callconv(.C) ?*anyopaque {
94 _ = name;
95 _ = json_type;
96
97 // Reset to post-level parsing
98 decoder.?.didDecodeTableValue = post_didDecodeTableValue;
99 decoder.?.willDecodeSublist = null;
100 decoder.?.didDecodeSublist = null;
101
102 return null;
103}
104
105fn record_didDecodeSublist(decoder: ?*pdapi.JSONDecoder, name: ?[*:0]const u8, json_type: pdapi.JSONValueType) callconv(.C) ?*anyopaque {
106 _ = name;
107 _ = json_type;
108
109 // Reset to post-level parsing
110 decoder.?.didDecodeTableValue = post_didDecodeTableValue;
111 decoder.?.willDecodeSublist = null;
112 decoder.?.didDecodeSublist = null;
113
114 return null;
115}
116
117fn author_didDecodeTableValue(decoder: ?*pdapi.JSONDecoder, key: ?[*:0]const u8, value: pdapi.JSONValue) callconv(.C) void {
118 if (key == null) return;
119
120 const key_str = std.mem.span(key.?);
121 const json_type: pdapi.JSONValueType = @enumFromInt(value.type);
122
123 // Extract handle from author object
124 if (std.mem.eql(u8, key_str, "handle") and json_type == pdapi.JSONValueType.JSONString) {
125 if (g_post_count.* < g_post_max) {
126 const author_str = std.mem.span(value.data.stringval);
127 const copy_len = @min(author_str.len, g_posts_out[g_post_count.*].author.len - 1);
128 @memcpy(g_posts_out[g_post_count.*].author[0..copy_len], author_str[0..copy_len]);
129 g_posts_out[g_post_count.*].author_len = copy_len;
130 }
131 }
132
133 if (false) {
134 debugDidDecodeTableValue(decoder, key, value);
135 }
136}
137
138fn record_didDecodeTableValue(decoder: ?*pdapi.JSONDecoder, key: ?[*:0]const u8, value: pdapi.JSONValue) callconv(.C) void {
139 if (key == null) return;
140
141 const key_str = std.mem.span(key.?);
142 const json_type: pdapi.JSONValueType = @enumFromInt(value.type);
143
144 // Extract text from record object
145 if (std.mem.eql(u8, key_str, "text") and json_type == pdapi.JSONValueType.JSONString) {
146 if (g_post_count.* < g_post_max) {
147 const content_str = std.mem.span(value.data.stringval);
148 const copy_len = @min(content_str.len, g_posts_out[g_post_count.*].content.len - 1);
149 @memcpy(g_posts_out[g_post_count.*].content[0..copy_len], content_str[0..copy_len]);
150
151 // Clean up multiple consecutive newlines
152 const cleaned_len = cleanupTextContent(g_posts_out[g_post_count.*].content[0..copy_len], copy_len);
153 g_posts_out[g_post_count.*].content_len = cleaned_len;
154 }
155 }
156
157 if (false) {
158 debugDidDecodeTableValue(decoder, key, value);
159 }
160}
161
162fn post_didDecodeTableValue(decoder: ?*pdapi.JSONDecoder, key: ?[*:0]const u8, value: pdapi.JSONValue) callconv(.C) void {
163 if (key == null) return;
164
165 const key_str = std.mem.span(key.?);
166 const json_type: pdapi.JSONValueType = @enumFromInt(value.type);
167
168 // Set up nested object parsing
169 if (std.mem.eql(u8, key_str, "author") and json_type == pdapi.JSONValueType.JSONTable) {
170 decoder.?.willDecodeSublist = null;
171 decoder.?.didDecodeSublist = author_didDecodeSublist;
172 decoder.?.didDecodeTableValue = author_didDecodeTableValue;
173 return;
174 }
175
176 if (std.mem.eql(u8, key_str, "record") and json_type == pdapi.JSONValueType.JSONTable) {
177 decoder.?.willDecodeSublist = null;
178 decoder.?.didDecodeSublist = record_didDecodeSublist;
179 decoder.?.didDecodeTableValue = record_didDecodeTableValue;
180 return;
181 }
182
183 debugDidDecodeTableValue(decoder, key, value);
184}
185
186fn feed_item_didDecodeTableValue(decoder: ?*pdapi.JSONDecoder, key: ?[*:0]const u8, value: pdapi.JSONValue) callconv(.C) void {
187 if (key == null) return;
188
189 const key_str = std.mem.span(key.?);
190 const json_type: pdapi.JSONValueType = @enumFromInt(value.type);
191
192 // Set up post object parsing
193 if (std.mem.eql(u8, key_str, "post") and json_type == pdapi.JSONValueType.JSONTable) {
194 decoder.?.willDecodeSublist = null;
195 decoder.?.didDecodeSublist = post_didDecodeSublist;
196 decoder.?.didDecodeTableValue = post_didDecodeTableValue;
197 return;
198 }
199
200 if (false) {
201 debugDidDecodeTableValue(decoder, key, value);
202 }
203}
204
205fn post_didDecodeSublist(decoder: ?*pdapi.JSONDecoder, name: ?[*:0]const u8, json_type: pdapi.JSONValueType) callconv(.C) ?*anyopaque {
206 _ = name;
207 _ = json_type;
208
209 // Reset to feed item level parsing
210 decoder.?.didDecodeTableValue = feed_item_didDecodeTableValue;
211 decoder.?.willDecodeSublist = null;
212 decoder.?.didDecodeSublist = null;
213
214 return null;
215}
216
217fn willDecodeFeedList(decoder: ?*pdapi.JSONDecoder, name: ?[*:0]const u8, json_type: pdapi.JSONValueType) callconv(.C) void {
218 _ = name;
219
220 // This is called when entering each element in the feed array
221 if (json_type == pdapi.JSONValueType.JSONTable) {
222 // Initialize new post with empty data at current index
223 if (g_post_count.* < g_post_max) {
224 g_posts_out[g_post_count.*] = BskyPost{
225 .author = [_]u8{0} ** AUTHOR_BUFFER_SIZE,
226 .author_len = 0,
227 .content = [_]u8{0} ** CONTENT_BUFFER_SIZE,
228 .content_len = 0,
229 .alt_text = [_]u8{0} ** ALT_TEXT_BUFFER_SIZE,
230 .alt_text_len = 0,
231 .post_type = .normal,
232 .repost_author = [_]u8{0} ** AUTHOR_BUFFER_SIZE,
233 .repost_author_len = 0,
234 .reply_author = [_]u8{0} ** AUTHOR_BUFFER_SIZE,
235 .reply_author_len = 0,
236 };
237 }
238
239 // Set up feed item parsing (looking for "post" object)
240 decoder.?.didDecodeTableValue = feed_item_didDecodeTableValue;
241 }
242}
243
244fn didDecodeFeedList(decoder: ?*pdapi.JSONDecoder, name: ?[*:0]const u8, json_type: pdapi.JSONValueType) callconv(.C) ?*anyopaque {
245 _ = name;
246
247 if (json_type != pdapi.JSONValueType.JSONTable) {
248 return null;
249 }
250
251 // Post parsing complete - increment count if we have valid data
252 if (g_post_count.* < g_post_max) {
253 const has_author = g_posts_out[g_post_count.*].author_len > 0;
254 const has_content = g_posts_out[g_post_count.*].content_len > 0;
255
256 if (has_author or has_content) {
257 g_post_count.* += 1;
258 }
259 }
260
261 // Reset decoder for root-level parsing (back to root after this feed item)
262 decoder.?.didDecodeTableValue = root_didDecodeTableValue;
263 decoder.?.willDecodeSublist = willDecodeFeedList;
264 decoder.?.didDecodeSublist = didDecodeFeedList;
265
266 return null;
267}
268
269fn root_didDecodeTableValue(decoder: ?*pdapi.JSONDecoder, key: ?[*:0]const u8, value: pdapi.JSONValue) callconv(.C) void {
270 const json_type: pdapi.JSONValueType = @enumFromInt(value.type);
271 const path_ptr = decoder.?.path orelse "_root";
272 const path = std.mem.span(path_ptr);
273
274 // Check if we're inside a feed item by looking at the path
275 if (key != null) {
276 const key_str = std.mem.span(key.?);
277
278 // Extract author handle
279 if (std.mem.eql(u8, key_str, "handle") and json_type == pdapi.JSONValueType.JSONString) {
280 // Check if we're in an author context (path should contain something like feed[n].post.author)
281 if (std.mem.indexOf(u8, path, "feed[") != null and std.mem.indexOf(u8, path, ".post.author") != null) {
282 if (g_post_count.* < g_post_max) {
283 const author_str = std.mem.span(value.data.stringval);
284 const copy_len = @min(author_str.len, g_posts_out[g_post_count.*].author.len - 1);
285 @memcpy(g_posts_out[g_post_count.*].author[0..copy_len], author_str[0..copy_len]);
286 g_posts_out[g_post_count.*].author_len = copy_len;
287 g_playdate.system.logToConsole("Found author: %.*s", @as(c_int, @intCast(copy_len)), @as([*:0]const u8, @ptrCast(&g_posts_out[g_post_count.*].author)));
288 }
289 return;
290 }
291 }
292
293 // Extract post text content
294 if (std.mem.eql(u8, key_str, "text") and json_type == pdapi.JSONValueType.JSONString) {
295 // Check if we're in a record context (path should contain something like feed[n].post.record)
296 if (std.mem.indexOf(u8, path, "feed[") != null and std.mem.indexOf(u8, path, ".post.record") != null) {
297 if (g_post_count.* < g_post_max) {
298 const content_str = std.mem.span(value.data.stringval);
299 const copy_len = @min(content_str.len, g_posts_out[g_post_count.*].content.len - 1);
300 @memcpy(g_posts_out[g_post_count.*].content[0..copy_len], content_str[0..copy_len]);
301 g_posts_out[g_post_count.*].content_len = copy_len;
302 const preview_len = @min(50, copy_len);
303 g_playdate.system.logToConsole("Found text content (%d chars): %.*s...", @as(c_int, @intCast(copy_len)), @as(c_int, @intCast(preview_len)), @as([*:0]const u8, @ptrCast(&g_posts_out[g_post_count.*].content)));
304 }
305 return;
306 }
307 }
308
309 // Extract alt text from image embeds
310 if (std.mem.eql(u8, key_str, "alt") and json_type == pdapi.JSONValueType.JSONString) {
311 // Check if we're in an image embed context (path should contain something like feed[n].post.embed.images[])
312 if (std.mem.indexOf(u8, path, "feed[") != null and std.mem.indexOf(u8, path, ".post.embed.images[") != null) {
313 if (g_post_count.* < g_post_max) {
314 const alt_str = std.mem.span(value.data.stringval);
315
316 // Create alt text with [IMG]: prefix
317 const prefix = "[IMG]: ";
318 const prefix_len = prefix.len;
319 const max_alt_len = g_posts_out[g_post_count.*].alt_text.len - prefix_len - 1;
320 const alt_copy_len = @min(alt_str.len, max_alt_len);
321
322 // Copy prefix first
323 @memcpy(g_posts_out[g_post_count.*].alt_text[0..prefix_len], prefix);
324
325 // Copy alt text after prefix
326 @memcpy(g_posts_out[g_post_count.*].alt_text[prefix_len .. prefix_len + alt_copy_len], alt_str[0..alt_copy_len]);
327
328 g_posts_out[g_post_count.*].alt_text_len = prefix_len + alt_copy_len;
329 }
330 return;
331 }
332 }
333
334 // Handle repost detection
335 if (std.mem.eql(u8, key_str, "$type") and json_type == pdapi.JSONValueType.JSONString) {
336 const type_str = std.mem.span(value.data.stringval);
337 // Check if we're in a reason context (indicating a repost)
338 if (std.mem.indexOf(u8, path, "feed[") != null and std.mem.indexOf(u8, path, ".reason") != null) {
339 if (std.mem.eql(u8, type_str, "app.bsky.feed.defs#reasonRepost")) {
340 if (g_post_count.* < g_post_max) {
341 g_posts_out[g_post_count.*].post_type = .repost;
342 g_playdate.system.logToConsole("Detected repost");
343 }
344 }
345 return;
346 }
347 }
348
349 // Handle repost author (the person who reposted)
350 if (std.mem.eql(u8, key_str, "handle") and json_type == pdapi.JSONValueType.JSONString) {
351 // Check if we're in a reason.by context (repost author)
352 if (std.mem.indexOf(u8, path, "feed[") != null and std.mem.indexOf(u8, path, ".reason.by") != null) {
353 if (g_post_count.* < g_post_max) {
354 const repost_author_str = std.mem.span(value.data.stringval);
355 const copy_len = @min(repost_author_str.len, g_posts_out[g_post_count.*].repost_author.len - 1);
356 @memcpy(g_posts_out[g_post_count.*].repost_author[0..copy_len], repost_author_str[0..copy_len]);
357 g_posts_out[g_post_count.*].repost_author_len = copy_len;
358 g_playdate.system.logToConsole("Found repost author: %.*s", @as(c_int, @intCast(copy_len)), @as([*:0]const u8, @ptrCast(&g_posts_out[g_post_count.*].repost_author)));
359 }
360 return;
361 }
362 }
363
364 // Handle reply detection - look for reply field in record
365 if (std.mem.eql(u8, key_str, "reply") and json_type == pdapi.JSONValueType.JSONTable) {
366 // Check if we're in a post.record context
367 if (std.mem.indexOf(u8, path, "feed[") != null and std.mem.indexOf(u8, path, ".post.record") != null) {
368 if (g_post_count.* < g_post_max) {
369 g_posts_out[g_post_count.*].post_type = .reply;
370 g_playdate.system.logToConsole("Detected reply");
371 }
372 return;
373 }
374 }
375
376 // Handle feed item initialization and completion
377 if (std.mem.eql(u8, key_str, "post") and json_type == pdapi.JSONValueType.JSONTable) {
378 // Check if we're at feed[n].post level (not deeper)
379 if (std.mem.indexOf(u8, path, "feed[") != null and std.mem.count(u8, path, ".") == 0) {
380 // Extract the feed index from the path like "feed[5]"
381 const feed_start = std.mem.indexOf(u8, path, "feed[").? + 5;
382 const feed_end = std.mem.indexOf(u8, path[feed_start..], "]").? + feed_start;
383 const index_str = path[feed_start..feed_end];
384 const feed_index = std.fmt.parseInt(i32, index_str, 10) catch -1;
385
386 // If this is a new feed item, complete the previous post
387 if (g_current_feed_index != feed_index) {
388 if (g_current_feed_index >= 0 and g_post_count.* < g_post_max) {
389 // Complete previous post if it has data
390 const has_author = g_posts_out[g_post_count.*].author_len > 0;
391 const has_content = g_posts_out[g_post_count.*].content_len > 0;
392 const has_alt_text = g_posts_out[g_post_count.*].alt_text_len > 0;
393 if (has_author or has_content or has_alt_text) {
394 g_post_count.* += 1;
395 }
396 }
397
398 // Initialize new post
399 g_current_feed_index = feed_index;
400 if (g_post_count.* < g_post_max) {
401 g_posts_out[g_post_count.*] = BskyPost{
402 .author = [_]u8{0} ** AUTHOR_BUFFER_SIZE,
403 .author_len = 0,
404 .content = [_]u8{0} ** CONTENT_BUFFER_SIZE,
405 .content_len = 0,
406 .alt_text = [_]u8{0} ** ALT_TEXT_BUFFER_SIZE,
407 .alt_text_len = 0,
408 .post_type = .normal,
409 .repost_author = [_]u8{0} ** AUTHOR_BUFFER_SIZE,
410 .repost_author_len = 0,
411 .reply_author = [_]u8{0} ** AUTHOR_BUFFER_SIZE,
412 .reply_author_len = 0,
413 };
414 g_playdate.system.logToConsole("Initialized new post at feed index %d", feed_index);
415 }
416 }
417 return;
418 }
419 }
420 }
421
422 if (false) {
423 debugDidDecodeTableValue(decoder, key, value);
424 }
425}
426
427fn debugDidDecodeArrayValue(decoder: ?*pdapi.JSONDecoder, pos: c_int, value: pdapi.JSONValue) callconv(.C) void {
428 _ = decoder;
429 _ = pos;
430 _ = value;
431}
432
433fn debugDidDecodeTableValue(decoder: ?*pdapi.JSONDecoder, key: ?[*:0]const u8, value: pdapi.JSONValue) callconv(.C) void {
434 _ = decoder;
435 _ = key;
436 _ = value;
437}
438
439pub fn decodePostsJson(
440 playdate: *pdapi.PlaydateAPI,
441 buffer: []const u8,
442 buffer_size: usize,
443 post_max: usize,
444 posts: []BskyPost,
445 post_count: *usize,
446 cursor_buffer: []u8,
447 cursor_len: *usize,
448) void {
449 // Initialize globals for parsing
450 g_playdate = playdate;
451 g_posts_out = posts;
452 g_post_count = post_count;
453 g_post_max = post_max;
454 _ = cursor_buffer;
455 _ = cursor_len;
456
457 const pd = playdate;
458
459 if (buffer_size == 0) {
460 pd.system.logToConsole("JSON decode called with empty buffer");
461 return;
462 }
463
464 // Reset parsing state
465 post_count.* = 0;
466 g_parsing_feed = false;
467 g_current_feed_index = -1;
468
469 // Log the first and last few characters of the JSON for debugging
470 pd.system.logToConsole("JSON buffer size: %d bytes", @as(c_int, @intCast(buffer_size)));
471
472 // Log first 100 characters
473 const preview_len = @min(100, buffer_size);
474 var preview_buffer: [101]u8 = undefined;
475 @memcpy(preview_buffer[0..preview_len], buffer[0..preview_len]);
476 preview_buffer[preview_len] = 0;
477 pd.system.logToConsole("JSON start: %s", @as([*:0]const u8, @ptrCast(&preview_buffer)));
478
479 // Log last 50 characters if buffer is large enough
480 if (buffer_size > 50) {
481 const tail_start = buffer_size - 50;
482 var tail_buffer: [51]u8 = undefined;
483 @memcpy(tail_buffer[0..50], buffer[tail_start..buffer_size]);
484 tail_buffer[50] = 0;
485 pd.system.logToConsole("JSON end: %s", @as([*:0]const u8, @ptrCast(&tail_buffer)));
486 }
487
488 if (false) {
489 pdtools.logLargeMessage(pd, buffer, buffer_size);
490 }
491
492 var decoder = pdapi.JSONDecoder{
493 .willDecodeSublist = null,
494 .shouldDecodeTableValueForKey = null,
495 .didDecodeTableValue = root_didDecodeTableValue,
496 .shouldDecodeArrayValueAtIndex = null,
497 .didDecodeArrayValue = null,
498 .didDecodeSublist = null,
499 .decodeError = onDecodeError,
500 .userdata = null,
501 .returnString = 0,
502 .path = null,
503 };
504
505 var jsonValue: pdapi.JSONValue = undefined;
506
507 pd.system.logToConsole("Starting JSON decoding...");
508 const decode_result = pd.json.decodeString(&decoder, @as(?[*:0]const u8, @ptrCast(buffer)), &jsonValue);
509 pd.system.logToConsole("JSON decoding finished with result: %d", decode_result);
510
511 // Complete the final post if we have one
512 if (g_current_feed_index >= 0 and g_post_count.* < g_post_max) {
513 const has_author = g_posts_out[g_post_count.*].author_len > 0;
514 const has_content = g_posts_out[g_post_count.*].content_len > 0;
515 const has_alt_text = g_posts_out[g_post_count.*].alt_text_len > 0;
516 pd.system.logToConsole("Final post check: author=%d content=%d alt=%d", @as(c_int, if (has_author) 1 else 0), @as(c_int, if (has_content) 1 else 0), @as(c_int, if (has_alt_text) 1 else 0));
517 if (has_author or has_content or has_alt_text) {
518 g_post_count.* += 1;
519 pd.system.logToConsole("Added final post, total count: %d", @as(c_int, @intCast(g_post_count.*)));
520 }
521 }
522
523 pd.system.logToConsole("JSON parsing complete - final post count: %d", @as(c_int, @intCast(g_post_count.*)));
524}
525
526pub fn renderPost(
527 pd: *pdapi.PlaydateAPI,
528 post: *const BskyPost,
529 x: c_int,
530 y: c_int,
531 width: c_int,
532 dry_run: bool,
533) usize {
534 const font = fonts.g_font;
535 const padding = 4;
536 const corner_radius = 4;
537
538 // Account for post padding
539 const content_width = @as(?c_int, @intCast(width - (padding * 2)));
540
541 // Author height
542 const author_len = std.mem.len(@as([*:0]const u8, @ptrCast(&post.author)));
543 const author_drawable = DrawableText{
544 .playdate = pd,
545 .text = post.author[0..author_len],
546 .max_width = content_width,
547 .font = font,
548 .wrapping_mode = .WrapWord,
549 .alignment = .AlignTextLeft,
550 };
551
552 // Content height
553 const content_len = std.mem.len(@as([*:0]const u8, @ptrCast(&post.content)));
554 const content_drawable = DrawableText{
555 .playdate = pd,
556 .text = post.content[0..content_len],
557 .max_width = content_width,
558 .font = font,
559 .wrapping_mode = .WrapWord,
560 .alignment = .AlignTextLeft,
561 };
562
563 // Alt text height
564 const alt_text_len = std.mem.len(@as([*:0]const u8, @ptrCast(&post.alt_text)));
565 var alt_drawable = DrawableText{
566 .playdate = pd,
567 .text = post.alt_text[0..alt_text_len],
568 .max_width = content_width,
569 .font = font,
570 .wrapping_mode = .WrapWord,
571 .alignment = .AlignTextLeft,
572 };
573
574 // Render the post (it's at least partially visible)
575
576 // Create DrawableText for @ symbol and author text
577 const at_symbol = "@";
578
579 const at_drawable = DrawableText{
580 .playdate = pd,
581 .text = at_symbol,
582 .max_width = content_width,
583 .font = font,
584 .wrapping_mode = .WrapWord,
585 .alignment = .AlignTextLeft,
586 };
587
588 var current_y = y + padding;
589 const current_x = x + padding;
590
591 // Draw @ symbol and author text
592 if (author_len > 0) {
593 at_drawable.render(current_x, current_y, dry_run);
594 const at_width = at_drawable.getWidth();
595 author_drawable.render(current_x + at_width, current_y, dry_run);
596 current_y += author_drawable.getHeight();
597 }
598
599 // Draw post content with text wrapping
600 content_drawable.render(current_x, current_y, dry_run);
601 current_y += content_drawable.getHeight();
602 current_y += padding;
603
604 // Draw alt text if available
605 if (alt_text_len > 0) {
606 alt_drawable.render(current_x, current_y, dry_run);
607 current_y += alt_drawable.getHeight();
608 current_y += padding;
609 }
610
611 const height = current_y - y;
612
613 if (!dry_run) {
614 pd.graphics.drawRoundRect(
615 x,
616 y,
617 width,
618 height,
619 corner_radius,
620 1,
621 @intFromEnum(pdapi.LCDSolidColor.ColorBlack),
622 );
623 }
624
625 return @as(usize, @intCast(height)); // Return total height of the rendered post
626}