A Bluesky Playdate client
1const std = @import("std");
2const pdapi = @import("playdate_api_definitions.zig");
3const network = @import("network.zig");
4const bsky_post = @import("bsky_post.zig");
5const pdtools = @import("pdtools/index.zig");
6const ScrollingValue = @import("pdtools/ScrollingValue.zig").SlidingValue;
7const panic_handler = @import("panic_handler.zig");
8const defs = @import("definitions.zig");
9const keyboard_mod = @import("pdtools/keyboard.zig");
10const Keyboard = keyboard_mod.Keyboard(256);
11
12const fonts = @import("fonts.zig");
13
14// Global buffers to avoid large stack allocations
15var g_headers_buffer: [512]u8 = undefined;
16
17// Login input state
18const LoginInputState = enum {
19 username,
20 password,
21 ready,
22};
23
24// Login field selection
25const LoginFieldSelection = enum {
26 username,
27 password,
28 login_button,
29};
30
31// Post field selection
32const PostFieldSelection = enum {
33 edit_text,
34 confirm_post,
35};
36
37// Simple app state for timeline fetching
38const AppState = struct {
39 playdate: *pdapi.PlaydateAPI,
40 message: []const u8,
41 response_buffer: [defs.RESPONSE_BUFFER_SIZE]u8 = undefined,
42 response_length: usize,
43
44 posts: [bsky_post.MAX_POSTS]bsky_post.BskyPost,
45 post_count: usize,
46 cursor_buffer: [bsky_post.CURSOR_BUFFER_SIZE]u8,
47 cursor_len: usize,
48
49 network_access_requested: bool,
50 body_scroll: ScrollingValue,
51
52 // Login state
53 is_logged_in: bool,
54 access_token: [512]u8,
55 access_token_len: usize,
56 user_did: [256]u8,
57 user_did_len: usize,
58
59 keyboard: keyboard_mod.Keyboard(256),
60
61 // Login input state
62 login_input_state: LoginInputState,
63 login_field_selection: LoginFieldSelection,
64 username: [defs.MAX_CREDENTIAL_LENGTH]u8,
65 password: [defs.MAX_CREDENTIAL_LENGTH]u8,
66
67 previous_buttons: pdapi.PDButtons,
68
69 // Post composition state
70 post_field_selection: PostFieldSelection,
71 post_text: [defs.EDITOR_BUFFER_SIZE]u8,
72 post_text_len: usize,
73};
74
75var g_app: AppState = undefined;
76
77const Page = enum {
78 login,
79 home,
80 post,
81};
82
83var g_selected_page: Page = .login;
84
85const LCD_WIDTH = pdapi.LCD_COLUMNS;
86const LCD_HEIGHT = pdapi.LCD_ROWS;
87const MARGIN = 4;
88const CHUNK_SIZE = 512; // Read 512 bytes at a time
89
90// Credential storage functions
91fn saveCredentials(pd: *pdapi.PlaydateAPI) void {
92 pdtools.saveStringFile(pd, defs.USERNAME_FILENAME, &g_app.username);
93 pdtools.saveStringFile(pd, defs.PASSWORD_FILENAME, &g_app.password);
94}
95
96fn loadCredentials(pd: *pdapi.PlaydateAPI) void {
97 pd.system.logToConsole("Loading username from: %s", defs.USERNAME_FILENAME.ptr);
98 pdtools.loadStringFile(pd, defs.USERNAME_FILENAME, &g_app.username);
99 pd.system.logToConsole("Loading password from: %s", defs.PASSWORD_FILENAME.ptr);
100 pdtools.loadStringFile(pd, defs.PASSWORD_FILENAME, &g_app.password);
101}
102
103fn clearCredentials(pd: *pdapi.PlaydateAPI) void {
104 // Clear in-memory credentials
105 @memset(&g_app.username, 0);
106 @memset(&g_app.password, 0);
107 g_app.login_input_state = .username;
108 g_app.login_field_selection = .username;
109
110 pdtools.delStringFile(pd, defs.USERNAME_FILENAME);
111 pdtools.delStringFile(pd, defs.PASSWORD_FILENAME);
112
113 pd.system.logToConsole("Credentials cleared");
114 g_app.message = "Credentials cleared - enter new ones";
115}
116
117// Network callback functions
118fn onLoginSuccess(status_code: network.HttpStatusCode, response_data: []const u8) void {
119 const pd = g_app.playdate;
120 pd.system.logToConsole("Login successful with status: %d", status_code);
121
122 // Parse the JSON to extract access token
123 if (std.mem.indexOf(u8, response_data, "\"accessJwt\":\"")) |start_pos| {
124 const token_start = start_pos + 13; // Length of "accessJwt":"
125 if (std.mem.indexOfPos(u8, response_data, token_start, "\"")) |end_pos| {
126 const token_len = end_pos - token_start;
127 if (token_len < g_app.access_token.len) {
128 @memcpy(g_app.access_token[0..token_len], response_data[token_start..end_pos]);
129 g_app.access_token_len = token_len;
130
131 // Also parse the DID from the response
132 if (std.mem.indexOf(u8, response_data, "\"did\":\"")) |did_start_pos| {
133 const did_token_start = did_start_pos + 7; // Length of "did":"
134 if (std.mem.indexOfPos(u8, response_data, did_token_start, "\"")) |did_end_pos| {
135 const did_len = did_end_pos - did_token_start;
136 if (did_len < g_app.user_did.len) {
137 @memcpy(g_app.user_did[0..did_len], response_data[did_token_start..did_end_pos]);
138 g_app.user_did[did_len] = 0;
139 g_app.user_did_len = did_len;
140 pd.system.logToConsole("User DID extracted: %.*s", @as(c_int, @intCast(did_len)), @as([*c]const u8, @ptrCast(&g_app.user_did)));
141 }
142 }
143 }
144
145 g_app.is_logged_in = true;
146 g_app.message = "Login successful - fetching posts...";
147 g_selected_page = .home;
148 pd.system.logToConsole("Access token extracted successfully (length: %d)", @as(c_int, @intCast(token_len)));
149
150 // Automatically fetch posts after successful login
151 fetchBlueskyFeed();
152 } else {
153 g_app.message = "Access token too long";
154 pd.system.logToConsole("Access token too long: %d", @as(c_int, @intCast(token_len)));
155 }
156 } else {
157 g_app.message = "Failed to parse access token";
158 pd.system.logToConsole("Failed to find end of access token");
159 }
160 } else {
161 g_app.message = "Access token not found";
162 pd.system.logToConsole("Access token not found in response");
163 }
164}
165
166fn onLoginFailure(status_code: network.HttpStatusCode, error_message: []const u8) void {
167 const pd = g_app.playdate;
168 pd.system.logToConsole("Login failed with status: %d", status_code);
169 pd.system.logToConsole("Error: %.*s", @as(c_int, @intCast(error_message.len)), @as([*c]const u8, @ptrCast(error_message.ptr)));
170 g_app.message = if (status_code == 0) "Login connection error" else "Login failed";
171}
172
173fn onFeedSuccess(status_code: network.HttpStatusCode, response_data: []const u8) void {
174 const pd = g_app.playdate;
175 pd.system.logToConsole("Feed fetch successful with status: %d, %d bytes", status_code, response_data.len);
176
177 // The response_data is already pointing to our g_app.response_buffer
178 // So we just need to set the length - no copying needed
179 g_app.response_length = response_data.len;
180
181 // Ensure null termination
182 if (g_app.response_length < g_app.response_buffer.len) {
183 g_app.response_buffer[g_app.response_length] = 0;
184 }
185
186 g_app.message = "Feed data received";
187
188 // Parse the JSON response into posts
189 parseFeedResponseIntoPosts();
190}
191
192fn onFeedFailure(status_code: network.HttpStatusCode, error_message: []const u8) void {
193 const pd = g_app.playdate;
194 pd.system.logToConsole("Feed fetch failed with status: %d", status_code);
195
196 if (error_message.len > 0) {
197 pd.system.logToConsole("Error message: %.*s", @as(c_int, @intCast(error_message.len)), @as([*c]const u8, @ptrCast(error_message.ptr)));
198 } else {
199 pd.system.logToConsole("No error message provided");
200 }
201
202 // Clear any previous response data
203 g_app.response_length = 0;
204 g_app.post_count = 0;
205
206 if (status_code == 0) {
207 g_app.message = "Network connection failed";
208 } else if (status_code == 401) {
209 g_app.message = "Login expired - please re-login";
210 } else if (status_code >= 400 and status_code < 500) {
211 g_app.message = "Request error - check credentials";
212 } else if (status_code >= 500) {
213 g_app.message = "Server error - try again later";
214 } else {
215 g_app.message = "Feed fetch failed";
216 }
217}
218
219fn onPostSuccess(status_code: network.HttpStatusCode, response_data: []const u8) void {
220 const pd = g_app.playdate;
221 pd.system.logToConsole("Post successful with status: %d", status_code);
222
223 // Clear the post text since it was successfully posted
224 @memset(&g_app.post_text, 0);
225 g_app.post_text_len = 0;
226
227 // Reset to default text for next post
228 const default_post_text = "Hello World!\n\nPosted from my #playdate";
229 const default_len = @min(default_post_text.len, g_app.post_text.len - 1);
230 @memcpy(g_app.post_text[0..default_len], default_post_text[0..default_len]);
231 g_app.post_text[default_len] = 0;
232 g_app.post_text_len = default_len;
233
234 g_app.message = "Post published successfully!";
235
236 // Log response for debugging
237 if (response_data.len > 0) {
238 const preview_len = @min(100, response_data.len);
239 pd.system.logToConsole("Post response: %.*s", @as(c_int, @intCast(preview_len)), @as([*c]const u8, @ptrCast(response_data.ptr)));
240 }
241}
242
243fn onPostFailure(status_code: network.HttpStatusCode, error_message: []const u8) void {
244 const pd = g_app.playdate;
245 pd.system.logToConsole("Post failed with status: %d", status_code);
246
247 if (error_message.len > 0) {
248 pd.system.logToConsole("Post error: %.*s", @as(c_int, @intCast(error_message.len)), @as([*c]const u8, @ptrCast(error_message.ptr)));
249 }
250
251 if (status_code == 0) {
252 g_app.message = "Post failed - network error";
253 } else if (status_code == 401) {
254 g_app.message = "Post failed - please re-login";
255 } else if (status_code == 413) {
256 g_app.message = "Post too long - please shorten";
257 } else if (status_code >= 400 and status_code < 500) {
258 g_app.message = "Post failed - invalid request";
259 } else if (status_code >= 500) {
260 g_app.message = "Post failed - server error";
261 } else {
262 g_app.message = "Post failed - unknown error";
263 }
264}
265
266fn parseFeedResponseIntoPosts() void {
267 // Check if we have a valid response
268 if (g_app.response_length == 0) {
269 g_app.playdate.system.logToConsole("No response data to parse");
270 g_app.message = "No response data received";
271 return;
272 }
273
274 // Validate response length isn't too large
275 if (g_app.response_length >= g_app.response_buffer.len) {
276 g_app.playdate.system.logToConsole("WARNING: Response length %d exceeds buffer size %d", @as(c_int, @intCast(g_app.response_length)), @as(c_int, @intCast(g_app.response_buffer.len)));
277 g_app.response_length = g_app.response_buffer.len - 1;
278 }
279
280 g_app.playdate.system.logToConsole("Starting JSON parsing of %d bytes", @as(c_int, @intCast(g_app.response_length)));
281
282 // Log first few characters for debugging
283 const preview_len = @min(100, g_app.response_length);
284 g_app.playdate.system.logToConsole("Response preview (first %d chars): %.*s", @as(c_int, @intCast(preview_len)), @as(c_int, @intCast(preview_len)), @as([*]const u8, @ptrCast(&g_app.response_buffer)));
285
286 // Reset post count before parsing
287 g_app.post_count = 0;
288
289 // Add error handling around JSON parsing
290 bsky_post.decodePostsJson(
291 g_app.playdate,
292 &g_app.response_buffer,
293 g_app.response_length,
294 g_app.posts.len,
295 &g_app.posts,
296 &g_app.post_count,
297 &g_app.cursor_buffer,
298 &g_app.cursor_len,
299 );
300
301 g_app.playdate.system.logToConsole("JSON parsing completed, found %d posts", @as(c_int, @intCast(g_app.post_count)));
302
303 if (g_app.post_count == 0) {
304 g_app.message = "No posts found in response";
305 } else {
306 g_app.message = "Feed loaded successfully";
307 }
308}
309
310fn onScrollMaxThreshold() void {
311 // Handle scroll max threshold being reached
312 g_app.playdate.system.logToConsole("Scroll max threshold reached");
313}
314
315fn onScrollMinThreshold() void {
316 // Handle scroll min threshold being reached
317 g_app.playdate.system.logToConsole("Scroll min threshold reached");
318
319 // Refresh posts.
320 g_app.playdate.system.logToConsole("Refreshing posts...");
321 fetchBlueskyFeed();
322}
323
324pub export fn eventHandler(playdate: *pdapi.PlaydateAPI, event: pdapi.PDSystemEvent, arg: u32) callconv(.C) c_int {
325 _ = arg;
326 switch (event) {
327 .EventInit => {
328 playdate.system.logToConsole("Initializing timeline fetch app...");
329
330 // Initialize panic handler for better error reporting
331 panic_handler.init(playdate);
332
333 // Test basic memory allocation to catch early issues
334 playdate.system.logToConsole("Testing basic memory allocation...");
335
336 playdate.system.logToConsole("Memory test passed - AppState size: %d bytes", @as(c_int, @intCast(@sizeOf(AppState))));
337
338 playdate.system.logToConsole("Initializing full application");
339
340 // Initialize full app state
341 g_app.playdate = playdate;
342
343 fonts.initializeFonts(playdate);
344
345 // Initialize keyboard
346 g_app.keyboard = keyboard_mod.Keyboard(256){
347 .playdate = playdate,
348 .font = fonts.g_font,
349 .title = undefined,
350 .editor_buffer = undefined,
351 .output_buffer = undefined,
352 };
353
354 g_app.message = "Use ← → to navigate, B to interact";
355 g_app.response_length = 0;
356 g_app.post_count = 0;
357 g_app.cursor_len = 0;
358 g_app.network_access_requested = false;
359
360 // Initialize scrolling value for posts with reasonable bounds
361 g_app.body_scroll = ScrollingValue{
362 .playdate = playdate,
363 .soft_min_runout = 100, // Static runout distance from min_value
364 .soft_max_runout = 100, // Static runout distance from max_value
365 .runout_margin = 5.0, // Margin before callback detection
366 .min_value = -200, // Allow scrolling above top with hard limit
367 .max_value = 2000, // Hard limit - will be updated based on content
368 .current_value = 0,
369 .current_height = 0,
370 .crank_position_offset = 0,
371 .onScrollMaxThreshold = &onScrollMaxThreshold,
372 .onScrollMinThreshold = &onScrollMinThreshold,
373 };
374 g_app.is_logged_in = false;
375 g_app.access_token_len = 0;
376 g_app.user_did_len = 0;
377 // Initialize login state with default credentials for testing
378 g_app.login_input_state = .ready; // Start ready since we have default credentials
379 g_app.login_field_selection = .username; // Start with username selected
380
381 // Initialize with empty credentials first
382 @memset(&g_app.username, 0);
383 @memset(&g_app.password, 0);
384
385 g_app.previous_buttons = 0;
386
387 // Initialize post composition state with default text
388 g_app.post_field_selection = .edit_text;
389 const default_post_text = "Hello World!\n\nPosted from my #playdate";
390 const default_len = @min(default_post_text.len, g_app.post_text.len - 1);
391 @memcpy(g_app.post_text[0..default_len], default_post_text[0..default_len]);
392 g_app.post_text[default_len] = 0;
393 g_app.post_text_len = default_len;
394
395 // Set up real update callback with full functionality
396 playdate.system.setUpdateCallback(update_and_render, null);
397 playdate.system.logToConsole("Update callback set");
398
399 playdate.system.logToConsole("App initialized successfully");
400
401 playdate.graphics.clear(@intFromEnum(pdapi.LCDSolidColor.ColorWhite));
402
403 // Try to load saved credentials
404 playdate.system.logToConsole("Attempting to load saved credentials...");
405 loadCredentials(playdate);
406 g_app.login_input_state = .ready;
407 playdate.system.logToConsole("Credentials loaded - Username: %s", &g_app.username);
408 g_app.message = "Saved credentials loaded";
409 },
410
411 else => {},
412 }
413 return 0;
414}
415
416fn loginToBluesky() void {
417 const pd = g_app.playdate;
418
419 // Check if we're already busy with a network operation
420 if (!network.isNetworkIdle()) {
421 return;
422 }
423
424 // Request network access
425 if (!g_app.network_access_requested) {
426 pd.system.logToConsole("Requesting network access for https://bsky.social...");
427 const response = pd.network.playdate_http.requestAccess("https://bsky.social", 443, true, "login", null, null);
428 g_app.network_access_requested = true;
429
430 switch (response) {
431 .AccessAllow => {
432 pd.system.logToConsole("Network access GRANTED");
433 },
434 .AccessDeny => {
435 pd.system.logToConsole("Network access DENIED by user");
436 g_app.message = "Network access denied";
437 return;
438 },
439 else => {
440 pd.system.logToConsole("Network access request FAILED");
441 g_app.message = "Access request failed";
442 return;
443 },
444 }
445 } else {
446 pd.system.logToConsole("Network access already requested");
447 }
448
449 // Check if we have credentials
450 if (g_app.username[0] == 0 or g_app.password[0] == 0) {
451 g_app.message = "Please enter username and password";
452 g_app.login_input_state = if (g_app.username[0] == 0) .username else .password;
453 return;
454 }
455
456 // Prepare login request body using entered credentials
457 var login_body: [512]u8 = undefined;
458 const username = std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.username)));
459 const password = std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.password)));
460
461 // Debug logging for credentials
462 pd.system.logToConsole("Login credentials - Username: %s", @as([*:0]const u8, @ptrCast(&g_app.username)));
463 pd.system.logToConsole("Login credentials - Password: %s", @as([*:0]const u8, @ptrCast(&g_app.password)));
464 const login_json = std.fmt.bufPrint(&login_body, "{{\"identifier\":\"{s}\",\"password\":\"{s}\"}}", .{ username, password }) catch {
465 g_app.message = "Failed to format login request";
466 return;
467 };
468
469 // Create HTTP request
470 const request = network.HttpRequest{
471 .method = .POST,
472 .url = "https://bsky.social/xrpc/com.atproto.server.createSession",
473 .server = "bsky.social",
474 .port = 443,
475 .path = "/xrpc/com.atproto.server.createSession",
476 .use_https = true,
477 .bearer_token = null,
478 .body = login_json,
479 .response_buffer = &g_app.response_buffer,
480 .success_callback = onLoginSuccess,
481 .failure_callback = onLoginFailure,
482 };
483
484 if (network.makeHttpRequest(pd, request)) {
485 g_app.message = "Logging in...";
486 } else {
487 g_app.message = "Network connection failed - check internet";
488 pd.system.logToConsole("Troubleshooting suggestions:");
489 pd.system.logToConsole("- Check internet connection");
490 pd.system.logToConsole("- Try in Playdate simulator vs device");
491 pd.system.logToConsole("- Check firewall settings");
492 pd.system.logToConsole("- Verify DNS resolution works");
493 }
494}
495
496fn fetchBlueskyFeed() void {
497 const pd = g_app.playdate;
498
499 // Check if logged in
500 if (!g_app.is_logged_in) {
501 g_app.message = "Please log in first";
502 return;
503 }
504
505 // Check if we're already busy with a network operation
506 if (!network.isNetworkIdle()) {
507 return;
508 }
509
510 // Reset state for refetch
511 g_app.post_count = 0;
512 g_app.body_scroll.setValue(0);
513
514 // Request network access only once
515 if (!g_app.network_access_requested) {
516 pd.system.logToConsole("Requesting network access...");
517 const response = pd.network.playdate_http.requestAccess("https://bsky.social", 443, true, "timeline", null, null);
518 g_app.network_access_requested = true;
519
520 switch (response) {
521 .AccessAllow => {},
522 .AccessDeny => {
523 g_app.message = "Network access denied";
524 return;
525 },
526 else => {
527 g_app.message = "Access request failed";
528 return;
529 },
530 }
531 }
532
533 // Create HTTP request with bearer token
534 const bearer_token = g_app.access_token[0..g_app.access_token_len];
535 const request = network.HttpRequest{
536 .method = .GET,
537 .url = "https://bsky.social/xrpc/app.bsky.feed.getTimeline?limit=50",
538 .server = "bsky.social",
539 .port = 443,
540 .path = "/xrpc/app.bsky.feed.getTimeline?limit=50",
541 .use_https = true,
542 .bearer_token = bearer_token,
543 .body = null,
544 .response_buffer = &g_app.response_buffer,
545 .success_callback = onFeedSuccess,
546 .failure_callback = onFeedFailure,
547 };
548
549 if (network.makeHttpRequest(pd, request)) {
550 g_app.message = "Fetching feed data...";
551 } else {
552 g_app.message = "Failed to start feed request";
553 }
554}
555
556// Login keyboard callbacks
557fn usernameKeyboardCancelled() void {
558 g_app.playdate.system.logToConsole("Username input cancelled");
559 g_app.login_input_state = .username; // Stay on username input
560}
561
562fn usernameKeyboardConfirmed(text: []const u8) void {
563 g_app.playdate.system.logToConsole("Username entered: %.*s", @as(c_int, @intCast(text.len)), @as([*c]const u8, @ptrCast(text.ptr)));
564
565 // Copy username
566 const copy_len = @min(text.len, g_app.username.len - 1);
567 @memcpy(g_app.username[0..copy_len], text[0..copy_len]);
568 g_app.username[copy_len] = 0;
569
570 saveCredentials(g_app.playdate);
571
572 // Move to password input
573 g_app.login_input_state = .password;
574}
575
576fn passwordKeyboardCancelled() void {
577 g_app.playdate.system.logToConsole("Password input cancelled");
578 g_app.login_input_state = .username; // Go back to username input
579}
580
581fn passwordKeyboardConfirmed(text: []const u8) void {
582 g_app.playdate.system.logToConsole("Password entered (length: %d)", @as(c_int, @intCast(text.len)));
583
584 // Copy password
585 const copy_len = @min(text.len, g_app.password.len - 1);
586 @memcpy(g_app.password[0..copy_len], text[0..copy_len]);
587 g_app.password[copy_len] = 0;
588
589 saveCredentials(g_app.playdate);
590
591 g_app.login_input_state = .ready;
592}
593
594// JSON writer context for building post JSON
595const JsonWriteContext = struct {
596 buffer: []u8,
597 pos: usize,
598};
599
600// JSON write callback for Playdate JSON encoder
601fn jsonWriteCallback(userdata: ?*anyopaque, str: [*c]const u8, len: c_int) callconv(.C) void {
602 if (userdata == null) return;
603 const context: *JsonWriteContext = @ptrCast(@alignCast(userdata.?));
604 const string_len = @as(usize, @intCast(len));
605
606 if (context.pos + string_len > context.buffer.len) return; // Buffer overflow protection
607
608 const src_slice = @as([*]const u8, @ptrCast(str))[0..string_len];
609 @memcpy(context.buffer[context.pos .. context.pos + string_len], src_slice);
610 context.pos += string_len;
611}
612
613fn submitBlueskyPost() void {
614 const pd = g_app.playdate;
615
616 // Check if logged in
617 if (!g_app.is_logged_in) {
618 g_app.message = "Please log in first";
619 return;
620 }
621
622 // Check if we have post content
623 if (g_app.post_text_len == 0) {
624 g_app.message = "Please enter post content first";
625 return;
626 }
627
628 // Check if we're already busy with a network operation
629 if (!network.isNetworkIdle()) {
630 return;
631 }
632
633 // Request network access if not already done
634 if (!g_app.network_access_requested) {
635 const response = pd.network.playdate_http.requestAccess("https://bsky.social", 443, true, "post", null, null);
636 g_app.network_access_requested = true;
637
638 switch (response) {
639 .AccessAllow => {},
640 .AccessDeny => {
641 g_app.message = "Network access denied";
642 return;
643 },
644 else => {
645 g_app.message = "Access request failed";
646 return;
647 },
648 }
649 }
650
651 // Get current timestamp in ISO 8601 format using accurate Playdate API
652 const seconds_since_2000 = pd.system.getSecondsSinceEpoch(null);
653
654 // Convert to PDDateTime for formatting
655 var datetime: pdapi.PDDateTime = undefined;
656 pd.system.convertEpochToDateTime(seconds_since_2000, &datetime);
657
658 // Format as ISO 8601 using the utility function
659 const timestamp = pdtools.datetimeToISO(datetime);
660
661 // Check if we have user DID
662 if (g_app.user_did_len == 0) {
663 g_app.message = "Missing user DID - please re-login";
664 return;
665 }
666
667 // Prepare post request body using Playdate JSON encoder
668 var post_body: [1024]u8 = undefined;
669 var json_context = JsonWriteContext{ .buffer = &post_body, .pos = 0 };
670
671 var encoder: pdapi.JSONEncoder = undefined;
672 pd.json.initEncoder(&encoder, jsonWriteCallback, &json_context, 0);
673
674 const post_text = g_app.post_text[0..g_app.post_text_len];
675 const user_did = g_app.user_did[0..g_app.user_did_len];
676
677 // Build JSON: {"repo":"...", "collection":"...", "record":{"text":"...", "createdAt":"..."}}
678 encoder.startTable(&encoder);
679
680 // Add repo field
681 encoder.addTableMember(&encoder, "repo", 4);
682 encoder.writeString(&encoder, @as([*c]const u8, @ptrCast(user_did.ptr)), @as(c_int, @intCast(user_did.len)));
683
684 // Add collection field
685 encoder.addTableMember(&encoder, "collection", 10);
686 encoder.writeString(&encoder, "app.bsky.feed.post", 18);
687
688 // Add record field
689 encoder.addTableMember(&encoder, "record", 6);
690 encoder.startTable(&encoder);
691
692 // Add text field to record
693 encoder.addTableMember(&encoder, "text", 4);
694 encoder.writeString(&encoder, @as([*c]const u8, @ptrCast(post_text.ptr)), @as(c_int, @intCast(post_text.len)));
695
696 // Add createdAt field to record
697 encoder.addTableMember(&encoder, "createdAt", 9);
698 encoder.writeString(&encoder, @as([*c]const u8, @ptrCast(timestamp.ptr)), @as(c_int, @intCast(timestamp.len)));
699
700 encoder.endTable(&encoder); // End record
701 encoder.endTable(&encoder); // End root
702
703 const post_json = post_body[0..json_context.pos];
704
705 // Log json
706 pdtools.logLargeMessage(pd, post_json, post_json.len);
707
708 // Create HTTP request
709 if (defs.DEBUG_DONT_POST) {
710 g_app.message = "Debug mode - not posting";
711 return;
712 }
713
714 const bearer_token = g_app.access_token[0..g_app.access_token_len];
715 const request = network.HttpRequest{
716 .method = .POST,
717 .url = "https://bsky.social/xrpc/com.atproto.repo.createRecord",
718 .server = "bsky.social",
719 .port = 443,
720 .path = "/xrpc/com.atproto.repo.createRecord",
721 .use_https = true,
722 .bearer_token = bearer_token,
723 .body = post_json,
724 .response_buffer = &g_app.response_buffer,
725 .success_callback = onPostSuccess,
726 .failure_callback = onPostFailure,
727 };
728
729 if (network.makeHttpRequest(pd, request)) {
730 g_app.message = "Posting...";
731 } else {
732 g_app.message = "Failed to start post request";
733 }
734}
735
736// Post keyboard callbacks
737fn postKeyboardCancelled() void {
738 g_app.playdate.system.logToConsole("Post input cancelled");
739 // Stay on post page, don't change anything
740}
741
742// New callback for text editing that doesn't auto-submit
743fn postTextEditConfirmed(text: []const u8) void {
744 g_app.playdate.system.logToConsole("Post text edited (length: %d)", @as(c_int, @intCast(text.len)));
745
746 // Copy post text but don't submit automatically
747 const copy_len = @min(text.len, g_app.post_text.len - 1);
748 @memcpy(g_app.post_text[0..copy_len], text[0..copy_len]);
749 g_app.post_text[copy_len] = 0;
750 g_app.post_text_len = copy_len;
751
752 g_app.message = "Post text updated - use confirm button to post";
753}
754
755fn update_and_render(userdata: ?*anyopaque) callconv(.C) c_int {
756 _ = userdata; // Ignore userdata, use global state
757
758 // Safety check - ensure app is initialized
759 const pd = g_app.playdate;
760
761 // Update network processing
762 network.updateNetwork(pd);
763
764 if (g_app.keyboard.updateAndRender()) {
765 return 1;
766 }
767
768 // Handle button input
769 var current: pdapi.PDButtons = undefined;
770 var pushed: pdapi.PDButtons = undefined;
771 var released: pdapi.PDButtons = undefined;
772 pd.system.getButtonState(¤t, &pushed, &released);
773
774 // Handle keyboard input first if keyboard is active
775
776 // Handle up/down navigation for login field selection
777 if (g_selected_page == .login and !g_app.is_logged_in) {
778 if (pushed & pdapi.BUTTON_UP != 0) {
779 g_app.login_field_selection = switch (g_app.login_field_selection) {
780 .username => .login_button,
781 .password => .username,
782 .login_button => .password,
783 };
784 } else if (pushed & pdapi.BUTTON_DOWN != 0) {
785 g_app.login_field_selection = switch (g_app.login_field_selection) {
786 .username => .password,
787 .password => .login_button,
788 .login_button => .username,
789 };
790 }
791 }
792
793 // Handle up/down navigation for post field selection
794 if (g_selected_page == .post and g_app.is_logged_in) {
795 if (pushed & pdapi.BUTTON_UP != 0) {
796 g_app.post_field_selection = switch (g_app.post_field_selection) {
797 .edit_text => .confirm_post,
798 .confirm_post => .edit_text,
799 };
800 } else if (pushed & pdapi.BUTTON_DOWN != 0) {
801 g_app.post_field_selection = switch (g_app.post_field_selection) {
802 .edit_text => .confirm_post,
803 .confirm_post => .edit_text,
804 };
805 }
806 }
807
808 // Check if left/right was pressed to change page selection
809 if (pushed & pdapi.BUTTON_LEFT != 0) {
810 g_selected_page = switch (g_selected_page) {
811 .login => if (g_app.is_logged_in) .post else .login,
812 .home => .login,
813 .post => .home,
814 };
815 } else if (pushed & pdapi.BUTTON_RIGHT != 0) {
816 g_selected_page = switch (g_selected_page) {
817 .login => if (g_app.is_logged_in) .home else .login,
818 .home => if (g_app.is_logged_in) .post else .login,
819 .post => .login,
820 };
821 }
822
823 // Check if B button was pushed (only if keyboard not active)
824 if (pushed & pdapi.BUTTON_B != 0) {
825 if (g_selected_page == .login and !g_app.is_logged_in) {
826 // Handle login input based on selected field
827 switch (g_app.login_field_selection) {
828 .username => {
829 // Start username input
830 const initial_text = if (g_app.username[0] != 0) std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.username))) else null;
831 g_app.keyboard.start(
832 "Enter username:",
833 g_app.username.len,
834 initial_text,
835 false,
836 1,
837 usernameKeyboardCancelled,
838 usernameKeyboardConfirmed,
839 );
840 },
841 .password => {
842 // Start password input
843 const initial_text = if (g_app.password[0] != 0) std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.password))) else null;
844 g_app.keyboard.start(
845 "Enter password:",
846 g_app.password.len,
847 initial_text,
848 false,
849 1,
850 passwordKeyboardCancelled,
851 passwordKeyboardConfirmed,
852 );
853 },
854 .login_button => {
855 // Check if we can login
856 if (g_app.username[0] > 0 and g_app.password[0] > 0) {
857 loginToBluesky();
858 } else {
859 g_app.message = "Please enter username and password";
860 }
861 },
862 }
863 } else if (g_selected_page == .home) {
864 fetchBlueskyFeed();
865 } else if (g_selected_page == .post) {
866 // Check if user is logged in before allowing post creation
867 if (!g_app.is_logged_in) {
868 g_app.message = "Please log in first to create posts";
869 g_selected_page = .login;
870 } else {
871 // Handle post field selection
872 switch (g_app.post_field_selection) {
873 .edit_text => {
874 // Start post composition with keyboard (multiline enabled)
875 const initial_text = if (g_app.post_text_len > 0) g_app.post_text[0..g_app.post_text_len] else null;
876 g_app.keyboard.start("Enter your post:", 300, initial_text, true, 5, postKeyboardCancelled, postTextEditConfirmed);
877 },
878 .confirm_post => {
879 // Submit the post directly
880 if (g_app.post_text_len > 0) {
881 submitBlueskyPost();
882 } else {
883 g_app.message = "Please enter some text first";
884 }
885 },
886 }
887 }
888 }
889 }
890
891 // Render keyboard if active, otherwise render the selected page
892 switch (g_selected_page) {
893 .login => render_login(0, 20, LCD_WIDTH, LCD_HEIGHT - 20),
894 .home => render_home(0, 20, LCD_WIDTH, LCD_HEIGHT - 20),
895 .post => render_create_post(0, 20, LCD_WIDTH, LCD_HEIGHT - 20),
896 }
897 render_header(0, 0, LCD_WIDTH);
898
899 return 1;
900}
901
902fn render_header(x: c_int, y: c_int, w: c_int) void {
903 const pd = g_app.playdate;
904
905 const header_height = pd.graphics.getFontHeight(fonts.g_font) + MARGIN * 2;
906
907 // Draw header background
908 pd.graphics.fillRect(
909 x,
910 y,
911 w + MARGIN * 2,
912 header_height,
913 @intFromEnum(pdapi.LCDSolidColor.ColorBlack),
914 );
915
916 // Set draw mode for white text on black background
917 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeFillWhite);
918
919 // Draw header text
920 const header_text = "Bluesky";
921 _ = pd.graphics.drawText(
922 header_text.ptr,
923 header_text.len,
924 pdapi.PDStringEncoding.UTF8Encoding,
925 x + MARGIN,
926 y + MARGIN,
927 );
928
929 const text_width = pd.graphics.getTextWidth(
930 fonts.g_font,
931 header_text.ptr,
932 header_text.len,
933 pdapi.PDStringEncoding.UTF8Encoding,
934 0,
935 );
936
937 // spinning loading indicator.
938 // Spinning loading indicator based on network state
939 const current_network_state = network.getNetworkState();
940 if (current_network_state == .connecting or current_network_state == .requesting) {
941 pdtools.renderSpinner(pd, x + w - 20, y + 2, pdapi.LCDSolidColor.ColorWhite);
942 }
943
944 // Reset draw mode to normal
945 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy);
946
947 // Show all available pages
948 var temp_w: c_int = 0;
949 var curr_x = text_width + x + MARGIN * 2;
950
951 // Login button (always available)
952 pdtools.renderButton(
953 pd,
954 curr_x,
955 y,
956 "Login",
957 g_selected_page == .login,
958 &temp_w,
959 null,
960 );
961
962 if (g_app.is_logged_in) {
963 curr_x += temp_w;
964 pdtools.renderButton(
965 pd,
966 curr_x,
967 y,
968 "Home",
969 g_selected_page == .home,
970 &temp_w,
971 null,
972 );
973
974 curr_x += temp_w;
975 pdtools.renderButton(
976 pd,
977 curr_x,
978 y,
979 "Post",
980 g_selected_page == .post,
981 null,
982 null,
983 );
984 }
985}
986
987fn render_post_list(x: c_int, y: c_int, w: c_int, h: c_int) void {
988 const scrollbar_width = 10;
989 const post_gap = 4;
990 const padding = 4;
991 const pd = g_app.playdate;
992
993 // Clip the whole thing so we don't go out of bounds
994 pd.graphics.setClipRect(x, y, w, h);
995
996 const posts_width = w - scrollbar_width - (padding * 2);
997
998 const start_y = @as(c_int, @intFromFloat(@as(f32, @floatFromInt(y)) - g_app.body_scroll.getValue()));
999 var current_y = start_y + padding;
1000 const current_x = x + padding;
1001
1002 // Now render each post with actual positions
1003 for (0..g_app.post_count) |i| {
1004 const post = &g_app.posts[i];
1005 const post_height = bsky_post.renderPost(
1006 pd,
1007 post,
1008 current_x,
1009 current_y,
1010 posts_width,
1011 false,
1012 );
1013 current_y += @as(c_int, @intCast(post_height));
1014 current_y += post_gap;
1015 }
1016 current_y += padding;
1017
1018 // current_y is the scrolled position, so we need to add back the scroll offset to get true content height
1019 const content_height = current_y - start_y;
1020
1021 g_app.body_scroll.current_height = @as(f32, @floatFromInt(h));
1022 g_app.body_scroll.min_value = 0;
1023 g_app.body_scroll.max_value = @floatFromInt(content_height);
1024
1025 // Render scrollbar on the right side (below header, taking remaining height)
1026 const scrollbar_x = pdapi.LCD_COLUMNS - scrollbar_width;
1027 pdtools.renderScrollbar(
1028 pd,
1029 scrollbar_x,
1030 y,
1031 scrollbar_width,
1032 h,
1033 g_app.body_scroll.getValue(),
1034 @as(f32, @floatFromInt(content_height)),
1035 @as(f32, @floatFromInt(h)),
1036 );
1037
1038 pd.graphics.clearClipRect();
1039}
1040
1041fn render_home(x: i32, y: i32, w: usize, h: usize) void {
1042 const pd = g_app.playdate;
1043
1044 // #region Body
1045
1046 // Update scrolling value with crank input
1047 g_app.body_scroll.update();
1048
1049 pd.graphics.fillRect(
1050 @intCast(x),
1051 @intCast(y),
1052 @intCast(w),
1053 @intCast(h),
1054 @intFromEnum(pdapi.LCDSolidColor.ColorWhite),
1055 );
1056
1057 // Set draw mode for black text on white background
1058 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeFillBlack);
1059
1060 // Show login prompt if not logged in
1061 if (!g_app.is_logged_in) {
1062 _ = pd.graphics.drawText("Please log in first", 19, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, y + MARGIN);
1063 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy);
1064 return;
1065 }
1066
1067 // Get current network state
1068 const current_network_state = network.getNetworkState();
1069
1070 // Show loading progress if network is active
1071 if (current_network_state == .connecting or current_network_state == .requesting) {
1072 const progress_msg = "Loading feed...";
1073 _ = pd.graphics.drawText(progress_msg.ptr, progress_msg.len, pdapi.PDStringEncoding.UTF8Encoding, x, y);
1074
1075 // Reset draw mode to normal
1076 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy);
1077 return;
1078 }
1079
1080 // If we have parsed posts, display them formatted
1081 if (g_app.post_count > 0) {
1082 render_post_list(@intCast(x), @intCast(y), @intCast(w), @intCast(h));
1083 } else if (current_network_state == .success or current_network_state == .idle) {
1084 // Show message when no posts are available
1085 const no_posts_msg = "No posts found - Press B to refresh";
1086 _ = pd.graphics.drawText(no_posts_msg.ptr, no_posts_msg.len, pdapi.PDStringEncoding.UTF8Encoding, x, y);
1087 } else {
1088 // Show current status message
1089 _ = pd.graphics.drawText(g_app.message.ptr, g_app.message.len, pdapi.PDStringEncoding.UTF8Encoding, x, y);
1090 }
1091
1092 // Reset draw mode to normal
1093 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy);
1094}
1095
1096fn render_login(x: c_int, y: c_int, w: c_int, h: c_int) void {
1097 const pd = g_app.playdate;
1098
1099 pd.graphics.fillRect(x, y, w, h, @intFromEnum(pdapi.LCDSolidColor.ColorWhite));
1100 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeFillBlack);
1101
1102 const line_height = pd.graphics.getFontHeight(fonts.g_font) + 4;
1103 var current_y = y + MARGIN;
1104
1105 const current_network_state = network.getNetworkState();
1106
1107 if (current_network_state == .requesting or current_network_state == .connecting) {
1108 _ = pd.graphics.drawText("Logging in...", 12, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1109 } else if (current_network_state == .network_error) {
1110 _ = pd.graphics.drawText("Login failed!", 13, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1111 current_y += line_height;
1112 _ = pd.graphics.drawText("Press B to retry", 16, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1113 } else {
1114 _ = pd.graphics.drawText("Welcome to Bluesky!", 20, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1115 current_y += line_height * 2;
1116
1117 // Show current username with selection indicator
1118 var username_text: [200]u8 = undefined;
1119 const username_prefix = if (g_app.login_field_selection == .username) pdtools.SELECTION_ARROW ++ " Username: " else " Username: ";
1120 const username_value = if (g_app.username[0] != 0) std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.username))) else "(not entered)";
1121 const username_display = std.fmt.bufPrint(&username_text, "{s}{s}", .{ username_prefix, username_value }) catch "Username: (error)";
1122 _ = pd.graphics.drawText(username_display.ptr, username_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1123 current_y += line_height;
1124
1125 // Show password status with selection indicator
1126 const password_prefix = if (g_app.login_field_selection == .password) pdtools.SELECTION_ARROW ++ " Password: " else " Password: ";
1127 var password_text: [200]u8 = undefined;
1128 const password_display = if (g_app.password[0] != 0) blk: {
1129 // Generate dots based on actual password length using safe ASCII dots
1130 var dots_buffer: [64]u8 = undefined;
1131 const password_span = std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.password)));
1132 const dots_len = @min(password_span.len, dots_buffer.len);
1133 @memset(dots_buffer[0..dots_len], '*');
1134 const dots_str = dots_buffer[0..dots_len];
1135 break :blk std.fmt.bufPrint(&password_text, "{s}{s}", .{ password_prefix, dots_str }) catch "Password: (error)";
1136 } else std.fmt.bufPrint(&password_text, "{s}(not entered)", .{password_prefix}) catch "Password: (error)";
1137 _ = pd.graphics.drawText(password_display.ptr, password_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1138 current_y += line_height;
1139
1140 // Show login button with selection indicator
1141 const login_prefix = if (g_app.login_field_selection == .login_button) pdtools.SELECTION_ARROW ++ " " else " ";
1142 const login_text = if (g_app.username[0] != 0 and g_app.password[0] != 0) "Login" else "Login (enter credentials first)";
1143 var login_button_text: [200]u8 = undefined;
1144 const login_display = std.fmt.bufPrint(&login_button_text, "{s}{s}", .{ login_prefix, login_text }) catch "Login";
1145 _ = pd.graphics.drawText(login_display.ptr, login_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1146 current_y += line_height * 2;
1147
1148 // Show navigation instructions
1149 const instruction = pdtools.UP_ARROW_EMOJI ++ pdtools.DOWN_ARROW_EMOJI ++ ": Select field B: Edit/Login";
1150 _ = pd.graphics.drawText(instruction.ptr, instruction.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1151 current_y += line_height;
1152 }
1153
1154 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy);
1155}
1156
1157fn render_create_post(x: c_int, y: c_int, w: c_int, h: c_int) void {
1158 const pd = g_app.playdate;
1159
1160 pd.graphics.fillRect(x, y, w, h, @intFromEnum(pdapi.LCDSolidColor.ColorWhite));
1161 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeFillBlack);
1162
1163 const line_height = pd.graphics.getFontHeight(fonts.g_font) + 4;
1164 var current_y = y + MARGIN;
1165
1166 // Check if user is logged in
1167 if (!g_app.is_logged_in) {
1168 _ = pd.graphics.drawText("Please log in first to create posts", 34, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1169 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy);
1170 return;
1171 }
1172
1173 // Post composition page
1174 _ = pd.graphics.drawText("Create a Post", 13, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1175 current_y += line_height;
1176
1177 // Show character count
1178 var char_count_text: [50]u8 = undefined;
1179 const char_count_display = std.fmt.bufPrint(&char_count_text, "Characters: {d}/300", .{g_app.post_text_len}) catch "Characters: 0/300";
1180 _ = pd.graphics.drawText(char_count_display.ptr, char_count_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1181 current_y += line_height;
1182
1183 // Show current post text if any
1184 if (g_app.post_text_len > 0) {
1185 _ = pd.graphics.drawText("Current post:", 13, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1186 current_y += line_height;
1187
1188 // Draw a border around the post text area
1189 const text_area_height = line_height * 6; // Space for multiple lines
1190 pd.graphics.drawRect(x + MARGIN, current_y, w - MARGIN * 2, text_area_height, @intFromEnum(pdapi.LCDSolidColor.ColorBlack));
1191
1192 // Render multiline post text
1193 const post_text = g_app.post_text[0..g_app.post_text_len];
1194 var text_y = current_y + 4;
1195 var line_start: usize = 0;
1196 var displayed_lines: usize = 0;
1197 const max_lines = 5;
1198
1199 for (post_text, 0..) |char, i| {
1200 if (char == '\n' or i == post_text.len - 1) {
1201 if (displayed_lines >= max_lines) break;
1202
1203 const line_end = if (char == '\n') i else i + 1;
1204 const line_text = post_text[line_start..line_end];
1205
1206 _ = pd.graphics.drawText(line_text.ptr, line_text.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN + 4, text_y);
1207 text_y += line_height;
1208 displayed_lines += 1;
1209 line_start = i + 1;
1210 }
1211 }
1212
1213 // If no newlines, display as single line
1214 if (displayed_lines == 0) {
1215 _ = pd.graphics.drawText(post_text.ptr, post_text.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN + 4, current_y + 4);
1216 }
1217
1218 current_y += text_area_height + line_height;
1219 } else {
1220 current_y += line_height * 2;
1221 _ = pd.graphics.drawText("No post drafted yet", 19, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1222 current_y += line_height * 2;
1223 }
1224
1225 // Show field selection options
1226 const edit_prefix = if (g_app.post_field_selection == .edit_text) pdtools.SELECTION_ARROW ++ " " else " ";
1227 var edit_text: [100]u8 = undefined;
1228 const edit_display = std.fmt.bufPrint(&edit_text, "{s}Edit Post Text", .{edit_prefix}) catch "Edit Post Text";
1229 _ = pd.graphics.drawText(edit_display.ptr, edit_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1230 current_y += line_height;
1231
1232 const confirm_prefix = if (g_app.post_field_selection == .confirm_post) pdtools.SELECTION_ARROW ++ " " else " ";
1233 var confirm_text: [100]u8 = undefined;
1234 const confirm_display = std.fmt.bufPrint(&confirm_text, "{s}Confirm & Post", .{confirm_prefix}) catch "Confirm & Post";
1235 _ = pd.graphics.drawText(confirm_display.ptr, confirm_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1236 current_y += line_height * 2;
1237
1238 // Show navigation instructions
1239 const instruction = pdtools.UP_ARROW_EMOJI ++ pdtools.DOWN_ARROW_EMOJI ++ ": Select action B: Execute " ++ pdtools.LEFT_ARROW_EMOJI ++ pdtools.RIGHT_ARROW_EMOJI ++ ": Navigate tabs";
1240 _ = pd.graphics.drawText(instruction.ptr, instruction.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y);
1241
1242 pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy);
1243}