From 448144affd3f192860ce0a9d95a93038d53698aa Mon Sep 17 00:00:00 2001 From: systematiccaos Date: Sat, 14 Mar 2026 14:45:09 +0100 Subject: [PATCH] Add Libra Colour / Elipsa 2E support, fix config persistence, UTF-8 rendering, page-turn buttons, and feed list scrollbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device support: - Add touch quirks for Kobo Libra Colour (monza/390) and Elipsa 2E (condor/389) with APP_TOUCH_MIRROR_Y=1 - Query actual framebuffer dimensions at runtime in diagnostics instead of hardcoding 1072x1448 Config persistence: - Add fflush()+fsync() before fclose() in config_save() to ensure data reaches persistent storage before the forced reboot - Guard persist_session() and save_session() against writing empty session data that would overwrite a valid config.ini - Add double sync with 2s delay before reboot in run.sh to let the eMMC write-back cache drain (prevents FAT32 corruption / FSCK files) - Add diagnostic logging throughout bootstrap, config, and path code UTF-8 rendering: - Rework sanitize_bitmap_text() → sanitize_display_text(): when OT fonts are loaded, pass UTF-8 through to FBInk's FreeType/HarfBuzz renderer instead of replacing all non-ASCII with '?'. German umlauts, accented characters, CJK, etc. now render correctly. The ASCII-only fallback is kept for the bitmap VGA font path. - Expose font_ot_enabled() so callers can query the rendering mode Physical page-turn buttons: - Accept any EV_KEY device during input scan (the Libra Colour's gpio-keys was silently dropped because it didn't match the narrow key code allowlist) - Accept key_press events from all key-capable devices, not just the first one found - Map KEY_F23 (193) / KEY_F24 (194) for Libra Colour, plus KEY_PAGEUP/DOWN, KEY_NEXTSONG/PREVIOUSSONG, KEY_BACK/FORWARD for other models - Route page-turn buttons to feed (next/prev page), thread (scroll), settings (scroll), and feed list (scroll) views Feed list scrollbar: - The feed list view (paper roll icon, top-left) previously cut off feeds that didn't fit on screen with no way to scroll - Add vertical scrollbar with UP/DN end caps and draggable track - Narrow content area to make room for scrollbar on the right - Support scrolling via touch (scrollbar) and physical page-turn buttons - Fix feed selection to account for scroll offset (page_start) --- run.sh | 22 ++++ src/kobo/bootstrap.cpp | 62 +++++++++- src/kobo/diag_main.cpp | 21 +++- src/kobo/main.cpp | 268 ++++++++++++++++++++++++++++++++++++----- src/platform/input.cpp | 44 ++++++- src/ui/font.cpp | 4 + src/ui/font.h | 4 + src/util/config.cpp | 55 +++++++-- src/util/paths.cpp | 17 ++- 9 files changed, 447 insertions(+), 50 deletions(-) diff --git a/run.sh b/run.sh index 5a3e7d0..82ae0c0 100644 --- a/run.sh +++ b/run.sh @@ -76,12 +76,29 @@ exec >>"${LOG}" 2>&1 hydrate_probe_env +# Clara Colour (spaColour/393): snow protocol, needs mirror_x if [ -z "${APP_TOUCH_MIRROR_X}" ] && \ { [ "${APP_CODENAME}" = "spaColour" ] || [ "${APP_PRODUCT_ID}" = "393" ]; } && \ [ "${APP_TOUCH_PROTOCOL}" = "snow" ]; then APP_TOUCH_MIRROR_X=1 fi +# Libra Colour (monza/390): standard multitouch, needs mirror_y (not mirror_x) +if [ -z "${APP_TOUCH_MIRROR_Y}" ] && \ + { [ "${APP_CODENAME}" = "monza" ] || [ "${APP_PRODUCT_ID}" = "390" ]; }; then + APP_TOUCH_MIRROR_Y=1 +fi + +# Elipsa 2E (condor/389): standard multitouch, needs mirror_y (not mirror_x) +if [ -z "${APP_TOUCH_MIRROR_Y}" ] && \ + { [ "${APP_CODENAME}" = "condor" ] || [ "${APP_PRODUCT_ID}" = "389" ]; }; then + APP_TOUCH_MIRROR_Y=1 +fi + +if [ -z "${APP_TOUCH_MIRROR_X}" ]; then + APP_TOUCH_MIRROR_X=0 +fi + if [ -z "${APP_TOUCH_MIRROR_Y}" ]; then APP_TOUCH_MIRROR_Y=0 fi @@ -158,6 +175,11 @@ stop_watchdog() { cleanup_app_mode() { echo "sKeets cleanup at $(date), rebooting..." stop_watchdog + # Flush all pending writes and give the eMMC controller time to commit + # them to persistent storage. Without this delay FAT32 metadata can be + # lost on reboot, causing files to disappear or be renamed to FSCK*. + sync + sleep 2 sync reboot } diff --git a/src/kobo/bootstrap.cpp b/src/kobo/bootstrap.cpp index a983375..bd32820 100644 --- a/src/kobo/bootstrap.cpp +++ b/src/kobo/bootstrap.cpp @@ -70,9 +70,15 @@ bool file_exists(const char* path) { } Bsky::Session load_saved_session() { + const char* config_path = skeets_config_path(); + std::fprintf(stderr, "bootstrap: load_saved_session: config_path='%s'\n", config_path); + Bsky::Session session; - config_t* config = config_open(skeets_config_path()); - if (!config) return session; + config_t* config = config_open(config_path); + if (!config) { + std::fprintf(stderr, "bootstrap: load_saved_session: config_open returned NULL\n"); + return session; + } session.handle = config_get_str(config, "handle", ""); session.access_jwt = config_get_str(config, "access_jwt", ""); @@ -82,6 +88,14 @@ Bsky::Session load_saved_session() { session.appview_url = config_get_str(config, "appview_url", config_get_str(config, "appview", kDefaultAppView)); + + std::fprintf(stderr, "bootstrap: load_saved_session: loaded handle='%s' did='%s' pds='%s' has_access=%zu has_refresh=%zu\n", + session.handle.empty() ? "(empty)" : session.handle.c_str(), + session.did.empty() ? "(empty)" : session.did.c_str(), + session.pds_url.empty() ? "(empty)" : session.pds_url.c_str(), + session.access_jwt.size(), + session.refresh_jwt.size()); + config_free(config); return session; } @@ -91,8 +105,22 @@ bool has_saved_session(const Bsky::Session& session) { } void save_session(const Bsky::Session& session) { - config_t* config = config_open(skeets_config_path()); - if (!config) return; + const char* config_path = skeets_config_path(); + + if (session.handle.empty() && session.access_jwt.empty() && session.did.empty()) { + std::fprintf(stderr, "bootstrap: save_session: SKIPPING — session is empty (would overwrite valid config)\n"); + return; + } + + std::fprintf(stderr, "bootstrap: save_session: saving handle='%s' did='%s' pds='%s' access_len=%zu refresh_len=%zu\n", + session.handle.c_str(), session.did.c_str(), session.pds_url.c_str(), + session.access_jwt.size(), session.refresh_jwt.size()); + + config_t* config = config_open(config_path); + if (!config) { + std::fprintf(stderr, "bootstrap: save_session: config_open returned NULL for '%s'\n", config_path); + return; + } config_set_str(config, "handle", session.handle.c_str()); config_set_str(config, "access_jwt", session.access_jwt.c_str()); @@ -100,7 +128,13 @@ void save_session(const Bsky::Session& session) { config_set_str(config, "did", session.did.c_str()); config_set_str(config, "pds_url", session.pds_url.c_str()); config_set_str(config, "appview_url", session.appview_url.c_str()); - config_save(config); + + int result = config_save(config); + if (result != 0) { + std::fprintf(stderr, "bootstrap: save_session: config_save FAILED for '%s' (result=%d)\n", config_path, result); + } else { + std::fprintf(stderr, "bootstrap: save_session: config_save succeeded for '%s'\n", config_path); + } config_free(config); } @@ -143,16 +177,22 @@ skeets_bootstrap_result_t make_error_result(const std::string& error_message, bo } // namespace skeets_bootstrap_result_t skeets_run_bootstrap() { + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: starting\n"); + if (skeets_ensure_data_dirs() != 0) { + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: failed to create data dirs\n"); return make_error_result("Failed to create rewrite data directories", false); } + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: loading saved session\n"); Bsky::Session saved_session = load_saved_session(); Bsky::Session restored_session; std::string saved_session_error; if (has_saved_session(saved_session)) { + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: found saved session, attempting restore\n"); if (restore_saved_session_with_retry(saved_session, restored_session, saved_session_error)) { + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: session restore succeeded\n"); save_session(restored_session); skeets_bootstrap_result_t result; result.state = skeets_bootstrap_state_t::session_restored; @@ -163,21 +203,31 @@ skeets_bootstrap_result_t skeets_run_bootstrap() { result.used_saved_session = true; return result; } + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: session restore failed: %s\n", saved_session_error.c_str()); + } else { + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: no saved session found\n"); } + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: checking for login.txt\n"); bool found_login_file = false; const skeets_login_txt_t login = read_login_txt(found_login_file); if (!found_login_file) { + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: no login.txt found\n"); if (has_saved_session(saved_session)) { + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: returning error (had session but restore failed)\n"); return make_error_result(saved_session_error.empty() ? "Saved session resume failed" : saved_session_error, false); } + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: returning waiting for login\n"); return make_waiting_result(); } + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: login.txt found, handle='%s'\n", login.handle.c_str()); if (login.handle.empty() || login.password.empty()) { + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: login.txt missing handle or password\n"); return make_error_result("login.txt is missing required handle or password fields", true); } + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: attempting createSession\n"); bool created = false; std::string error_message; Bsky::Session created_session; @@ -194,6 +244,7 @@ skeets_bootstrap_result_t skeets_run_bootstrap() { return make_error_result(error_message.empty() ? "Login failed" : error_message, true); } + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: createSession succeeded, saving session\n"); created_session.appview_url = login.appview_url.empty() ? kDefaultAppView : login.appview_url; save_session(created_session); @@ -204,6 +255,7 @@ skeets_bootstrap_result_t skeets_run_bootstrap() { result.detail = "Session tokens were saved under the rewrite app root."; result.authenticated = true; result.consumed_login_file = true; + std::fprintf(stderr, "bootstrap: skeets_run_bootstrap: returning success\n"); return result; } diff --git a/src/kobo/diag_main.cpp b/src/kobo/diag_main.cpp index 8cea924..b76a482 100644 --- a/src/kobo/diag_main.cpp +++ b/src/kobo/diag_main.cpp @@ -96,11 +96,30 @@ skeets_input_protocol_t detect_input_protocol() { int run_input_diag() { std::fprintf(stderr, "rewrite diag: input.begin\n"); + // Query actual framebuffer dimensions instead of hardcoding a single device's resolution. + int fb_width = 0; + int fb_height = 0; + { + skeets_framebuffer_t probe_fb; + std::string fb_err; + if (skeets_framebuffer_open(probe_fb, &fb_err)) { + fb_width = probe_fb.info.screen_width; + fb_height = probe_fb.info.screen_height; + skeets_framebuffer_close(probe_fb); + std::fprintf(stderr, "rewrite diag: input.detected_fb=%dx%d\n", fb_width, fb_height); + } else { + fb_width = 1072; + fb_height = 1448; + std::fprintf(stderr, "rewrite diag: input.fb_probe_failed=%s (using %dx%d fallback)\n", + fb_err.c_str(), fb_width, fb_height); + } + } + skeets_input_t input; input.debug_raw_events = true; input.raw_event_log_budget = 64; std::string error_message; - if (!skeets_input_open(input, 1072, 1448, detect_input_protocol(), &error_message)) { + if (!skeets_input_open(input, fb_width, fb_height, detect_input_protocol(), &error_message)) { std::fprintf(stderr, "rewrite diag: input.open_failed=%s\n", error_message.c_str()); return 5; } diff --git a/src/kobo/main.cpp b/src/kobo/main.cpp index 6a19313..670fa9c 100644 --- a/src/kobo/main.cpp +++ b/src/kobo/main.cpp @@ -214,6 +214,12 @@ struct skeets_app_t { std::vector feed_list_rows; skeets_button_t feed_list_button; skeets_button_t feed_list_back_button; + int feed_list_page_start = 0; + int feed_list_page_count = 0; + skeets_rect_t feed_list_scrollbar_up_rect{}; + skeets_rect_t feed_list_scrollbar_down_rect{}; + skeets_rect_t feed_list_scrollbar_rect{}; + skeets_rect_t feed_list_scrollbar_thumb_rect{}; skeets_button_t feed_back_button; skeets_button_t feed_refresh_button; skeets_button_t feed_next_button; @@ -585,7 +591,28 @@ std::string to_lower_ascii(std::string text) { return text; } -std::string sanitize_bitmap_text(const std::string& text) { +std::string sanitize_display_text(const std::string& text) { + // When OpenType fonts are loaded, pass UTF-8 through — the renderer + // (FreeType/HarfBuzz via FBInk) handles the full Unicode range. + // Only strip C0/C1 control characters that would confuse the layout. + if (font_ot_enabled()) { + std::string out; + out.reserve(text.size()); + for (size_t i = 0; i < text.size(); ++i) { + const unsigned char c = static_cast(text[i]); + if (c < 0x20 && c != '\n' && c != '\t') continue; // strip controls except newline/tab + if (c == 0x7F) continue; // DEL + if (c == 0xC2 && i + 1 < text.size()) { // C1 control block U+0080..U+009F + const unsigned char c1 = static_cast(text[i + 1]); + if (c1 >= 0x80 && c1 <= 0x9F) { ++i; continue; } + } + out.push_back(static_cast(c)); + } + return out; + } + + // Bitmap VGA fallback: only ASCII is renderable. Replace non-ASCII + // with ASCII approximations where possible, '?' otherwise. std::string out; out.reserve(text.size()); for (size_t i = 0; i < text.size(); ++i) { @@ -621,7 +648,7 @@ std::string sanitize_bitmap_text(const std::string& text) { int measure_quote_embed_height(const skeets_app_t& app, int width, const Bsky::Post* quoted) { if (!quoted) return 0; const int inner_width = std::max(40, width - kQuoteIndent - 8); - const std::string text = sanitize_bitmap_text(quoted->text); + const std::string text = sanitize_display_text(quoted->text); const int body_height = font_measure_wrapped(inner_width, text.c_str(), 4); @@ -642,7 +669,7 @@ void draw_quote_embed(skeets_app_t& app, int x, int y, int width, const Bsky::Po std::string author = quoted->author.display_name.empty() ? "@" + quoted->author.handle : quoted->author.display_name + " @" + quoted->author.handle; - author = sanitize_bitmap_text(author); + author = sanitize_display_text(author); font_draw_string(&app.text_fb, quote_x, quote_y, @@ -650,7 +677,7 @@ void draw_quote_embed(skeets_app_t& app, int x, int y, int width, const Bsky::Po kColorMeta, COLOR_WHITE); quote_y += app.text_fb.font_h + 2; - const std::string text = sanitize_bitmap_text(quoted->text); + const std::string text = sanitize_display_text(quoted->text); font_draw_wrapped(&app.text_fb, quote_x, quote_y, @@ -680,10 +707,10 @@ int measure_external_embed_height(const skeets_app_t& app, int width, const Bsky const int thumb_height = has_thumb ? std::max(96, (thumb_width * 3) / 4) : 0; const int side_text_width = has_thumb ? std::max(120, width - thumb_width - 18) : std::max(120, width - 12); const int full_text_width = std::max(120, width - 12); - const std::string title = sanitize_bitmap_text(post.ext_title.empty() ? "External media" : post.ext_title); + const std::string title = sanitize_display_text(post.ext_title.empty() ? "External media" : post.ext_title); const std::string description = title_only ? std::string{} - : sanitize_bitmap_text(post.ext_description.empty() ? "External media preview" : post.ext_description); + : sanitize_display_text(post.ext_description.empty() ? "External media preview" : post.ext_description); const int side_title_height = font_measure_wrapped(side_text_width, title.c_str(), 4); const int side_description_height = description.empty() ? 0 : font_measure_wrapped(side_text_width, description.c_str(), 4); const int side_text_height = side_title_height + (description.empty() ? 0 : (side_description_height + 4)); @@ -702,7 +729,7 @@ std::vector image_alt_lines(const Bsky::Post& post) { for (int index = 0; index < image_count; ++index) { std::string alt; if (index < static_cast(post.image_alts.size())) { - alt = sanitize_bitmap_text(post.image_alts[index]); + alt = sanitize_display_text(post.image_alts[index]); } if (alt.empty()) { alt = image_count == 1 @@ -759,8 +786,8 @@ int measure_unsupported_media_height(const skeets_app_t& app, const Bsky::Post& const int thumb_height = has_preview ? std::max(96, (thumb_width * 3) / 4) : 0; const int side_text_width = has_preview ? std::max(120, width - thumb_width - 18) : std::max(120, width - 12); const int full_text_width = std::max(120, width - 12); - const std::string label = sanitize_bitmap_text(post.media_label.empty() ? "Unsupported media" : post.media_label); - const std::string alt = sanitize_bitmap_text(post.media_alt_text.empty() ? "Preview unavailable for this media type." : post.media_alt_text); + const std::string label = sanitize_display_text(post.media_label.empty() ? "Unsupported media" : post.media_label); + const std::string alt = sanitize_display_text(post.media_alt_text.empty() ? "Preview unavailable for this media type." : post.media_alt_text); const int side_label_height = font_measure_wrapped(side_text_width, label.c_str(), 4); const int side_alt_height = font_measure_wrapped(side_text_width, alt.c_str(), 4); const int side_text_height = side_label_height + 4 + side_alt_height; @@ -784,8 +811,8 @@ void draw_unsupported_media_block(skeets_app_t& app, const Bsky::Post& post, int int text_x = x + 6; int text_width = std::max(120, width - 12); int text_y = y + 6; - const std::string label = sanitize_bitmap_text(post.media_label.empty() ? "Unsupported media" : post.media_label); - const std::string alt = sanitize_bitmap_text(post.media_alt_text.empty() ? "Preview unavailable for this media type." : post.media_alt_text); + const std::string label = sanitize_display_text(post.media_label.empty() ? "Unsupported media" : post.media_label); + const std::string alt = sanitize_display_text(post.media_alt_text.empty() ? "Preview unavailable for this media type." : post.media_alt_text); const int side_text_width = has_preview ? std::max(120, width - thumb_width - 18) : text_width; const int side_label_height = font_measure_wrapped(side_text_width, label.c_str(), 4); const int side_alt_height = font_measure_wrapped(side_text_width, alt.c_str(), 4); @@ -1067,10 +1094,10 @@ void draw_external_embed(skeets_app_t& app, int x, int y, int width, const Bsky: int text_x = x + 6; int text_width = std::max(120, width - 12); int text_y = y + 6; - const std::string title = sanitize_bitmap_text(post.ext_title.empty() ? "External media" : post.ext_title); + const std::string title = sanitize_display_text(post.ext_title.empty() ? "External media" : post.ext_title); const std::string description = title_only ? std::string{} - : sanitize_bitmap_text(post.ext_description.empty() ? "External media preview" : post.ext_description); + : sanitize_display_text(post.ext_description.empty() ? "External media preview" : post.ext_description); const int side_text_width = has_thumb ? std::max(120, width - thumb_width - 18) : text_width; const int side_title_height = font_measure_wrapped(side_text_width, title.c_str(), 4); const int side_description_height = description.empty() ? 0 : font_measure_wrapped(side_text_width, description.c_str(), 4); @@ -1540,6 +1567,7 @@ void save_settings(const skeets_app_t& app) { } void clear_saved_session(skeets_app_t& app) { + std::fprintf(stderr, "clear_saved_session: CLEARING session from config\n"); config_t* config = config_open(skeets_config_path()); if (config) { config_set_str(config, "handle", ""); @@ -1569,15 +1597,33 @@ void clear_saved_session(skeets_app_t& app) { } void persist_session(skeets_app_t& app, const Bsky::Session& session) { - config_t* config = config_open(skeets_config_path()); - if (config) { + const char* config_path = skeets_config_path(); + + if (session.handle.empty() && session.access_jwt.empty() && session.did.empty()) { + std::fprintf(stderr, "persist_session: SKIPPING save — session is empty (would overwrite valid config)\n"); + return; + } + + std::fprintf(stderr, "persist_session: saving handle='%s' did='%s' pds='%s' access_len=%zu refresh_len=%zu\n", + session.handle.c_str(), session.did.c_str(), session.pds_url.c_str(), + session.access_jwt.size(), session.refresh_jwt.size()); + + config_t* config = config_open(config_path); + if (!config) { + std::fprintf(stderr, "persist_session: config_open returned NULL for '%s'\n", config_path); + } else { config_set_str(config, "handle", session.handle.c_str()); config_set_str(config, "access_jwt", session.access_jwt.c_str()); config_set_str(config, "refresh_jwt", session.refresh_jwt.c_str()); config_set_str(config, "did", session.did.c_str()); config_set_str(config, "pds_url", session.pds_url.c_str()); config_set_str(config, "appview_url", session.appview_url.c_str()); - config_save(config); + int result = config_save(config); + if (result != 0) { + std::fprintf(stderr, "persist_session: config_save FAILED (result=%d)\n", result); + } else { + std::fprintf(stderr, "persist_session: config_save succeeded\n"); + } config_free(config); } app.bootstrap.session = session; @@ -1858,7 +1904,7 @@ int measure_thread_post_height(const skeets_app_t& app, 4); needed += 4; } else if (!post.text.empty()) { - const std::string text = sanitize_bitmap_text(post.text); + const std::string text = sanitize_display_text(post.text); needed += font_measure_wrapped(text_width_for_post, text.c_str(), 4); @@ -2324,7 +2370,7 @@ void render_feed_screen(skeets_app_t& app, bool full_refresh) { needed += font_measure_wrapped(post_text_width - 8, hidden_message.c_str(), line_spacing); needed += 4; } else { - const std::string measured_post_text = sanitize_bitmap_text(post.text); + const std::string measured_post_text = sanitize_display_text(post.text); needed += font_measure_wrapped(post_text_width - 8, measured_post_text.c_str(), line_spacing); needed += measure_embed_block_height(app, post, post_text_width - 8); } @@ -2335,7 +2381,7 @@ void render_feed_screen(skeets_app_t& app, bool full_refresh) { // Repost attribution if (!post.reposted_by.empty()) { - std::string repost_line = sanitize_bitmap_text(">> Reposted by @" + post.reposted_by); + std::string repost_line = sanitize_display_text(">> Reposted by @" + post.reposted_by); draw_text(app, text_left, cursor_y, @@ -2350,7 +2396,7 @@ void render_feed_screen(skeets_app_t& app, bool full_refresh) { std::string author = post.author.display_name.empty() ? "@" + post.author.handle : post.author.display_name + " @" + post.author.handle; - author = sanitize_bitmap_text(author); + author = sanitize_display_text(author); char time_buf[64]; str_format_time(time_buf, sizeof(time_buf), static_cast(post.indexed_at)); @@ -2426,7 +2472,7 @@ void render_feed_screen(skeets_app_t& app, bool full_refresh) { cursor_y = text_top + text_height; cursor_y += 4; } else if (!post.text.empty()) { - const std::string post_text = sanitize_bitmap_text(post.text); + const std::string post_text = sanitize_display_text(post.text); const int text_top = cursor_y; const int text_height = font_measure_wrapped(post_text_width - 8, post_text.c_str(), @@ -2510,18 +2556,33 @@ void render_feed_list_screen(skeets_app_t& app, bool full_refresh) { const int header_height = std::max(96, height / 11); const int row_height = std::max(116, height / 10); const int row_gap = 16; - const int content_width = width - (kOuterMargin * 2); + const int content_right = width - kOuterMargin - kThreadScrollbarWidth - kThreadScrollbarGap; + const int content_width = content_right - kOuterMargin; const int first_row_y = header_height + kOuterMargin; + const int content_bottom = height - kOuterMargin; + const int total = static_cast(app.available_feeds.size()); app.feed_list_back_button = {{kOuterMargin, 10, 160, header_height - 20}, kEmojiBack}; app.feed_list_rows.clear(); + // Clamp page_start + if (app.feed_list_page_start >= total) app.feed_list_page_start = std::max(0, total - 1); + if (app.feed_list_page_start < 0) app.feed_list_page_start = 0; + + // Layout scrollbar + layout_vertical_scrollbar(width, header_height, first_row_y, content_bottom, + app.feed_list_scrollbar_up_rect, + app.feed_list_scrollbar_down_rect, + app.feed_list_scrollbar_rect, + app.feed_list_scrollbar_thumb_rect); + skeets_framebuffer_clear(app.framebuffer, COLOR_WHITE); const skeets_rect_t header_rect{0, 0, width, header_height}; skeets_framebuffer_fill_rect(app.framebuffer, header_rect, kColorHeader); draw_button(app, app.feed_list_back_button, kColorButtonSecondary); draw_header_brand(app, header_rect); + int rendered = 0; if (app.available_feeds.empty()) { draw_wrapped_text(app, kOuterMargin, @@ -2532,9 +2593,9 @@ void render_feed_list_screen(skeets_app_t& app, bool full_refresh) { COLOR_BLACK, COLOR_WHITE); } else { - for (size_t index = 0; index < app.available_feeds.size(); ++index) { - const int row_y = first_row_y + static_cast(index) * (row_height + row_gap); - if (row_y + row_height > height - kOuterMargin) { + for (int index = app.feed_list_page_start; index < total; ++index) { + const int row_y = first_row_y + rendered * (row_height + row_gap); + if (row_y + row_height > content_bottom) { break; } @@ -2561,8 +2622,20 @@ void render_feed_list_screen(skeets_app_t& app, bool full_refresh) { skeets_text_role_t::settings_detail, kColorMeta, fill); + rendered++; } } + app.feed_list_page_count = rendered; + + // Draw scrollbar + draw_vertical_scrollbar(app, + app.feed_list_scrollbar_up_rect, + app.feed_list_scrollbar_down_rect, + app.feed_list_scrollbar_rect, + app.feed_list_scrollbar_thumb_rect, + total, + rendered, + app.feed_list_page_start); std::string error_message; const skeets_rect_t screen_rect = full_screen_rect(app); @@ -2654,7 +2727,7 @@ void render_thread_screen(skeets_app_t& app, bool full_refresh) { std::string author = post->author.display_name.empty() ? "@" + post->author.handle : post->author.display_name + " @" + post->author.handle; - author = sanitize_bitmap_text(author); + author = sanitize_display_text(author); const int author_y = cursor_y; if (app.profile_images_enabled) { @@ -2708,7 +2781,7 @@ void render_thread_screen(skeets_app_t& app, bool full_refresh) { cursor_y = text_top + text_height; cursor_y += 4; } else { - const std::string text = sanitize_bitmap_text(post->text); + const std::string text = sanitize_display_text(post->text); const int text_top = cursor_y; const int text_height = font_measure_wrapped(text_width_for_post, text.c_str(), @@ -3438,6 +3511,103 @@ int main(int argc, char* argv[]) { break; } + // Physical page-turn buttons (Kobo Libra Colour, etc.) + // Different Kobo models report different key codes for the same buttons: + // - KEY_PAGEDOWN/KEY_PAGEUP: standard page-turn codes + // - KEY_NEXTSONG/KEY_PREVIOUSSONG: some older Kobo models + // - KEY_BACK (158) / KEY_FORWARD (159): some Kobo gpio-keys + // - KEY_F23 (193) / KEY_F24 (194): Libra Colour gpio-keys + const bool is_page_forward = event.key_code == KEY_PAGEDOWN || + event.key_code == KEY_NEXTSONG || + event.key_code == KEY_FORWARD || + event.key_code == KEY_F24; + const bool is_page_back = event.key_code == KEY_PAGEUP || + event.key_code == KEY_PREVIOUSSONG || + event.key_code == KEY_BACK || + event.key_code == KEY_F23; + + if (is_page_forward || is_page_back) { + if (skeets_app.view_mode == skeets_view_mode_t::feed) { + if (is_page_forward) { + if (advance_feed_page(skeets_app)) { + render_feed_screen(skeets_app, true); + } + } else { + if (!skeets_app.feed_page_history.empty()) { + skeets_app.feed_page_start = skeets_app.feed_page_history.back(); + skeets_app.feed_page_history.pop_back(); + render_feed_screen(skeets_app, true); + } + } + continue; + } + + if (skeets_app.view_mode == skeets_view_mode_t::thread) { + std::vector> nodes; + if (skeets_app.thread_result.state == skeets_thread_state_t::loaded) { + flatten_thread_posts(skeets_app.thread_result.root, 0, nodes); + } + const int total = static_cast(nodes.size()); + const int visible = std::max(1, skeets_app.thread_page_count); + const int max_start = std::max(0, total - visible); + if (is_page_forward) { + if (skeets_app.thread_page_start < max_start) { + const int page_step = std::max(1, visible - 1); + skeets_app.thread_page_start = std::min(max_start, skeets_app.thread_page_start + page_step); + render_thread_screen(skeets_app, true); + } + } else { + if (skeets_app.thread_page_start > 0) { + const int page_step = std::max(1, visible - 1); + skeets_app.thread_page_start = std::max(0, skeets_app.thread_page_start - page_step); + render_thread_screen(skeets_app, true); + } + } + continue; + } + + if (skeets_app.view_mode == skeets_view_mode_t::settings) { + const int max_start = std::max(0, kScrollableSettingsRowCount - std::max(1, skeets_app.settings_page_count)); + if (is_page_forward) { + if (skeets_app.settings_page_start < max_start) { + const int page_step = std::max(1, skeets_app.settings_page_count - 1); + skeets_app.settings_page_start = std::min(max_start, skeets_app.settings_page_start + page_step); + render_settings_screen(skeets_app, true); + } + } else { + if (skeets_app.settings_page_start > 0) { + const int page_step = std::max(1, skeets_app.settings_page_count - 1); + skeets_app.settings_page_start = std::max(0, skeets_app.settings_page_start - page_step); + render_settings_screen(skeets_app, true); + } + } + continue; + } + + if (skeets_app.view_mode == skeets_view_mode_t::feed_list) { + const int total = static_cast(skeets_app.available_feeds.size()); + const int visible = std::max(1, skeets_app.feed_list_page_count); + const int max_start = std::max(0, total - visible); + if (is_page_forward) { + if (skeets_app.feed_list_page_start < max_start) { + const int page_step = std::max(1, visible - 1); + skeets_app.feed_list_page_start = std::min(max_start, skeets_app.feed_list_page_start + page_step); + render_feed_list_screen(skeets_app, true); + } + } else { + if (skeets_app.feed_list_page_start > 0) { + const int page_step = std::max(1, visible - 1); + skeets_app.feed_list_page_start = std::max(0, skeets_app.feed_list_page_start - page_step); + render_feed_list_screen(skeets_app, true); + } + } + continue; + } + + // For other views (dashboard, diagnostics) just ignore + continue; + } + skeets_app.status_line = "Unhandled key press"; skeets_app.input_line = "Linux input code " + std::to_string(event.key_code); render_screen(skeets_app, false); @@ -3591,12 +3761,54 @@ int main(int argc, char* argv[]) { continue; } + // Feed list scrollbar: UP + if (contains_point(skeets_app.feed_list_scrollbar_up_rect, event.x, event.y)) { + if (skeets_app.feed_list_page_start > 0) { + const int page_step = std::max(1, skeets_app.feed_list_page_count - 1); + skeets_app.feed_list_page_start = std::max(0, skeets_app.feed_list_page_start - page_step); + render_feed_list_screen(skeets_app, true); + } + continue; + } + + // Feed list scrollbar: DOWN + if (contains_point(skeets_app.feed_list_scrollbar_down_rect, event.x, event.y)) { + const int total = static_cast(skeets_app.available_feeds.size()); + const int max_start = std::max(0, total - std::max(1, skeets_app.feed_list_page_count)); + if (skeets_app.feed_list_page_start < max_start) { + const int page_step = std::max(1, skeets_app.feed_list_page_count - 1); + skeets_app.feed_list_page_start = std::min(max_start, skeets_app.feed_list_page_start + page_step); + render_feed_list_screen(skeets_app, true); + } + continue; + } + + // Feed list scrollbar: track drag + if (contains_point(skeets_app.feed_list_scrollbar_rect, event.x, event.y)) { + const int total = static_cast(skeets_app.available_feeds.size()); + const int visible = std::max(1, skeets_app.feed_list_page_count); + const int max_start = std::max(0, total - visible); + if (max_start > 0) { + const int relative_y = std::max(0, + std::min(event.y - skeets_app.feed_list_scrollbar_rect.y, + skeets_app.feed_list_scrollbar_rect.height - 1)); + skeets_app.feed_list_page_start = (relative_y * max_start) / + std::max(1, skeets_app.feed_list_scrollbar_rect.height - 1); + render_feed_list_screen(skeets_app, true); + } + continue; + } + for (size_t index = 0; index < skeets_app.feed_list_rows.size() && index < skeets_app.available_feeds.size(); ++index) { if (!contains_point(skeets_app.feed_list_rows[index].rect, event.x, event.y)) { continue; } - skeets_app.active_feed_source = skeets_app.available_feeds[index]; + // Map visible row index back to feed index (offset by page_start) + const int feed_index = skeets_app.feed_list_page_start + static_cast(index); + if (feed_index >= static_cast(skeets_app.available_feeds.size())) break; + + skeets_app.active_feed_source = skeets_app.available_feeds[feed_index]; skeets_app.view_mode = skeets_view_mode_t::feed; render_loading_screen(skeets_app, "Loading feed", diff --git a/src/platform/input.cpp b/src/platform/input.cpp index 3ed504b..37bc439 100644 --- a/src/platform/input.cpp +++ b/src/platform/input.cpp @@ -231,11 +231,34 @@ bool scan_input_device(const std::string& path, if (has_key) { std::array key_bits{}; if (ioctl(fd, EVIOCGBIT(EV_KEY, sizeof(key_bits)), key_bits.data()) >= 0) { - info.is_key_device = test_bit(key_bits, BTN_TOUCH) || test_bit(key_bits, KEY_POWER) || - test_bit(key_bits, KEY_NEXTSONG) || test_bit(key_bits, KEY_PREVIOUSSONG); + // Accept any device that reports EV_KEY. On Kobo devices, + // page-turn buttons can be on "gpio-keys" with arbitrary key + // codes that vary between models, so we cannot enumerate them + // all. The only devices we want to exclude are pure touchscreen + // drivers that only report touch-tool bits and nothing else, + // but even those are useful for BTN_TOUCH snow-protocol support + // so we accept them too. + info.is_key_device = true; + + std::fprintf(stderr, "input scan: %s name='%s' has_key=1 BTN_TOUCH=%d" + " POWER=%d PAGEUP=%d PAGEDOWN=%d NEXTSONG=%d PREVIOUSSONG=%d HOME=%d" + " => is_key=%d\n", + path.c_str(), info.name.c_str(), + test_bit(key_bits, BTN_TOUCH) ? 1 : 0, + test_bit(key_bits, KEY_POWER) ? 1 : 0, + test_bit(key_bits, KEY_PAGEUP) ? 1 : 0, + test_bit(key_bits, KEY_PAGEDOWN) ? 1 : 0, + test_bit(key_bits, KEY_NEXTSONG) ? 1 : 0, + test_bit(key_bits, KEY_PREVIOUSSONG) ? 1 : 0, + test_bit(key_bits, KEY_HOME) ? 1 : 0, + info.is_key_device ? 1 : 0); } } + std::fprintf(stderr, "input scan: %s name='%s' is_touch=%d is_key=%d\n", + path.c_str(), info.name.c_str(), + info.is_touch_device ? 1 : 0, + info.is_key_device ? 1 : 0); return info.is_touch_device || info.is_key_device; } @@ -368,8 +391,16 @@ bool skeets_input_open(skeets_input_t& input, const int index = static_cast(input.devices.size()); input.devices.push_back(std::move(info)); - if (input.devices[index].is_touch_device && input.touch_device_index < 0) input.touch_device_index = index; - if (input.devices[index].is_key_device && input.key_device_index < 0) input.key_device_index = index; + if (input.devices[index].is_touch_device && input.touch_device_index < 0) { + input.touch_device_index = index; + std::fprintf(stderr, "input open: touch device [%d] = %s (%s)\n", index, + input.devices[index].path.c_str(), input.devices[index].name.c_str()); + } + if (input.devices[index].is_key_device && input.key_device_index < 0) { + input.key_device_index = index; + std::fprintf(stderr, "input open: key device [%d] = %s (%s)\n", index, + input.devices[index].path.c_str(), input.devices[index].name.c_str()); + } } closedir(dir); @@ -445,9 +476,10 @@ bool skeets_input_poll(skeets_input_t& input, handle_touch_abs(input, device, raw_event); } else if (raw_event.type == EV_KEY) { handle_touch_key(input, raw_event, event); - if (event.type == skeets_input_event_type_t::key_press && - device_indexes[index] == input.key_device_index) { + if (event.type == skeets_input_event_type_t::key_press && device.is_key_device) { event.source_path = device.path; + std::fprintf(stderr, "input poll: key_press code=%d from %s (%s)\n", + event.key_code, device.path.c_str(), device.name.c_str()); return true; } } else if (device.is_touch_device && raw_event.type == EV_SYN && raw_event.code == SYN_REPORT) { diff --git a/src/ui/font.cpp b/src/ui/font.cpp index 2984cc5..32a56a7 100644 --- a/src/ui/font.cpp +++ b/src/ui/font.cpp @@ -199,6 +199,10 @@ void font_set_ot_enabled(bool enabled) { s_ot_enabled = enabled; } +bool font_ot_enabled(void) { + return s_ot_enabled; +} + int font_cell_w(void) { return s_font_w; } int font_cell_h(void) { return s_font_h; } int font_line_height(int size_px, font_style_t style) { return font_line_height_internal(size_px, style); } diff --git a/src/ui/font.h b/src/ui/font.h index 579eda5..e105233 100644 --- a/src/ui/font.h +++ b/src/ui/font.h @@ -24,6 +24,10 @@ void font_init(fb_t *fb); * built-in bitmap font support. */ void font_set_ot_enabled(bool enabled); +/* Returns true when OpenType (TrueType) font rendering is active. + * When false, only the built-in bitmap VGA font is available (ASCII only). */ +bool font_ot_enabled(void); + /* Returns current font cell width in pixels. */ int font_cell_w(void); diff --git a/src/util/config.cpp b/src/util/config.cpp index b5cd400..5f3aea6 100644 --- a/src/util/config.cpp +++ b/src/util/config.cpp @@ -1,9 +1,11 @@ #include "config.h" #include "str.h" -#include -#include -#include +#include +#include +#include +#include +#include #define CONFIG_MAX_ENTRIES 128 @@ -28,12 +30,19 @@ static config_entry_t *find_entry(config_t *cfg, const char *key) { config_t *config_open(const char *path) { config_t *cfg = (config_t *)calloc(1, sizeof(config_t)); - if (!cfg) return NULL; + if (!cfg) { + std::fprintf(stderr, "config_open: calloc failed for '%s'\n", path ? path : "(null)"); + return NULL; + } str_safe_copy(cfg->path, path, sizeof(cfg->path)); FILE *f = fopen(path, "r"); - if (!f) return cfg; /* new config, no file yet */ + if (!f) { + std::fprintf(stderr, "config_open: file '%s' not found (errno=%d), returning empty config\n", path, errno); + return cfg; /* new config, no file yet */ + } + std::fprintf(stderr, "config_open: reading '%s'\n", path); char line[CONFIG_MAX_KEY + CONFIG_MAX_VALUE + 4]; while (fgets(line, sizeof(line), f)) { str_trim(line); @@ -49,6 +58,7 @@ config_t *config_open(const char *path) { config_set_str(cfg, k, v); } fclose(f); + std::fprintf(stderr, "config_open: loaded %d entries from '%s'\n", cfg->count, path); return cfg; } @@ -57,13 +67,42 @@ void config_free(config_t *cfg) { } int config_save(config_t *cfg) { - if (!cfg) return -1; + if (!cfg) { + std::fprintf(stderr, "config_save: cfg is NULL\n"); + return -1; + } FILE *f = fopen(cfg->path, "w"); - if (!f) return -1; + if (!f) { + std::fprintf(stderr, "config_save: failed to open '%s' for writing: %s (errno=%d)\n", + cfg->path, std::strerror(errno), errno); + return -1; + } fprintf(f, "# sKeets config\n"); for (int i = 0; i < cfg->count; i++) fprintf(f, "%s=%s\n", cfg->entries[i].key, cfg->entries[i].value); - fclose(f); + + // Flush to kernel buffers + if (fflush(f) != 0) { + std::fprintf(stderr, "config_save: fflush failed for '%s': %s (errno=%d)\n", + cfg->path, std::strerror(errno), errno); + fclose(f); + return -1; + } + + // Sync to persistent storage (critical on embedded devices) + int fd = fileno(f); + if (fd >= 0 && fsync(fd) != 0) { + std::fprintf(stderr, "config_save: fsync failed for '%s': %s (errno=%d)\n", + cfg->path, std::strerror(errno), errno); + // Don't fail - data may still reach disk on fclose + } + + if (fclose(f) != 0) { + std::fprintf(stderr, "config_save: fclose failed for '%s': %s (errno=%d)\n", + cfg->path, std::strerror(errno), errno); + return -1; + } + std::fprintf(stderr, "config_save: successfully wrote %d entries to '%s'\n", cfg->count, cfg->path); return 0; } diff --git a/src/util/paths.cpp b/src/util/paths.cpp index f497366..c43ee59 100644 --- a/src/util/paths.cpp +++ b/src/util/paths.cpp @@ -1,5 +1,6 @@ #include "paths.h" +#include #include #include #include @@ -84,8 +85,20 @@ const char *skeets_cache_dir() { int skeets_ensure_data_dirs() { const path_state_t &state = paths(); - if (!mkdirs(state.data_dir)) return -1; - if (!mkdirs(state.cache_dir)) return -1; + fprintf(stderr, "paths: ensuring data directories exist\n"); + fprintf(stderr, "paths: data_dir='%s'\n", state.data_dir.c_str()); + fprintf(stderr, "paths: config_path='%s'\n", state.config_path.c_str()); + fprintf(stderr, "paths: cache_dir='%s'\n", state.cache_dir.c_str()); + + if (!mkdirs(state.data_dir)) { + fprintf(stderr, "paths: FAILED to create data_dir='%s'\n", state.data_dir.c_str()); + return -1; + } + if (!mkdirs(state.cache_dir)) { + fprintf(stderr, "paths: FAILED to create cache_dir='%s'\n", state.cache_dir.c_str()); + return -1; + } + fprintf(stderr, "paths: data directories ready\n"); return 0; }