const std = @import("std"); const pdapi = @import("playdate_api_definitions.zig"); const network = @import("network.zig"); const bsky_post = @import("bsky_post.zig"); const pdtools = @import("pdtools/index.zig"); const ScrollingValue = @import("pdtools/ScrollingValue.zig").SlidingValue; const panic_handler = @import("panic_handler.zig"); const defs = @import("definitions.zig"); const keyboard_mod = @import("pdtools/keyboard.zig"); const Keyboard = keyboard_mod.Keyboard(256); const fonts = @import("fonts.zig"); // Global buffers to avoid large stack allocations var g_headers_buffer: [512]u8 = undefined; // Login input state const LoginInputState = enum { username, password, ready, }; // Login field selection const LoginFieldSelection = enum { username, password, login_button, }; // Post field selection const PostFieldSelection = enum { edit_text, confirm_post, }; // Simple app state for timeline fetching const AppState = struct { playdate: *pdapi.PlaydateAPI, message: []const u8, response_buffer: [defs.RESPONSE_BUFFER_SIZE]u8 = undefined, response_length: usize, posts: [bsky_post.MAX_POSTS]bsky_post.BskyPost, post_count: usize, cursor_buffer: [bsky_post.CURSOR_BUFFER_SIZE]u8, cursor_len: usize, network_access_requested: bool, body_scroll: ScrollingValue, // Login state is_logged_in: bool, access_token: [512]u8, access_token_len: usize, user_did: [256]u8, user_did_len: usize, keyboard: keyboard_mod.Keyboard(256), // Login input state login_input_state: LoginInputState, login_field_selection: LoginFieldSelection, username: [defs.MAX_CREDENTIAL_LENGTH]u8, password: [defs.MAX_CREDENTIAL_LENGTH]u8, previous_buttons: pdapi.PDButtons, // Post composition state post_field_selection: PostFieldSelection, post_text: [defs.EDITOR_BUFFER_SIZE]u8, post_text_len: usize, }; var g_app: AppState = undefined; const Page = enum { login, home, post, }; var g_selected_page: Page = .login; const LCD_WIDTH = pdapi.LCD_COLUMNS; const LCD_HEIGHT = pdapi.LCD_ROWS; const MARGIN = 4; const CHUNK_SIZE = 512; // Read 512 bytes at a time // Credential storage functions fn saveCredentials(pd: *pdapi.PlaydateAPI) void { pdtools.saveStringFile(pd, defs.USERNAME_FILENAME, &g_app.username); pdtools.saveStringFile(pd, defs.PASSWORD_FILENAME, &g_app.password); } fn loadCredentials(pd: *pdapi.PlaydateAPI) void { pd.system.logToConsole("Loading username from: %s", defs.USERNAME_FILENAME.ptr); pdtools.loadStringFile(pd, defs.USERNAME_FILENAME, &g_app.username); pd.system.logToConsole("Loading password from: %s", defs.PASSWORD_FILENAME.ptr); pdtools.loadStringFile(pd, defs.PASSWORD_FILENAME, &g_app.password); } fn clearCredentials(pd: *pdapi.PlaydateAPI) void { // Clear in-memory credentials @memset(&g_app.username, 0); @memset(&g_app.password, 0); g_app.login_input_state = .username; g_app.login_field_selection = .username; pdtools.delStringFile(pd, defs.USERNAME_FILENAME); pdtools.delStringFile(pd, defs.PASSWORD_FILENAME); pd.system.logToConsole("Credentials cleared"); g_app.message = "Credentials cleared - enter new ones"; } // Network callback functions fn onLoginSuccess(status_code: network.HttpStatusCode, response_data: []const u8) void { const pd = g_app.playdate; pd.system.logToConsole("Login successful with status: %d", status_code); // Parse the JSON to extract access token if (std.mem.indexOf(u8, response_data, "\"accessJwt\":\"")) |start_pos| { const token_start = start_pos + 13; // Length of "accessJwt":" if (std.mem.indexOfPos(u8, response_data, token_start, "\"")) |end_pos| { const token_len = end_pos - token_start; if (token_len < g_app.access_token.len) { @memcpy(g_app.access_token[0..token_len], response_data[token_start..end_pos]); g_app.access_token_len = token_len; // Also parse the DID from the response if (std.mem.indexOf(u8, response_data, "\"did\":\"")) |did_start_pos| { const did_token_start = did_start_pos + 7; // Length of "did":" if (std.mem.indexOfPos(u8, response_data, did_token_start, "\"")) |did_end_pos| { const did_len = did_end_pos - did_token_start; if (did_len < g_app.user_did.len) { @memcpy(g_app.user_did[0..did_len], response_data[did_token_start..did_end_pos]); g_app.user_did[did_len] = 0; g_app.user_did_len = did_len; pd.system.logToConsole("User DID extracted: %.*s", @as(c_int, @intCast(did_len)), @as([*c]const u8, @ptrCast(&g_app.user_did))); } } } g_app.is_logged_in = true; g_app.message = "Login successful - fetching posts..."; g_selected_page = .home; pd.system.logToConsole("Access token extracted successfully (length: %d)", @as(c_int, @intCast(token_len))); // Automatically fetch posts after successful login fetchBlueskyFeed(); } else { g_app.message = "Access token too long"; pd.system.logToConsole("Access token too long: %d", @as(c_int, @intCast(token_len))); } } else { g_app.message = "Failed to parse access token"; pd.system.logToConsole("Failed to find end of access token"); } } else { g_app.message = "Access token not found"; pd.system.logToConsole("Access token not found in response"); } } fn onLoginFailure(status_code: network.HttpStatusCode, error_message: []const u8) void { const pd = g_app.playdate; pd.system.logToConsole("Login failed with status: %d", status_code); pd.system.logToConsole("Error: %.*s", @as(c_int, @intCast(error_message.len)), @as([*c]const u8, @ptrCast(error_message.ptr))); g_app.message = if (status_code == 0) "Login connection error" else "Login failed"; } fn onFeedSuccess(status_code: network.HttpStatusCode, response_data: []const u8) void { const pd = g_app.playdate; pd.system.logToConsole("Feed fetch successful with status: %d, %d bytes", status_code, response_data.len); // The response_data is already pointing to our g_app.response_buffer // So we just need to set the length - no copying needed g_app.response_length = response_data.len; // Ensure null termination if (g_app.response_length < g_app.response_buffer.len) { g_app.response_buffer[g_app.response_length] = 0; } g_app.message = "Feed data received"; // Parse the JSON response into posts parseFeedResponseIntoPosts(); } fn onFeedFailure(status_code: network.HttpStatusCode, error_message: []const u8) void { const pd = g_app.playdate; pd.system.logToConsole("Feed fetch failed with status: %d", status_code); if (error_message.len > 0) { pd.system.logToConsole("Error message: %.*s", @as(c_int, @intCast(error_message.len)), @as([*c]const u8, @ptrCast(error_message.ptr))); } else { pd.system.logToConsole("No error message provided"); } // Clear any previous response data g_app.response_length = 0; g_app.post_count = 0; if (status_code == 0) { g_app.message = "Network connection failed"; } else if (status_code == 401) { g_app.message = "Login expired - please re-login"; } else if (status_code >= 400 and status_code < 500) { g_app.message = "Request error - check credentials"; } else if (status_code >= 500) { g_app.message = "Server error - try again later"; } else { g_app.message = "Feed fetch failed"; } } fn onPostSuccess(status_code: network.HttpStatusCode, response_data: []const u8) void { const pd = g_app.playdate; pd.system.logToConsole("Post successful with status: %d", status_code); // Clear the post text since it was successfully posted @memset(&g_app.post_text, 0); g_app.post_text_len = 0; // Reset to default text for next post const default_post_text = "Hello World!\n\nPosted from my #playdate"; const default_len = @min(default_post_text.len, g_app.post_text.len - 1); @memcpy(g_app.post_text[0..default_len], default_post_text[0..default_len]); g_app.post_text[default_len] = 0; g_app.post_text_len = default_len; g_app.message = "Post published successfully!"; // Log response for debugging if (response_data.len > 0) { const preview_len = @min(100, response_data.len); pd.system.logToConsole("Post response: %.*s", @as(c_int, @intCast(preview_len)), @as([*c]const u8, @ptrCast(response_data.ptr))); } } fn onPostFailure(status_code: network.HttpStatusCode, error_message: []const u8) void { const pd = g_app.playdate; pd.system.logToConsole("Post failed with status: %d", status_code); if (error_message.len > 0) { pd.system.logToConsole("Post error: %.*s", @as(c_int, @intCast(error_message.len)), @as([*c]const u8, @ptrCast(error_message.ptr))); } if (status_code == 0) { g_app.message = "Post failed - network error"; } else if (status_code == 401) { g_app.message = "Post failed - please re-login"; } else if (status_code == 413) { g_app.message = "Post too long - please shorten"; } else if (status_code >= 400 and status_code < 500) { g_app.message = "Post failed - invalid request"; } else if (status_code >= 500) { g_app.message = "Post failed - server error"; } else { g_app.message = "Post failed - unknown error"; } } fn parseFeedResponseIntoPosts() void { // Check if we have a valid response if (g_app.response_length == 0) { g_app.playdate.system.logToConsole("No response data to parse"); g_app.message = "No response data received"; return; } // Validate response length isn't too large if (g_app.response_length >= g_app.response_buffer.len) { 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))); g_app.response_length = g_app.response_buffer.len - 1; } g_app.playdate.system.logToConsole("Starting JSON parsing of %d bytes", @as(c_int, @intCast(g_app.response_length))); // Log first few characters for debugging const preview_len = @min(100, g_app.response_length); 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))); // Reset post count before parsing g_app.post_count = 0; // Add error handling around JSON parsing bsky_post.decodePostsJson( g_app.playdate, &g_app.response_buffer, g_app.response_length, g_app.posts.len, &g_app.posts, &g_app.post_count, &g_app.cursor_buffer, &g_app.cursor_len, ); g_app.playdate.system.logToConsole("JSON parsing completed, found %d posts", @as(c_int, @intCast(g_app.post_count))); if (g_app.post_count == 0) { g_app.message = "No posts found in response"; } else { g_app.message = "Feed loaded successfully"; } } fn onScrollMaxThreshold() void { // Handle scroll max threshold being reached g_app.playdate.system.logToConsole("Scroll max threshold reached"); } fn onScrollMinThreshold() void { // Handle scroll min threshold being reached g_app.playdate.system.logToConsole("Scroll min threshold reached"); // Refresh posts. g_app.playdate.system.logToConsole("Refreshing posts..."); fetchBlueskyFeed(); } pub export fn eventHandler(playdate: *pdapi.PlaydateAPI, event: pdapi.PDSystemEvent, arg: u32) callconv(.C) c_int { _ = arg; switch (event) { .EventInit => { playdate.system.logToConsole("Initializing timeline fetch app..."); // Initialize panic handler for better error reporting panic_handler.init(playdate); // Test basic memory allocation to catch early issues playdate.system.logToConsole("Testing basic memory allocation..."); playdate.system.logToConsole("Memory test passed - AppState size: %d bytes", @as(c_int, @intCast(@sizeOf(AppState)))); playdate.system.logToConsole("Initializing full application"); // Initialize full app state g_app.playdate = playdate; fonts.initializeFonts(playdate); // Initialize keyboard g_app.keyboard = keyboard_mod.Keyboard(256){ .playdate = playdate, .font = fonts.g_font, .title = undefined, .editor_buffer = undefined, .output_buffer = undefined, }; g_app.message = "Use ← → to navigate, B to interact"; g_app.response_length = 0; g_app.post_count = 0; g_app.cursor_len = 0; g_app.network_access_requested = false; // Initialize scrolling value for posts with reasonable bounds g_app.body_scroll = ScrollingValue{ .playdate = playdate, .soft_min_runout = 100, // Static runout distance from min_value .soft_max_runout = 100, // Static runout distance from max_value .runout_margin = 5.0, // Margin before callback detection .min_value = -200, // Allow scrolling above top with hard limit .max_value = 2000, // Hard limit - will be updated based on content .current_value = 0, .current_height = 0, .crank_position_offset = 0, .onScrollMaxThreshold = &onScrollMaxThreshold, .onScrollMinThreshold = &onScrollMinThreshold, }; g_app.is_logged_in = false; g_app.access_token_len = 0; g_app.user_did_len = 0; // Initialize login state with default credentials for testing g_app.login_input_state = .ready; // Start ready since we have default credentials g_app.login_field_selection = .username; // Start with username selected // Initialize with empty credentials first @memset(&g_app.username, 0); @memset(&g_app.password, 0); g_app.previous_buttons = 0; // Initialize post composition state with default text g_app.post_field_selection = .edit_text; const default_post_text = "Hello World!\n\nPosted from my #playdate"; const default_len = @min(default_post_text.len, g_app.post_text.len - 1); @memcpy(g_app.post_text[0..default_len], default_post_text[0..default_len]); g_app.post_text[default_len] = 0; g_app.post_text_len = default_len; // Set up real update callback with full functionality playdate.system.setUpdateCallback(update_and_render, null); playdate.system.logToConsole("Update callback set"); playdate.system.logToConsole("App initialized successfully"); playdate.graphics.clear(@intFromEnum(pdapi.LCDSolidColor.ColorWhite)); // Try to load saved credentials playdate.system.logToConsole("Attempting to load saved credentials..."); loadCredentials(playdate); g_app.login_input_state = .ready; playdate.system.logToConsole("Credentials loaded - Username: %s", &g_app.username); g_app.message = "Saved credentials loaded"; }, else => {}, } return 0; } fn loginToBluesky() void { const pd = g_app.playdate; // Check if we're already busy with a network operation if (!network.isNetworkIdle()) { return; } // Request network access if (!g_app.network_access_requested) { pd.system.logToConsole("Requesting network access for https://bsky.social..."); const response = pd.network.playdate_http.requestAccess("https://bsky.social", 443, true, "login", null, null); g_app.network_access_requested = true; switch (response) { .AccessAllow => { pd.system.logToConsole("Network access GRANTED"); }, .AccessDeny => { pd.system.logToConsole("Network access DENIED by user"); g_app.message = "Network access denied"; return; }, else => { pd.system.logToConsole("Network access request FAILED"); g_app.message = "Access request failed"; return; }, } } else { pd.system.logToConsole("Network access already requested"); } // Check if we have credentials if (g_app.username[0] == 0 or g_app.password[0] == 0) { g_app.message = "Please enter username and password"; g_app.login_input_state = if (g_app.username[0] == 0) .username else .password; return; } // Prepare login request body using entered credentials var login_body: [512]u8 = undefined; const username = std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.username))); const password = std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.password))); // Debug logging for credentials pd.system.logToConsole("Login credentials - Username: %s", @as([*:0]const u8, @ptrCast(&g_app.username))); pd.system.logToConsole("Login credentials - Password: %s", @as([*:0]const u8, @ptrCast(&g_app.password))); const login_json = std.fmt.bufPrint(&login_body, "{{\"identifier\":\"{s}\",\"password\":\"{s}\"}}", .{ username, password }) catch { g_app.message = "Failed to format login request"; return; }; // Create HTTP request const request = network.HttpRequest{ .method = .POST, .url = "https://bsky.social/xrpc/com.atproto.server.createSession", .server = "bsky.social", .port = 443, .path = "/xrpc/com.atproto.server.createSession", .use_https = true, .bearer_token = null, .body = login_json, .response_buffer = &g_app.response_buffer, .success_callback = onLoginSuccess, .failure_callback = onLoginFailure, }; if (network.makeHttpRequest(pd, request)) { g_app.message = "Logging in..."; } else { g_app.message = "Network connection failed - check internet"; pd.system.logToConsole("Troubleshooting suggestions:"); pd.system.logToConsole("- Check internet connection"); pd.system.logToConsole("- Try in Playdate simulator vs device"); pd.system.logToConsole("- Check firewall settings"); pd.system.logToConsole("- Verify DNS resolution works"); } } fn fetchBlueskyFeed() void { const pd = g_app.playdate; // Check if logged in if (!g_app.is_logged_in) { g_app.message = "Please log in first"; return; } // Check if we're already busy with a network operation if (!network.isNetworkIdle()) { return; } // Reset state for refetch g_app.post_count = 0; g_app.body_scroll.setValue(0); // Request network access only once if (!g_app.network_access_requested) { pd.system.logToConsole("Requesting network access..."); const response = pd.network.playdate_http.requestAccess("https://bsky.social", 443, true, "timeline", null, null); g_app.network_access_requested = true; switch (response) { .AccessAllow => {}, .AccessDeny => { g_app.message = "Network access denied"; return; }, else => { g_app.message = "Access request failed"; return; }, } } // Create HTTP request with bearer token const bearer_token = g_app.access_token[0..g_app.access_token_len]; const request = network.HttpRequest{ .method = .GET, .url = "https://bsky.social/xrpc/app.bsky.feed.getTimeline?limit=50", .server = "bsky.social", .port = 443, .path = "/xrpc/app.bsky.feed.getTimeline?limit=50", .use_https = true, .bearer_token = bearer_token, .body = null, .response_buffer = &g_app.response_buffer, .success_callback = onFeedSuccess, .failure_callback = onFeedFailure, }; if (network.makeHttpRequest(pd, request)) { g_app.message = "Fetching feed data..."; } else { g_app.message = "Failed to start feed request"; } } // Login keyboard callbacks fn usernameKeyboardCancelled() void { g_app.playdate.system.logToConsole("Username input cancelled"); g_app.login_input_state = .username; // Stay on username input } fn usernameKeyboardConfirmed(text: []const u8) void { g_app.playdate.system.logToConsole("Username entered: %.*s", @as(c_int, @intCast(text.len)), @as([*c]const u8, @ptrCast(text.ptr))); // Copy username const copy_len = @min(text.len, g_app.username.len - 1); @memcpy(g_app.username[0..copy_len], text[0..copy_len]); g_app.username[copy_len] = 0; saveCredentials(g_app.playdate); // Move to password input g_app.login_input_state = .password; } fn passwordKeyboardCancelled() void { g_app.playdate.system.logToConsole("Password input cancelled"); g_app.login_input_state = .username; // Go back to username input } fn passwordKeyboardConfirmed(text: []const u8) void { g_app.playdate.system.logToConsole("Password entered (length: %d)", @as(c_int, @intCast(text.len))); // Copy password const copy_len = @min(text.len, g_app.password.len - 1); @memcpy(g_app.password[0..copy_len], text[0..copy_len]); g_app.password[copy_len] = 0; saveCredentials(g_app.playdate); g_app.login_input_state = .ready; } // JSON writer context for building post JSON const JsonWriteContext = struct { buffer: []u8, pos: usize, }; // JSON write callback for Playdate JSON encoder fn jsonWriteCallback(userdata: ?*anyopaque, str: [*c]const u8, len: c_int) callconv(.C) void { if (userdata == null) return; const context: *JsonWriteContext = @ptrCast(@alignCast(userdata.?)); const string_len = @as(usize, @intCast(len)); if (context.pos + string_len > context.buffer.len) return; // Buffer overflow protection const src_slice = @as([*]const u8, @ptrCast(str))[0..string_len]; @memcpy(context.buffer[context.pos .. context.pos + string_len], src_slice); context.pos += string_len; } fn submitBlueskyPost() void { const pd = g_app.playdate; // Check if logged in if (!g_app.is_logged_in) { g_app.message = "Please log in first"; return; } // Check if we have post content if (g_app.post_text_len == 0) { g_app.message = "Please enter post content first"; return; } // Check if we're already busy with a network operation if (!network.isNetworkIdle()) { return; } // Request network access if not already done if (!g_app.network_access_requested) { const response = pd.network.playdate_http.requestAccess("https://bsky.social", 443, true, "post", null, null); g_app.network_access_requested = true; switch (response) { .AccessAllow => {}, .AccessDeny => { g_app.message = "Network access denied"; return; }, else => { g_app.message = "Access request failed"; return; }, } } // Get current timestamp in ISO 8601 format using accurate Playdate API const seconds_since_2000 = pd.system.getSecondsSinceEpoch(null); // Convert to PDDateTime for formatting var datetime: pdapi.PDDateTime = undefined; pd.system.convertEpochToDateTime(seconds_since_2000, &datetime); // Format as ISO 8601 using the utility function const timestamp = pdtools.datetimeToISO(datetime); // Check if we have user DID if (g_app.user_did_len == 0) { g_app.message = "Missing user DID - please re-login"; return; } // Prepare post request body using Playdate JSON encoder var post_body: [1024]u8 = undefined; var json_context = JsonWriteContext{ .buffer = &post_body, .pos = 0 }; var encoder: pdapi.JSONEncoder = undefined; pd.json.initEncoder(&encoder, jsonWriteCallback, &json_context, 0); const post_text = g_app.post_text[0..g_app.post_text_len]; const user_did = g_app.user_did[0..g_app.user_did_len]; // Build JSON: {"repo":"...", "collection":"...", "record":{"text":"...", "createdAt":"..."}} encoder.startTable(&encoder); // Add repo field encoder.addTableMember(&encoder, "repo", 4); encoder.writeString(&encoder, @as([*c]const u8, @ptrCast(user_did.ptr)), @as(c_int, @intCast(user_did.len))); // Add collection field encoder.addTableMember(&encoder, "collection", 10); encoder.writeString(&encoder, "app.bsky.feed.post", 18); // Add record field encoder.addTableMember(&encoder, "record", 6); encoder.startTable(&encoder); // Add text field to record encoder.addTableMember(&encoder, "text", 4); encoder.writeString(&encoder, @as([*c]const u8, @ptrCast(post_text.ptr)), @as(c_int, @intCast(post_text.len))); // Add createdAt field to record encoder.addTableMember(&encoder, "createdAt", 9); encoder.writeString(&encoder, @as([*c]const u8, @ptrCast(timestamp.ptr)), @as(c_int, @intCast(timestamp.len))); encoder.endTable(&encoder); // End record encoder.endTable(&encoder); // End root const post_json = post_body[0..json_context.pos]; // Log json pdtools.logLargeMessage(pd, post_json, post_json.len); // Create HTTP request if (defs.DEBUG_DONT_POST) { g_app.message = "Debug mode - not posting"; return; } const bearer_token = g_app.access_token[0..g_app.access_token_len]; const request = network.HttpRequest{ .method = .POST, .url = "https://bsky.social/xrpc/com.atproto.repo.createRecord", .server = "bsky.social", .port = 443, .path = "/xrpc/com.atproto.repo.createRecord", .use_https = true, .bearer_token = bearer_token, .body = post_json, .response_buffer = &g_app.response_buffer, .success_callback = onPostSuccess, .failure_callback = onPostFailure, }; if (network.makeHttpRequest(pd, request)) { g_app.message = "Posting..."; } else { g_app.message = "Failed to start post request"; } } // Post keyboard callbacks fn postKeyboardCancelled() void { g_app.playdate.system.logToConsole("Post input cancelled"); // Stay on post page, don't change anything } // New callback for text editing that doesn't auto-submit fn postTextEditConfirmed(text: []const u8) void { g_app.playdate.system.logToConsole("Post text edited (length: %d)", @as(c_int, @intCast(text.len))); // Copy post text but don't submit automatically const copy_len = @min(text.len, g_app.post_text.len - 1); @memcpy(g_app.post_text[0..copy_len], text[0..copy_len]); g_app.post_text[copy_len] = 0; g_app.post_text_len = copy_len; g_app.message = "Post text updated - use confirm button to post"; } fn update_and_render(userdata: ?*anyopaque) callconv(.C) c_int { _ = userdata; // Ignore userdata, use global state // Safety check - ensure app is initialized const pd = g_app.playdate; // Update network processing network.updateNetwork(pd); if (g_app.keyboard.updateAndRender()) { return 1; } // Handle button input var current: pdapi.PDButtons = undefined; var pushed: pdapi.PDButtons = undefined; var released: pdapi.PDButtons = undefined; pd.system.getButtonState(¤t, &pushed, &released); // Handle keyboard input first if keyboard is active // Handle up/down navigation for login field selection if (g_selected_page == .login and !g_app.is_logged_in) { if (pushed & pdapi.BUTTON_UP != 0) { g_app.login_field_selection = switch (g_app.login_field_selection) { .username => .login_button, .password => .username, .login_button => .password, }; } else if (pushed & pdapi.BUTTON_DOWN != 0) { g_app.login_field_selection = switch (g_app.login_field_selection) { .username => .password, .password => .login_button, .login_button => .username, }; } } // Handle up/down navigation for post field selection if (g_selected_page == .post and g_app.is_logged_in) { if (pushed & pdapi.BUTTON_UP != 0) { g_app.post_field_selection = switch (g_app.post_field_selection) { .edit_text => .confirm_post, .confirm_post => .edit_text, }; } else if (pushed & pdapi.BUTTON_DOWN != 0) { g_app.post_field_selection = switch (g_app.post_field_selection) { .edit_text => .confirm_post, .confirm_post => .edit_text, }; } } // Check if left/right was pressed to change page selection if (pushed & pdapi.BUTTON_LEFT != 0) { g_selected_page = switch (g_selected_page) { .login => if (g_app.is_logged_in) .post else .login, .home => .login, .post => .home, }; } else if (pushed & pdapi.BUTTON_RIGHT != 0) { g_selected_page = switch (g_selected_page) { .login => if (g_app.is_logged_in) .home else .login, .home => if (g_app.is_logged_in) .post else .login, .post => .login, }; } // Check if B button was pushed (only if keyboard not active) if (pushed & pdapi.BUTTON_B != 0) { if (g_selected_page == .login and !g_app.is_logged_in) { // Handle login input based on selected field switch (g_app.login_field_selection) { .username => { // Start username input const initial_text = if (g_app.username[0] != 0) std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.username))) else null; g_app.keyboard.start( "Enter username:", g_app.username.len, initial_text, false, 1, usernameKeyboardCancelled, usernameKeyboardConfirmed, ); }, .password => { // Start password input const initial_text = if (g_app.password[0] != 0) std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.password))) else null; g_app.keyboard.start( "Enter password:", g_app.password.len, initial_text, false, 1, passwordKeyboardCancelled, passwordKeyboardConfirmed, ); }, .login_button => { // Check if we can login if (g_app.username[0] > 0 and g_app.password[0] > 0) { loginToBluesky(); } else { g_app.message = "Please enter username and password"; } }, } } else if (g_selected_page == .home) { fetchBlueskyFeed(); } else if (g_selected_page == .post) { // Check if user is logged in before allowing post creation if (!g_app.is_logged_in) { g_app.message = "Please log in first to create posts"; g_selected_page = .login; } else { // Handle post field selection switch (g_app.post_field_selection) { .edit_text => { // Start post composition with keyboard (multiline enabled) const initial_text = if (g_app.post_text_len > 0) g_app.post_text[0..g_app.post_text_len] else null; g_app.keyboard.start("Enter your post:", 300, initial_text, true, 5, postKeyboardCancelled, postTextEditConfirmed); }, .confirm_post => { // Submit the post directly if (g_app.post_text_len > 0) { submitBlueskyPost(); } else { g_app.message = "Please enter some text first"; } }, } } } } // Render keyboard if active, otherwise render the selected page switch (g_selected_page) { .login => render_login(0, 20, LCD_WIDTH, LCD_HEIGHT - 20), .home => render_home(0, 20, LCD_WIDTH, LCD_HEIGHT - 20), .post => render_create_post(0, 20, LCD_WIDTH, LCD_HEIGHT - 20), } render_header(0, 0, LCD_WIDTH); return 1; } fn render_header(x: c_int, y: c_int, w: c_int) void { const pd = g_app.playdate; const header_height = pd.graphics.getFontHeight(fonts.g_font) + MARGIN * 2; // Draw header background pd.graphics.fillRect( x, y, w + MARGIN * 2, header_height, @intFromEnum(pdapi.LCDSolidColor.ColorBlack), ); // Set draw mode for white text on black background pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeFillWhite); // Draw header text const header_text = "Bluesky"; _ = pd.graphics.drawText( header_text.ptr, header_text.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, y + MARGIN, ); const text_width = pd.graphics.getTextWidth( fonts.g_font, header_text.ptr, header_text.len, pdapi.PDStringEncoding.UTF8Encoding, 0, ); // spinning loading indicator. // Spinning loading indicator based on network state const current_network_state = network.getNetworkState(); if (current_network_state == .connecting or current_network_state == .requesting) { pdtools.renderSpinner(pd, x + w - 20, y + 2, pdapi.LCDSolidColor.ColorWhite); } // Reset draw mode to normal pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy); // Show all available pages var temp_w: c_int = 0; var curr_x = text_width + x + MARGIN * 2; // Login button (always available) pdtools.renderButton( pd, curr_x, y, "Login", g_selected_page == .login, &temp_w, null, ); if (g_app.is_logged_in) { curr_x += temp_w; pdtools.renderButton( pd, curr_x, y, "Home", g_selected_page == .home, &temp_w, null, ); curr_x += temp_w; pdtools.renderButton( pd, curr_x, y, "Post", g_selected_page == .post, null, null, ); } } fn render_post_list(x: c_int, y: c_int, w: c_int, h: c_int) void { const scrollbar_width = 10; const post_gap = 4; const padding = 4; const pd = g_app.playdate; // Clip the whole thing so we don't go out of bounds pd.graphics.setClipRect(x, y, w, h); const posts_width = w - scrollbar_width - (padding * 2); const start_y = @as(c_int, @intFromFloat(@as(f32, @floatFromInt(y)) - g_app.body_scroll.getValue())); var current_y = start_y + padding; const current_x = x + padding; // Now render each post with actual positions for (0..g_app.post_count) |i| { const post = &g_app.posts[i]; const post_height = bsky_post.renderPost( pd, post, current_x, current_y, posts_width, false, ); current_y += @as(c_int, @intCast(post_height)); current_y += post_gap; } current_y += padding; // current_y is the scrolled position, so we need to add back the scroll offset to get true content height const content_height = current_y - start_y; g_app.body_scroll.current_height = @as(f32, @floatFromInt(h)); g_app.body_scroll.min_value = 0; g_app.body_scroll.max_value = @floatFromInt(content_height); // Render scrollbar on the right side (below header, taking remaining height) const scrollbar_x = pdapi.LCD_COLUMNS - scrollbar_width; pdtools.renderScrollbar( pd, scrollbar_x, y, scrollbar_width, h, g_app.body_scroll.getValue(), @as(f32, @floatFromInt(content_height)), @as(f32, @floatFromInt(h)), ); pd.graphics.clearClipRect(); } fn render_home(x: i32, y: i32, w: usize, h: usize) void { const pd = g_app.playdate; // #region Body // Update scrolling value with crank input g_app.body_scroll.update(); pd.graphics.fillRect( @intCast(x), @intCast(y), @intCast(w), @intCast(h), @intFromEnum(pdapi.LCDSolidColor.ColorWhite), ); // Set draw mode for black text on white background pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeFillBlack); // Show login prompt if not logged in if (!g_app.is_logged_in) { _ = pd.graphics.drawText("Please log in first", 19, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, y + MARGIN); pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy); return; } // Get current network state const current_network_state = network.getNetworkState(); // Show loading progress if network is active if (current_network_state == .connecting or current_network_state == .requesting) { const progress_msg = "Loading feed..."; _ = pd.graphics.drawText(progress_msg.ptr, progress_msg.len, pdapi.PDStringEncoding.UTF8Encoding, x, y); // Reset draw mode to normal pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy); return; } // If we have parsed posts, display them formatted if (g_app.post_count > 0) { render_post_list(@intCast(x), @intCast(y), @intCast(w), @intCast(h)); } else if (current_network_state == .success or current_network_state == .idle) { // Show message when no posts are available const no_posts_msg = "No posts found - Press B to refresh"; _ = pd.graphics.drawText(no_posts_msg.ptr, no_posts_msg.len, pdapi.PDStringEncoding.UTF8Encoding, x, y); } else { // Show current status message _ = pd.graphics.drawText(g_app.message.ptr, g_app.message.len, pdapi.PDStringEncoding.UTF8Encoding, x, y); } // Reset draw mode to normal pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy); } fn render_login(x: c_int, y: c_int, w: c_int, h: c_int) void { const pd = g_app.playdate; pd.graphics.fillRect(x, y, w, h, @intFromEnum(pdapi.LCDSolidColor.ColorWhite)); pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeFillBlack); const line_height = pd.graphics.getFontHeight(fonts.g_font) + 4; var current_y = y + MARGIN; const current_network_state = network.getNetworkState(); if (current_network_state == .requesting or current_network_state == .connecting) { _ = pd.graphics.drawText("Logging in...", 12, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); } else if (current_network_state == .network_error) { _ = pd.graphics.drawText("Login failed!", 13, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); current_y += line_height; _ = pd.graphics.drawText("Press B to retry", 16, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); } else { _ = pd.graphics.drawText("Welcome to Bluesky!", 20, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); current_y += line_height * 2; // Show current username with selection indicator var username_text: [200]u8 = undefined; const username_prefix = if (g_app.login_field_selection == .username) pdtools.SELECTION_ARROW ++ " Username: " else " Username: "; const username_value = if (g_app.username[0] != 0) std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.username))) else "(not entered)"; const username_display = std.fmt.bufPrint(&username_text, "{s}{s}", .{ username_prefix, username_value }) catch "Username: (error)"; _ = pd.graphics.drawText(username_display.ptr, username_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); current_y += line_height; // Show password status with selection indicator const password_prefix = if (g_app.login_field_selection == .password) pdtools.SELECTION_ARROW ++ " Password: " else " Password: "; var password_text: [200]u8 = undefined; const password_display = if (g_app.password[0] != 0) blk: { // Generate dots based on actual password length using safe ASCII dots var dots_buffer: [64]u8 = undefined; const password_span = std.mem.span(@as([*:0]const u8, @ptrCast(&g_app.password))); const dots_len = @min(password_span.len, dots_buffer.len); @memset(dots_buffer[0..dots_len], '*'); const dots_str = dots_buffer[0..dots_len]; break :blk std.fmt.bufPrint(&password_text, "{s}{s}", .{ password_prefix, dots_str }) catch "Password: (error)"; } else std.fmt.bufPrint(&password_text, "{s}(not entered)", .{password_prefix}) catch "Password: (error)"; _ = pd.graphics.drawText(password_display.ptr, password_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); current_y += line_height; // Show login button with selection indicator const login_prefix = if (g_app.login_field_selection == .login_button) pdtools.SELECTION_ARROW ++ " " else " "; const login_text = if (g_app.username[0] != 0 and g_app.password[0] != 0) "Login" else "Login (enter credentials first)"; var login_button_text: [200]u8 = undefined; const login_display = std.fmt.bufPrint(&login_button_text, "{s}{s}", .{ login_prefix, login_text }) catch "Login"; _ = pd.graphics.drawText(login_display.ptr, login_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); current_y += line_height * 2; // Show navigation instructions const instruction = pdtools.UP_ARROW_EMOJI ++ pdtools.DOWN_ARROW_EMOJI ++ ": Select field B: Edit/Login"; _ = pd.graphics.drawText(instruction.ptr, instruction.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); current_y += line_height; } pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy); } fn render_create_post(x: c_int, y: c_int, w: c_int, h: c_int) void { const pd = g_app.playdate; pd.graphics.fillRect(x, y, w, h, @intFromEnum(pdapi.LCDSolidColor.ColorWhite)); pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeFillBlack); const line_height = pd.graphics.getFontHeight(fonts.g_font) + 4; var current_y = y + MARGIN; // Check if user is logged in if (!g_app.is_logged_in) { _ = pd.graphics.drawText("Please log in first to create posts", 34, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy); return; } // Post composition page _ = pd.graphics.drawText("Create a Post", 13, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); current_y += line_height; // Show character count var char_count_text: [50]u8 = undefined; const char_count_display = std.fmt.bufPrint(&char_count_text, "Characters: {d}/300", .{g_app.post_text_len}) catch "Characters: 0/300"; _ = pd.graphics.drawText(char_count_display.ptr, char_count_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); current_y += line_height; // Show current post text if any if (g_app.post_text_len > 0) { _ = pd.graphics.drawText("Current post:", 13, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); current_y += line_height; // Draw a border around the post text area const text_area_height = line_height * 6; // Space for multiple lines pd.graphics.drawRect(x + MARGIN, current_y, w - MARGIN * 2, text_area_height, @intFromEnum(pdapi.LCDSolidColor.ColorBlack)); // Render multiline post text const post_text = g_app.post_text[0..g_app.post_text_len]; var text_y = current_y + 4; var line_start: usize = 0; var displayed_lines: usize = 0; const max_lines = 5; for (post_text, 0..) |char, i| { if (char == '\n' or i == post_text.len - 1) { if (displayed_lines >= max_lines) break; const line_end = if (char == '\n') i else i + 1; const line_text = post_text[line_start..line_end]; _ = pd.graphics.drawText(line_text.ptr, line_text.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN + 4, text_y); text_y += line_height; displayed_lines += 1; line_start = i + 1; } } // If no newlines, display as single line if (displayed_lines == 0) { _ = pd.graphics.drawText(post_text.ptr, post_text.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN + 4, current_y + 4); } current_y += text_area_height + line_height; } else { current_y += line_height * 2; _ = pd.graphics.drawText("No post drafted yet", 19, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); current_y += line_height * 2; } // Show field selection options const edit_prefix = if (g_app.post_field_selection == .edit_text) pdtools.SELECTION_ARROW ++ " " else " "; var edit_text: [100]u8 = undefined; const edit_display = std.fmt.bufPrint(&edit_text, "{s}Edit Post Text", .{edit_prefix}) catch "Edit Post Text"; _ = pd.graphics.drawText(edit_display.ptr, edit_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); current_y += line_height; const confirm_prefix = if (g_app.post_field_selection == .confirm_post) pdtools.SELECTION_ARROW ++ " " else " "; var confirm_text: [100]u8 = undefined; const confirm_display = std.fmt.bufPrint(&confirm_text, "{s}Confirm & Post", .{confirm_prefix}) catch "Confirm & Post"; _ = pd.graphics.drawText(confirm_display.ptr, confirm_display.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); current_y += line_height * 2; // Show navigation instructions const instruction = pdtools.UP_ARROW_EMOJI ++ pdtools.DOWN_ARROW_EMOJI ++ ": Select action B: Execute " ++ pdtools.LEFT_ARROW_EMOJI ++ pdtools.RIGHT_ARROW_EMOJI ++ ": Navigate tabs"; _ = pd.graphics.drawText(instruction.ptr, instruction.len, pdapi.PDStringEncoding.UTF8Encoding, x + MARGIN, current_y); pd.graphics.setDrawMode(pdapi.LCDBitmapDrawMode.DrawModeCopy); }