A Bluesky Playdate client
at main 626 lines 24 kB view raw
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}