// Copyright (c) 2026 Pyxis contributors // SPDX-License-Identifier: MIT #include "MapScreen.h" #include "MapTileStreamReader.h" #ifdef ARDUINO #include "Theme.h" #include "../LVGL/LVGLInit.h" #include "../LVGL/LVGLLock.h" #include #include #define LODEPNG_NO_COMPILE_CPP extern "C" { #include } #include #include #include namespace UI { namespace LXMF { namespace { constexpr std::size_t TILE_PIXEL_COUNT = 256U * 256U; class PackReadStream final : public Pyxis::MapTileReadStream { public: explicit PackReadStream(Hardware::TDeck::MapTilePack& pack) : pack_(pack) {} Pyxis::MapTileStreamResult begin(const Hardware::TDeck::TileKey& key, std::uint32_t& size) override { const Hardware::TDeck::MapTilePackResult result = pack_.beginGet(key, size); if (result == Hardware::TDeck::MapTilePackResult::OK) return Pyxis::MapTileStreamResult::OK; if (result == Hardware::TDeck::MapTilePackResult::UNCOVERED || result == Hardware::TDeck::MapTilePackResult::TILE_MISSING || result == Hardware::TDeck::MapTilePackResult::NOT_INITIALIZED) { return Pyxis::MapTileStreamResult::MISS; } if (result == Hardware::TDeck::MapTilePackResult::STORAGE_UNAVAILABLE) { return Pyxis::MapTileStreamResult::STORAGE_UNAVAILABLE; } return Pyxis::MapTileStreamResult::IO_ERROR; } Pyxis::MapTileStreamResult read(std::uint8_t* output, std::size_t capacity, std::size_t& count) override { const Hardware::TDeck::MapTilePackResult result = pack_.readGetChunk(output, capacity, count); if (result == Hardware::TDeck::MapTilePackResult::OK) return Pyxis::MapTileStreamResult::OK; if (result == Hardware::TDeck::MapTilePackResult::STORAGE_UNAVAILABLE) { return Pyxis::MapTileStreamResult::STORAGE_UNAVAILABLE; } return Pyxis::MapTileStreamResult::IO_ERROR; } void end() override { pack_.endGet(); } private: Hardware::TDeck::MapTilePack& pack_; }; class AtomicStopSource final : public Pyxis::MapTileStopSource { public: explicit AtomicStopSource(const std::atomic& stop) : stop_(stop) {} bool stopRequested() const override { return stop_.load(std::memory_order_acquire); } private: const std::atomic& stop_; }; lv_obj_t* createToolbarButton(lv_obj_t* parent, const char* text, lv_event_cb_t callback, void* context, lv_coord_t width, lv_obj_t** label_output = nullptr) { lv_obj_t* button = lv_btn_create(parent); lv_obj_set_size(button, width, 26); lv_obj_set_style_pad_all(button, 0, 0); lv_obj_set_style_bg_color(button, Theme::surfaceInput(), 0); lv_obj_set_style_bg_color(button, Theme::primaryPressed(), LV_STATE_PRESSED); lv_obj_add_event_cb(button, callback, LV_EVENT_CLICKED, context); lv_obj_t* label = lv_label_create(button); lv_label_set_text(label, text); lv_obj_center(label); if (label_output) *label_output = label; return button; } const char* styleLabel(const char* style_id) { if (style_id == nullptr) return "Style"; if (std::strcmp(style_id, "osm-bright") == 0) return "Bright"; if (std::strcmp(style_id, "dark-matter") == 0) return "Dark"; if (std::strcmp(style_id, "positron") == 0) return "Positron"; if (std::strcmp(style_id, "toner") == 0) return "Toner"; return "Style"; } } // namespace static_assert(sizeof(lv_color_t) == 2U, "offline tile buffers assume LVGL RGB565 true color"); MapScreen::MapScreen(lv_obj_t* parent) : screen_(nullptr), toolbar_(nullptr), viewport_(nullptr), status_label_(nullptr), attribution_label_(nullptr), zoom_label_(nullptr), zoom_out_button_(nullptr), zoom_in_button_(nullptr), recenter_button_(nullptr), style_button_(nullptr), style_label_(nullptr), pan_buttons_{}, tile_images_{}, tile_descriptors_{}, tile_pixels_{}, decoded_tile_cache_(TILE_PIXEL_COUNT), decoded_cache_pixels_{}, approximation_halos_{}, markers_{}, marker_labels_{}, presenter_(), style_selector_(), storage_(), pack_(storage_), style_catalog_(storage_), style_request_{}, style_request_pending_(false), style_request_lifecycle_epoch_(0U), pack_attribution_{}, screen_visible_(false), style_lifecycle_epoch_(0U), style_lifecycle_exhausted_(false), pack_refresh_epoch_(0U), compressed_staging_(nullptr), state_mutex_(nullptr), worker_task_(nullptr), worker_exited_(true), worker_started_(false), requests_released_(false), has_location_fix_(false), center_initialized_(false), current_location_{}, dragging_(false), last_drag_point_{0, 0}, back_callback_() { LVGL_LOCK(); state_mutex_ = xSemaphoreCreateMutex(); // Reserve the mandatory SD/PNG staging buffer before optional decoded // cache entries. A partial cache must never prevent the worker starting. compressed_staging_ = static_cast(heap_caps_malloc( MAX_COMPRESSED_TILE_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); screen_ = lv_obj_create(parent ? parent : lv_scr_act()); lv_obj_set_size(screen_, 320, 240); lv_obj_set_style_pad_all(screen_, 0, 0); lv_obj_set_style_border_width(screen_, 0, 0); lv_obj_set_style_radius(screen_, 0, 0); lv_obj_set_style_bg_color(screen_, Theme::surface(), 0); lv_obj_clear_flag(screen_, LV_OBJ_FLAG_SCROLLABLE); toolbar_ = lv_obj_create(screen_); lv_obj_set_size(toolbar_, 320, 32); lv_obj_align(toolbar_, LV_ALIGN_TOP_MID, 0, 0); lv_obj_set_style_pad_all(toolbar_, 2, 0); lv_obj_set_style_pad_gap(toolbar_, 3, 0); lv_obj_set_style_border_width(toolbar_, 0, 0); lv_obj_set_style_radius(toolbar_, 0, 0); lv_obj_set_style_bg_color(toolbar_, Theme::surfaceHeader(), 0); lv_obj_set_flex_flow(toolbar_, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(toolbar_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); back_button_ = createToolbarButton(toolbar_, LV_SYMBOL_LEFT, onBack, this, 36); zoom_out_button_ = createToolbarButton(toolbar_, LV_SYMBOL_MINUS, onZoomOut, this, 36); zoom_label_ = lv_label_create(toolbar_); lv_obj_set_width(zoom_label_, 62); lv_obj_set_style_text_align(zoom_label_, LV_TEXT_ALIGN_CENTER, 0); lv_label_set_text(zoom_label_, "z2"); zoom_in_button_ = createToolbarButton(toolbar_, LV_SYMBOL_PLUS, onZoomIn, this, 36); recenter_button_ = createToolbarButton(toolbar_, LV_SYMBOL_GPS, onRecenter, this, 44); style_button_ = createToolbarButton(toolbar_, "Style", onStyle, this, 80, &style_label_); lv_obj_add_state(style_button_, LV_STATE_DISABLED); lv_obj_set_width(style_label_, 74); lv_label_set_long_mode(style_label_, LV_LABEL_LONG_DOT); lv_obj_set_style_text_align(style_label_, LV_TEXT_ALIGN_CENTER, 0); viewport_ = lv_obj_create(screen_); lv_obj_set_size(viewport_, Pyxis::MapScreenPresenter::VIEWPORT_WIDTH, Pyxis::MapScreenPresenter::VIEWPORT_HEIGHT); lv_obj_align(viewport_, LV_ALIGN_TOP_MID, 0, 32); lv_obj_set_style_pad_all(viewport_, 0, 0); lv_obj_set_style_border_width(viewport_, 0, 0); lv_obj_set_style_radius(viewport_, 0, 0); lv_obj_set_style_bg_color(viewport_, lv_color_hex(0x25282b), 0); lv_obj_set_style_bg_opa(viewport_, LV_OPA_COVER, 0); lv_obj_add_flag(viewport_, LV_OBJ_FLAG_CLICKABLE); lv_obj_clear_flag(viewport_, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(viewport_, onMapPressed, LV_EVENT_PRESSED, this); lv_obj_add_event_cb(viewport_, onMapPressing, LV_EVENT_PRESSING, this); lv_obj_add_event_cb(viewport_, onMapReleased, LV_EVENT_RELEASED, this); lv_obj_add_event_cb(viewport_, onMapReleased, LV_EVENT_PRESS_LOST, this); for (std::size_t index = 0; index < TILE_COUNT; ++index) { tile_pixels_[index] = static_cast(heap_caps_malloc( TILE_PIXEL_COUNT * sizeof(lv_color_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); if (tile_pixels_[index]) { for (std::size_t pixel = 0; pixel < TILE_PIXEL_COUNT; ++pixel) { const bool light = (((pixel % 256U) / 32U) + ((pixel / 256U) / 32U)) % 2U == 0U; tile_pixels_[index][pixel] = lv_color_hex(light ? 0x303438 : 0x272a2e); } } lv_img_dsc_t& descriptor = tile_descriptors_[index]; std::memset(&descriptor, 0, sizeof(descriptor)); descriptor.header.always_zero = 0; descriptor.header.w = 256; descriptor.header.h = 256; descriptor.header.cf = LV_IMG_CF_TRUE_COLOR; descriptor.data_size = TILE_PIXEL_COUNT * sizeof(lv_color_t); descriptor.data = reinterpret_cast( tile_pixels_[index]); tile_images_[index] = lv_img_create(viewport_); lv_obj_add_flag(tile_images_[index], LV_OBJ_FLAG_HIDDEN); } if (compressed_staging_) { for (std::size_t index = 0U; index < Pyxis::DecodedTileCache::CAPACITY; ++index) { decoded_cache_pixels_[index] = static_cast(heap_caps_malloc( TILE_PIXEL_COUNT * sizeof(std::uint16_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); if (decoded_cache_pixels_[index]) { (void)decoded_tile_cache_.attach(index, decoded_cache_pixels_[index]); } } } for (std::size_t index = 0; index < MARKER_COUNT; ++index) { approximation_halos_[index] = lv_obj_create(viewport_); lv_obj_set_style_radius(approximation_halos_[index], LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(approximation_halos_[index], Theme::info(), 0); lv_obj_set_style_bg_opa(approximation_halos_[index], LV_OPA_20, 0); lv_obj_set_style_border_width(approximation_halos_[index], 1, 0); lv_obj_set_style_border_color(approximation_halos_[index], Theme::info(), 0); lv_obj_set_style_pad_all(approximation_halos_[index], 0, 0); lv_obj_clear_flag(approximation_halos_[index], LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(approximation_halos_[index], LV_OBJ_FLAG_HIDDEN); markers_[index] = lv_obj_create(viewport_); lv_obj_set_size(markers_[index], 10, 10); lv_obj_set_style_radius(markers_[index], LV_RADIUS_CIRCLE, 0); lv_obj_set_style_border_width(markers_[index], 2, 0); lv_obj_set_style_border_color(markers_[index], lv_color_white(), 0); lv_obj_set_style_pad_all(markers_[index], 0, 0); lv_obj_clear_flag(markers_[index], LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(markers_[index], LV_OBJ_FLAG_HIDDEN); marker_labels_[index] = lv_label_create(viewport_); lv_label_set_text(marker_labels_[index], ""); lv_obj_set_style_text_font(marker_labels_[index], &lv_font_montserrat_12, 0); lv_obj_add_flag(marker_labels_[index], LV_OBJ_FLAG_HIDDEN); } static const char* pan_symbols[4] = { LV_SYMBOL_UP, LV_SYMBOL_DOWN, LV_SYMBOL_LEFT, LV_SYMBOL_RIGHT}; static const lv_align_t pan_alignments[4] = { LV_ALIGN_TOP_MID, LV_ALIGN_BOTTOM_MID, LV_ALIGN_LEFT_MID, LV_ALIGN_RIGHT_MID}; static const lv_coord_t pan_x[4] = {0, 0, 2, -2}; static const lv_coord_t pan_y[4] = {2, -2, 0, 0}; for (std::size_t index = 0; index < 4U; ++index) { pan_buttons_[index] = createToolbarButton( viewport_, pan_symbols[index], onPan, this, 30); lv_obj_set_user_data(pan_buttons_[index], reinterpret_cast(index)); lv_obj_align(pan_buttons_[index], pan_alignments[index], pan_x[index], pan_y[index]); lv_obj_set_style_bg_opa(pan_buttons_[index], LV_OPA_60, 0); } status_label_ = lv_label_create(viewport_); lv_label_set_text(status_label_, "Offline tiles"); lv_obj_set_style_text_color(status_label_, Theme::textTertiary(), 0); lv_obj_set_style_bg_color(status_label_, Theme::surfaceElevated(), 0); lv_obj_set_style_bg_opa(status_label_, LV_OPA_70, 0); lv_obj_set_style_pad_all(status_label_, 3, 0); lv_obj_align(status_label_, LV_ALIGN_BOTTOM_LEFT, 4, -4); attribution_label_ = lv_label_create(viewport_); lv_label_set_text(attribution_label_, "No active map pack"); lv_obj_set_style_text_color(attribution_label_, Theme::textTertiary(), 0); lv_obj_set_style_text_font(attribution_label_, &lv_font_montserrat_12, 0); lv_obj_set_style_bg_color(attribution_label_, Theme::surfaceElevated(), 0); lv_obj_set_style_bg_opa(attribution_label_, LV_OPA_70, 0); lv_obj_set_style_pad_all(attribution_label_, 2, 0); lv_obj_align(attribution_label_, LV_ALIGN_BOTTOM_RIGHT, -3, -3); lv_obj_add_flag(screen_, LV_OBJ_FLAG_HIDDEN); } MapScreen::~MapScreen() { stopWorker(); LVGL_LOCK(); if (screen_) lv_obj_del(screen_); screen_ = nullptr; for (std::size_t index = 0; index < TILE_COUNT; ++index) { if (tile_pixels_[index]) heap_caps_free(tile_pixels_[index]); tile_pixels_[index] = nullptr; } for (std::size_t index = 0U; index < Pyxis::DecodedTileCache::CAPACITY; ++index) { if (decoded_cache_pixels_[index]) heap_caps_free(decoded_cache_pixels_[index]); decoded_cache_pixels_[index] = nullptr; } if (compressed_staging_) heap_caps_free(compressed_staging_); compressed_staging_ = nullptr; if (state_mutex_) vSemaphoreDelete(state_mutex_); state_mutex_ = nullptr; } bool MapScreen::lockState(TickType_t ticks) { return state_mutex_ && xSemaphoreTake(state_mutex_, ticks) == pdTRUE; } void MapScreen::unlockState() { xSemaphoreGive(state_mutex_); } bool MapScreen::startWorker() { if (worker_started_) return true; if (!state_mutex_ || !compressed_staging_) return false; stop_requested_.store(false, std::memory_order_release); worker_exited_.store(false, std::memory_order_release); const BaseType_t created = xTaskCreatePinnedToCore( workerEntry, "map-tile-worker", 16384, this, 1, &worker_task_, 0); if (created != pdPASS) { worker_exited_.store(true, std::memory_order_release); worker_task_ = nullptr; return false; } worker_started_ = true; return true; } void MapScreen::stopWorker() { if (!worker_started_) return; stop_requested_.store(true, std::memory_order_release); if (worker_task_) xTaskNotifyGive(worker_task_); const TickType_t started = xTaskGetTickCount(); const TickType_t shutdown_limit = pdMS_TO_TICKS(20000); while (!worker_exited_.load(std::memory_order_acquire)) { if ((xTaskGetTickCount() - started) >= shutdown_limit) { // Do not destroy storage or decoder state while the worker // may still own it. A controlled restart is safer than UAF or an // unbounded UI teardown after a wedged hardware operation. esp_restart(); for (;;) vTaskDelay(pdMS_TO_TICKS(1000)); } vTaskDelay(pdMS_TO_TICKS(1)); } worker_task_ = nullptr; worker_started_ = false; } void MapScreen::workerEntry(void* context) { static_cast(context)->workerLoop(); } void MapScreen::publishPackAttribution() { const char* attribution = pack_.hasSelection() ? pack_.metadata().attribution : "No active map pack"; if (!lockState(portMAX_DELAY)) return; std::strncpy(pack_attribution_, attribution, sizeof(pack_attribution_) - 1U); pack_attribution_[sizeof(pack_attribution_) - 1U] = '\0'; unlockState(); } void MapScreen::publishStyleCatalog(std::uint32_t activation_token, std::uint32_t request_lifecycle_epoch, bool activation_failed) { Pyxis::MapStyleSummary summaries[Pyxis::MapStyleSelector::MAX_STYLES] = {}; char active_id[Pyxis::MapPackManifest::PACK_ID_CAPACITY] = {}; const std::size_t count = style_catalog_.count(); for (std::size_t index = 0U; index < count; ++index) { const Hardware::TDeck::MapStyleSummary* source = style_catalog_.style(index); if (source == nullptr) continue; std::strncpy(summaries[index].id, source->id, sizeof(summaries[index].id) - 1U); std::strncpy(summaries[index].label, styleLabel(source->id), sizeof(summaries[index].label) - 1U); if (source->active) { std::strncpy(active_id, source->id, sizeof(active_id) - 1U); } } if (!lockState(portMAX_DELAY)) return; const bool retain_error = activation_failed && screen_visible_.load(std::memory_order_acquire) && !style_lifecycle_exhausted_ && request_lifecycle_epoch == style_lifecycle_epoch_; if (activation_token == 0U) { (void)style_selector_.setCatalog(style_catalog_.generation(), summaries, count, active_id[0] == '\0' ? nullptr : active_id); } else { (void)style_selector_.reconcileActivation( activation_token, style_catalog_.generation(), summaries, count, active_id[0] == '\0' ? nullptr : active_id, retain_error); } unlockState(); } bool MapScreen::beginStyleActivationCommit(void* raw_context) { StyleActivationCommitContext* context = static_cast(raw_context); if (context == NULL || context->screen == NULL) return false; MapScreen* screen = context->screen; if (!screen->lockState(portMAX_DELAY)) return false; const bool admitted = screen->screen_visible_.load(std::memory_order_acquire) && !screen->style_lifecycle_exhausted_ && context->lifecycle_epoch == screen->style_lifecycle_epoch_ && screen->style_selector_.activationOwnedBy(context->token); // This is the lifecycle linearization point. Release before commitWrite() // so LVGL navigation never waits on SD I/O. A hide that wins this lock // rejects publication; a commit admitted first represents visible intent. screen->unlockState(); return admitted; } void MapScreen::workerLoop() { std::uint32_t handled_pack_refresh_epoch = pack_refresh_epoch_.load(std::memory_order_acquire); (void)pack_.initialize(); publishPackAttribution(); if (style_catalog_.discover() == Hardware::TDeck::MapStyleCatalogResult::OK) { publishStyleCatalog(); } while (!stop_requested_.load(std::memory_order_acquire)) { const bool screen_visible = screen_visible_.load(std::memory_order_acquire); const std::uint32_t pack_refresh_epoch = pack_refresh_epoch_.load(std::memory_order_acquire); if (pack_refresh_epoch != handled_pack_refresh_epoch) { const bool had_selection = pack_.hasSelection(); const std::uint32_t previous_generation = pack_.selectionGeneration(); char previous_pack_id[Pyxis::MapPackManifest::PACK_ID_CAPACITY] = {}; if (had_selection) { std::memcpy(previous_pack_id, pack_.metadata().pack_id, sizeof(previous_pack_id)); } (void)pack_.initialize(); const bool has_selection = pack_.hasSelection(); if (had_selection != has_selection || (had_selection && has_selection && (std::strcmp(previous_pack_id, pack_.metadata().pack_id) != 0 || previous_generation != pack_.selectionGeneration()))) { decoded_tile_cache_.clear(); } publishPackAttribution(); if (style_catalog_.discover() == Hardware::TDeck::MapStyleCatalogResult::OK) { publishStyleCatalog(); } handled_pack_refresh_epoch = pack_refresh_epoch; } Pyxis::MapStyleRequest style_request{}; std::uint32_t style_request_lifecycle_epoch = 0U; bool have_style_request = false; if (lockState(pdMS_TO_TICKS(20))) { if (style_request_pending_) { const bool style_screen_visible = screen_visible_.load(std::memory_order_acquire); if (!style_screen_visible || style_lifecycle_exhausted_ || style_request_lifecycle_epoch_ != style_lifecycle_epoch_) { (void)style_selector_.cancelPending(style_request_.token); style_request_pending_ = false; style_request_ = Pyxis::MapStyleRequest(); } else if (style_selector_.activationOwnedBy(style_request_.token)) { style_request = style_request_; style_request_lifecycle_epoch = style_request_lifecycle_epoch_; style_request_pending_ = false; style_request_ = Pyxis::MapStyleRequest(); have_style_request = true; } else { style_request_pending_ = false; style_request_ = Pyxis::MapStyleRequest(); } } unlockState(); } if (have_style_request) { bool activation_admitted = false; if (lockState(portMAX_DELAY)) { activation_admitted = screen_visible_.load(std::memory_order_acquire) && !style_lifecycle_exhausted_ && style_request_lifecycle_epoch == style_lifecycle_epoch_ && style_selector_.activationOwnedBy(style_request.token); if (!activation_admitted) { (void)style_selector_.cancelPending(style_request.token); } unlockState(); } if (!activation_admitted) continue; StyleActivationCommitContext commit_context = { this, style_request.token, style_request_lifecycle_epoch}; const Hardware::TDeck::MapStyleCatalogResult activation = style_catalog_.activate(style_request.catalog_generation, style_request.style_id, &MapScreen::beginStyleActivationCommit, &commit_context); if (activation == Hardware::TDeck::MapStyleCatalogResult::CANCELLED) { if (lockState(portMAX_DELAY)) { (void)style_selector_.cancelPending(style_request.token); unlockState(); } continue; } bool success = activation == Hardware::TDeck::MapStyleCatalogResult::OK; if (success) { const Hardware::TDeck::MapTilePackResult initialized = pack_.initialize(); success = initialized == Hardware::TDeck::MapTilePackResult::OK && pack_.hasSelection() && std::strcmp(pack_.metadata().pack_id, style_request.style_id) == 0; } if (success) { decoded_tile_cache_.clear(); publishPackAttribution(); } Pyxis::MapStyleCompletion style_completion{}; style_completion.token = style_request.token; style_completion.catalog_generation = style_request.catalog_generation; std::strncpy(style_completion.style_id, style_request.style_id, sizeof(style_completion.style_id) - 1U); style_completion.success = success; if (lockState(portMAX_DELAY)) { const bool same_visible_lifecycle = screen_visible_.load(std::memory_order_acquire) && !style_lifecycle_exhausted_ && style_request_lifecycle_epoch == style_lifecycle_epoch_; const bool completed = style_selector_.complete(style_completion); if (completed && !same_visible_lifecycle && !success) { (void)style_selector_.clearError(); } if (success) { presenter_.invalidateTiles(); requests_released_ = false; } unlockState(); } // Reconcile the reservation owner's catalog on every outcome. A // durable activate() may have advanced its generation even when the // subsequent pack reload failed; stale-generation failures can also // follow a refresh that raced the queued request. Keep the lease until // both the in-memory truth and any successful rediscovery are published. publishStyleCatalog(style_request.token, style_request_lifecycle_epoch, !success); if (style_catalog_.discover() == Hardware::TDeck::MapStyleCatalogResult::OK) { publishStyleCatalog(style_request.token, style_request_lifecycle_epoch, !success); } if (lockState(portMAX_DELAY)) { (void)style_selector_.releaseActivation(style_request.token); unlockState(); } continue; } Pyxis::MapTileRequest request{}; bool have_request = false; if (lockState(pdMS_TO_TICKS(20))) { if (requests_released_ && screen_visible) { have_request = presenter_.takeRequest(request); } unlockState(); } if (!have_request) { ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100)); continue; } Pyxis::MapTileCompletion completion{}; completion.generation = request.generation; completion.frame_epoch = request.frame_epoch; completion.slot_token = request.slot_token; completion.slot_index = request.slot_index; completion.key = request.key; completion.result = loadTile(request); if (lockState(portMAX_DELAY)) { (void)presenter_.publishCompletion(completion); unlockState(); } } worker_exited_.store(true, std::memory_order_release); vTaskDelete(nullptr); } Pyxis::MapTileLoadResult MapScreen::loadTile( const Pyxis::MapTileRequest& request) { return readTile(request); } Pyxis::MapTileLoadResult MapScreen::readTile( const Pyxis::MapTileRequest& request) { if (request.slot_index < TILE_COUNT && tile_pixels_[request.slot_index] && decoded_tile_cache_.get( request.key, reinterpret_cast(tile_pixels_[request.slot_index]), TILE_PIXEL_COUNT)) { return Pyxis::MapTileLoadResult::READY; } return readCompressedTile(request); } Pyxis::MapTileLoadResult MapScreen::readCompressedTile( const Pyxis::MapTileRequest& request) { PackReadStream pack_stream(pack_); AtomicStopSource stop(stop_requested_); std::size_t total = 0U; const Pyxis::MapTileStreamResult read = Pyxis::MapTileStreamReader::readExact( pack_stream, stop, request.key, compressed_staging_, MAX_COMPRESSED_TILE_BYTES, READ_CHUNK_BYTES, total); if (read == Pyxis::MapTileStreamResult::MISS) { return Pyxis::MapTileLoadResult::MISS; } if (read == Pyxis::MapTileStreamResult::STORAGE_UNAVAILABLE) { return Pyxis::MapTileLoadResult::STORAGE_UNAVAILABLE; } if (read == Pyxis::MapTileStreamResult::TOO_LARGE) { return Pyxis::MapTileLoadResult::TOO_LARGE; } if (read != Pyxis::MapTileStreamResult::OK) { return Pyxis::MapTileLoadResult::IO_ERROR; } unsigned char* rgb = nullptr; unsigned width = 0U; unsigned height = 0U; LodePNGState decode_state; lodepng_state_init(&decode_state); // Bound the largest legal 256x256 source scanline stream (RGBA16 plus // filter bytes/interlace overhead) independently of compressed size. decode_state.decoder.zlibsettings.max_output_size = 528U * 1024U; unsigned decode_error = lodepng_inspect( &width, &height, &decode_state, compressed_staging_, total); if (decode_error != 0U || width != 256U || height != 256U) { lodepng_state_cleanup(&decode_state); return Pyxis::MapTileLoadResult::INVALID_PNG; } decode_state.info_raw.colortype = LCT_RGB; decode_state.info_raw.bitdepth = 8U; decode_error = lodepng_decode( &rgb, &width, &height, &decode_state, compressed_staging_, total); lodepng_state_cleanup(&decode_state); if (decode_error != 0U || rgb == nullptr || width != 256U || height != 256U) { if (rgb) lv_mem_free(rgb); return Pyxis::MapTileLoadResult::INVALID_PNG; } if (request.slot_index >= TILE_COUNT || !tile_pixels_[request.slot_index]) { lv_mem_free(rgb); return Pyxis::MapTileLoadResult::IO_ERROR; } lv_color_t* pixels = tile_pixels_[request.slot_index]; for (std::size_t index = 0; index < TILE_PIXEL_COUNT; ++index) { const std::size_t source = index * 3U; pixels[index] = lv_color_make(rgb[source], rgb[source + 1U], rgb[source + 2U]); } lv_mem_free(rgb); (void)decoded_tile_cache_.put( request.key, reinterpret_cast(pixels), TILE_PIXEL_COUNT); return Pyxis::MapTileLoadResult::READY; } void MapScreen::serviceIo() { if (!worker_started_ && !startWorker()) return; if (worker_task_) xTaskNotifyGive(worker_task_); } void MapScreen::updateModel(const Pyxis::MapView::Request& request) { if (!lockState(pdMS_TO_TICKS(100))) return; has_location_fix_ = request.has_local_location; current_location_ = request.local_location; if (!center_initialized_ && request.has_local_location && presenter_.recenter(true, request.local_location)) { center_initialized_ = true; } if (!presenter_.frameBuiltForCurrentEpoch()) { requests_released_ = false; } (void)presenter_.buildFrame(request); unlockState(); } void MapScreen::setPlaceholder(std::size_t index) { lv_obj_add_flag(tile_images_[index], LV_OBJ_FLAG_HIDDEN); } void MapScreen::applyFrame() { if (!lockState(pdMS_TO_TICKS(100))) return; const Pyxis::MapView::Frame& frame = presenter_.frame(); for (std::size_t index = 0; index < TILE_COUNT; ++index) { const Pyxis::MapTileSlot& slot = presenter_.slot(index); if (slot.state == Pyxis::MapTileSlot::READY && tile_pixels_[index]) { lv_img_set_src(tile_images_[index], &tile_descriptors_[index]); lv_obj_set_pos(tile_images_[index], static_cast(std::lround(slot.screen_x)), static_cast(std::lround(slot.screen_y))); lv_obj_clear_flag(tile_images_[index], LV_OBJ_FLAG_HIDDEN); } else { setPlaceholder(index); } } for (std::size_t index = 0; index < MARKER_COUNT; ++index) { if (index >= frame.marker_count) { lv_obj_add_flag(approximation_halos_[index], LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(markers_[index], LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(marker_labels_[index], LV_OBJ_FLAG_HIDDEN); continue; } const Pyxis::MapView::Marker& marker = frame.markers[index]; const lv_coord_t x = static_cast(std::lround(marker.screen_x)); const lv_coord_t y = static_cast(std::lround(marker.screen_y)); if (marker.has_approx_radius && marker.approx_radius_pixels > 0.0) { const double bounded_radius = marker.approx_radius_pixels > 320.0 ? 320.0 : (marker.approx_radius_pixels < 2.0 ? 2.0 : marker.approx_radius_pixels); const lv_coord_t radius = static_cast(std::lround(bounded_radius)); lv_obj_set_size(approximation_halos_[index], radius * 2, radius * 2); lv_obj_set_pos(approximation_halos_[index], x - radius, y - radius); lv_obj_clear_flag(approximation_halos_[index], LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(approximation_halos_[index], LV_OBJ_FLAG_HIDDEN); } lv_obj_set_pos(markers_[index], x - 5, y - 5); lv_obj_set_style_bg_color( markers_[index], marker.kind == Pyxis::MapView::MarkerKind::LOCAL ? Theme::info() : Theme::success(), 0); lv_obj_clear_flag(markers_[index], LV_OBJ_FLAG_HIDDEN); char label[8] = {}; if (marker.kind == Pyxis::MapView::MarkerKind::LOCAL) { std::snprintf(label, sizeof(label), "me"); } else { std::snprintf(label, sizeof(label), "%02x%02x", marker.peer.bytes[Telemetry::PEER_ID_SIZE - 2U], marker.peer.bytes[Telemetry::PEER_ID_SIZE - 1U]); } lv_label_set_text(marker_labels_[index], label); lv_obj_set_pos(marker_labels_[index], x + 6, y - 7); lv_obj_clear_flag(marker_labels_[index], LV_OBJ_FLAG_HIDDEN); } char zoom_text[12] = {}; std::snprintf(zoom_text, sizeof(zoom_text), "z%lu", static_cast(presenter_.zoom())); lv_label_set_text(zoom_label_, zoom_text); if (style_selector_.state() == Pyxis::MapStyleSelector::State::APPLYING) { lv_label_set_text(style_label_, "Switching"); } else { lv_label_set_text(style_label_, style_selector_.activeLabel()); } if (style_selector_.canCycle()) { lv_obj_clear_state(style_button_, LV_STATE_DISABLED); } else { lv_obj_add_state(style_button_, LV_STATE_DISABLED); } lv_label_set_text(attribution_label_, pack_attribution_[0] != '\0' ? pack_attribution_ : "No active map pack"); refreshVisibleStatus(); if (style_selector_.state() == Pyxis::MapStyleSelector::State::APPLYING) { lv_label_set_text(status_label_, "Switching style..."); } else if (style_selector_.state() == Pyxis::MapStyleSelector::State::ERROR) { lv_label_set_text(status_label_, "Style switch failed"); } // Non-ready images are now detached under LVGL_LOCK, so the worker may // safely decode into their permanent buffers without a draw race. requests_released_ = true; unlockState(); if (worker_task_) xTaskNotifyGive(worker_task_); } void MapScreen::setStatusFor(Pyxis::MapTileLoadResult result) { switch (result) { case Pyxis::MapTileLoadResult::READY: lv_label_set_text(status_label_, "Tile ready"); break; case Pyxis::MapTileLoadResult::MISS: lv_label_set_text(status_label_, "Tile unavailable"); break; case Pyxis::MapTileLoadResult::STORAGE_UNAVAILABLE: lv_label_set_text(status_label_, "SD unavailable"); break; case Pyxis::MapTileLoadResult::INVALID_PNG: lv_label_set_text(status_label_, "Bad tile"); break; case Pyxis::MapTileLoadResult::TOO_LARGE: lv_label_set_text(status_label_, "Tile too large"); break; case Pyxis::MapTileLoadResult::DOWNLOAD_FAILED: lv_label_set_text(status_label_, "Tile unavailable"); break; case Pyxis::MapTileLoadResult::IO_ERROR: lv_label_set_text(status_label_, "Tile I/O error"); break; } } void MapScreen::refreshVisibleStatus() { Pyxis::MapTileLoadResult visible_status{}; if (presenter_.visibleTileStatus(visible_status)) { setStatusFor(visible_status); } else { lv_label_set_text(status_label_, "Loading tiles..."); } } bool MapScreen::applyOneCompletion() { if (!lockState(pdMS_TO_TICKS(100))) return false; Pyxis::MapTileCompletion completion{}; const bool applied = presenter_.takeApplicableCompletion(completion); if (applied) { refreshVisibleStatus(); if (completion.result == Pyxis::MapTileLoadResult::READY && completion.slot_index < TILE_COUNT) { const std::size_t index = completion.slot_index; lv_img_set_src(tile_images_[index], &tile_descriptors_[index]); const Pyxis::MapTileSlot& slot = presenter_.slot(index); lv_obj_set_pos(tile_images_[index], static_cast(std::lround(slot.screen_x)), static_cast(std::lround(slot.screen_y))); lv_obj_clear_flag(tile_images_[index], LV_OBJ_FLAG_HIDDEN); } } unlockState(); return applied; } void MapScreen::show() { pack_refresh_epoch_.fetch_add(1U, std::memory_order_acq_rel); screen_visible_.store(true, std::memory_order_release); if (worker_task_) xTaskNotifyGive(worker_task_); if (lockState(pdMS_TO_TICKS(100))) { presenter_.show(); unlockState(); } lv_obj_clear_flag(screen_, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(screen_); lv_group_t* group = LVGL::LVGLInit::get_default_group(); if (group) { lv_group_add_obj(group, back_button_); lv_group_add_obj(group, zoom_out_button_); lv_group_add_obj(group, zoom_in_button_); lv_group_add_obj(group, recenter_button_); lv_group_add_obj(group, style_button_); for (std::size_t i = 0; i < 4U; ++i) lv_group_add_obj(group, pan_buttons_[i]); lv_group_focus_obj(back_button_); } } void MapScreen::hide() { if (lockState(portMAX_DELAY)) { screen_visible_.store(false, std::memory_order_release); if (style_lifecycle_epoch_ == UINT32_MAX) { style_lifecycle_exhausted_ = true; } else { ++style_lifecycle_epoch_; } if (style_request_pending_) { (void)style_selector_.cancelPending(style_request_.token); style_request_pending_ = false; style_request_ = Pyxis::MapStyleRequest(); } (void)style_selector_.clearError(); presenter_.hide(); unlockState(); } if (worker_task_) xTaskNotifyGive(worker_task_); lv_group_t* group = LVGL::LVGLInit::get_default_group(); if (group) { lv_group_remove_obj(back_button_); lv_group_remove_obj(zoom_out_button_); lv_group_remove_obj(zoom_in_button_); lv_group_remove_obj(recenter_button_); lv_group_remove_obj(style_button_); for (std::size_t i = 0; i < 4U; ++i) lv_group_remove_obj(pan_buttons_[i]); } lv_obj_add_flag(screen_, LV_OBJ_FLAG_HIDDEN); } MapScreen* MapScreen::fromEvent(lv_event_t* event) { return static_cast(lv_event_get_user_data(event)); } void MapScreen::pan(double dx, double dy) { if (!lockState(pdMS_TO_TICKS(100))) return; center_initialized_ = true; (void)presenter_.panPixels(dx, dy); unlockState(); } void MapScreen::onBack(lv_event_t* event) { MapScreen* screen = fromEvent(event); if (screen && screen->back_callback_) screen->back_callback_(); } void MapScreen::onZoomIn(lv_event_t* event) { MapScreen* screen = fromEvent(event); if (screen && screen->lockState(pdMS_TO_TICKS(100))) { screen->center_initialized_ = true; (void)screen->presenter_.zoomBy(1); screen->unlockState(); } } void MapScreen::onZoomOut(lv_event_t* event) { MapScreen* screen = fromEvent(event); if (screen && screen->lockState(pdMS_TO_TICKS(100))) { screen->center_initialized_ = true; (void)screen->presenter_.zoomBy(-1); screen->unlockState(); } } void MapScreen::onRecenter(lv_event_t* event) { MapScreen* screen = fromEvent(event); if (!screen || !screen->lockState(pdMS_TO_TICKS(100))) return; const bool centered = screen->presenter_.recenter( screen->has_location_fix_, screen->current_location_); if (centered) screen->center_initialized_ = true; screen->unlockState(); if (!centered) lv_label_set_text(screen->status_label_, "No GPS fix"); } void MapScreen::onStyle(lv_event_t* event) { MapScreen* screen = fromEvent(event); bool queued = false; if (screen && screen->lockState(pdMS_TO_TICKS(100))) { if (!screen->style_lifecycle_exhausted_ && screen->style_selector_.requestNext(screen->style_request_)) { screen->style_request_lifecycle_epoch_ = screen->style_lifecycle_epoch_; screen->style_request_pending_ = true; queued = true; } screen->unlockState(); } if (queued && screen->worker_task_) xTaskNotifyGive(screen->worker_task_); } void MapScreen::onPan(lv_event_t* event) { MapScreen* screen = fromEvent(event); lv_obj_t* target = lv_event_get_current_target(event); if (!screen || !target) return; const std::size_t direction = reinterpret_cast( lv_obj_get_user_data(target)); switch (direction) { case 0U: screen->pan(0.0, -64.0); break; case 1U: screen->pan(0.0, 64.0); break; case 2U: screen->pan(-64.0, 0.0); break; case 3U: screen->pan(64.0, 0.0); break; default: break; } } void MapScreen::onMapPressed(lv_event_t* event) { MapScreen* screen = fromEvent(event); lv_indev_t* input = lv_indev_get_act(); if (!screen || !input) return; screen->dragging_ = true; lv_indev_get_point(input, &screen->last_drag_point_); } void MapScreen::onMapPressing(lv_event_t* event) { MapScreen* screen = fromEvent(event); lv_indev_t* input = lv_indev_get_act(); if (!screen || !input || !screen->dragging_) return; lv_point_t point{}; lv_indev_get_point(input, &point); const double dx = static_cast(screen->last_drag_point_.x - point.x); const double dy = static_cast(screen->last_drag_point_.y - point.y); screen->last_drag_point_ = point; if (dx != 0.0 || dy != 0.0) screen->pan(dx, dy); } void MapScreen::onMapReleased(lv_event_t* event) { MapScreen* screen = fromEvent(event); if (screen) screen->dragging_ = false; } } // namespace LXMF } // namespace UI #endif // ARDUINO