diff --git a/lib/lv_conf.h b/lib/lv_conf.h index 01084457..c7293bc9 100644 --- a/lib/lv_conf.h +++ b/lib/lv_conf.h @@ -156,6 +156,7 @@ 3RD PARTY LIBRARIES *====================*/ #define LV_USE_QRCODE 1 +#define LV_USE_PNG 1 /* Worker-only lodepng decode; never file-source rendering. */ /* lv_snapshot_take() — used by the T:SCREENSHOT serial command for * docs and automated UI testing. Pulls a full-screen RGB565 buffer diff --git a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp index b9db0047..20ef5b4e 100644 --- a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp @@ -684,6 +684,7 @@ void ConversationListScreen::on_bottom_nav_clicked(lv_event_t* event) { screen->_compose_callback(); } break; + default: break; } diff --git a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h index efc2a976..a3df74ea 100644 --- a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h +++ b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h @@ -70,6 +70,7 @@ public: */ using ConversationSelectedCallback = std::function; using ComposeCallback = std::function; + using MapCallback = std::function; using SyncCallback = std::function; using HomeCallback = std::function; using PeersCallback = std::function; @@ -119,6 +120,7 @@ public: * @param callback Function to call when compose is requested */ void set_compose_callback(ComposeCallback callback); + void set_map_callback(MapCallback callback) { _map_callback = callback; } /** * Set callback for sync button @@ -224,6 +226,7 @@ private: ConversationSelectedCallback _conversation_selected_callback; ComposeCallback _compose_callback; + MapCallback _map_callback; SyncCallback _sync_callback; HomeCallback _home_callback; PeersCallback _peers_callback; diff --git a/lib/tdeck_ui/UI/LXMF/MapScreen.cpp b/lib/tdeck_ui/UI/LXMF/MapScreen.cpp new file mode 100644 index 00000000..d8863710 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/MapScreen.cpp @@ -0,0 +1,607 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#include "MapScreen.h" + +#ifdef ARDUINO + +#include "Theme.h" +#include "../LVGL/LVGLInit.h" +#include "../LVGL/LVGLLock.h" + +#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; +constexpr std::uint32_t STORE_BYTE_QUOTA = 64U * 1024U * 1024U; +constexpr std::uint16_t STORE_ENTRY_CAPACITY = 128U; + +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* 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); + return button; +} + +} // namespace + +using Hardware::TDeck::MapTileStore; + +static_assert(MapTileStore::HARD_MAX_ENTRIES == 128, + "map cache index contract changed; revisit bounded RAM budget"); +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), pan_buttons_{}, tile_images_{}, + tile_descriptors_{}, tile_pixels_{}, approximation_halos_{}, markers_{}, marker_labels_{}, + presenter_(), storage_(), + store_config_{STORE_ENTRY_CAPACITY, STORE_BYTE_QUOTA, + MAX_COMPRESSED_TILE_BYTES}, + store_(storage_, store_config_), compressed_staging_(nullptr), + state_mutex_(nullptr), worker_task_(nullptr), stop_requested_(false), + worker_exited_(true), worker_started_(false), store_initialized_(false), + requests_released_(false), + has_location_fix_(false), current_location_{}, dragging_(false), + last_drag_point_{0, 0}, back_callback_() { + LVGL_LOCK(); + state_mutex_ = xSemaphoreCreateMutex(); + + 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); + + 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); + } + + 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_, "© OpenStreetMap contributors"); + 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; + } + 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_) return false; + compressed_staging_ = static_cast(heap_caps_malloc( + MAX_COMPRESSED_TILE_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (!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; + heap_caps_free(compressed_staging_); + compressed_staging_ = 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_); + while (!worker_exited_.load(std::memory_order_acquire)) { + vTaskDelay(pdMS_TO_TICKS(1)); + } + worker_task_ = nullptr; + worker_started_ = false; +} + +void MapScreen::workerEntry(void* context) { + static_cast(context)->workerLoop(); +} + +void MapScreen::workerLoop() { + Hardware::TDeck::TileStoreResult initialized = store_.initialize(); + store_initialized_ = initialized == Hardware::TDeck::TileStoreResult::OK; + while (!stop_requested_.load(std::memory_order_acquire)) { + Pyxis::MapTileRequest request{}; + bool have_request = false; + if (lockState(pdMS_TO_TICKS(20))) { + if (requests_released_) { + 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) { + if (!store_initialized_) { + return Pyxis::MapTileLoadResult::STORAGE_UNAVAILABLE; + } + std::uint32_t size = 0U; + Hardware::TDeck::TileStoreResult result = + store_.beginGet(request.key, size); + if (result == Hardware::TDeck::TileStoreResult::MISS) { + return Pyxis::MapTileLoadResult::MISS; + } + if (result == Hardware::TDeck::TileStoreResult::STORAGE_UNAVAILABLE || + result == Hardware::TDeck::TileStoreResult::NOT_INITIALIZED) { + return Pyxis::MapTileLoadResult::STORAGE_UNAVAILABLE; + } + if (result != Hardware::TDeck::TileStoreResult::OK) { + return Pyxis::MapTileLoadResult::IO_ERROR; + } + if (size > MAX_COMPRESSED_TILE_BYTES) { + store_.endGet(); + return Pyxis::MapTileLoadResult::TOO_LARGE; + } + std::size_t total = 0U; + while (total < size && + !stop_requested_.load(std::memory_order_acquire)) { + const std::size_t capacity = + (size - total) < READ_CHUNK_BYTES ? (size - total) : READ_CHUNK_BYTES; + std::size_t count = 0U; + result = store_.readGetChunk(compressed_staging_ + total, + capacity, count); + if (result != Hardware::TDeck::TileStoreResult::OK || count == 0U) { + store_.endGet(); + return result == Hardware::TDeck::TileStoreResult::STORAGE_UNAVAILABLE + ? Pyxis::MapTileLoadResult::STORAGE_UNAVAILABLE + : Pyxis::MapTileLoadResult::IO_ERROR; + } + total += count; + } + store_.endGet(); + if (total != size || stop_requested_.load(std::memory_order_acquire)) { + return Pyxis::MapTileLoadResult::IO_ERROR; + } + + unsigned char* rgb = nullptr; + unsigned width = 0U; + unsigned height = 0U; + const unsigned decode_error = lodepng_decode24( + &rgb, &width, &height, compressed_staging_, total); + 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); + 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; + 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); + // 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_, "Offline"); + 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::IO_ERROR: + lv_label_set_text(status_label_, "Tile I/O error"); + break; + } +} + +bool MapScreen::applyOneCompletion() { + if (!lockState(pdMS_TO_TICKS(100))) return false; + Pyxis::MapTileCompletion completion{}; + const bool applied = presenter_.takeApplicableCompletion(completion); + if (applied) { + setStatusFor(completion.result); + 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() { + 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_); + 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(pdMS_TO_TICKS(100))) { + presenter_.hide(); + unlockState(); + } + 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_); + 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; + (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))) { + (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))) { + (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_); + screen->unlockState(); + if (!centered) lv_label_set_text(screen->status_label_, "No GPS fix"); +} + +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 diff --git a/lib/tdeck_ui/UI/LXMF/MapScreen.h b/lib/tdeck_ui/UI/LXMF/MapScreen.h new file mode 100644 index 00000000..f748d105 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/MapScreen.h @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_MAP_SCREEN_H +#define UI_LXMF_MAP_SCREEN_H + +#include "MapScreenPresenter.h" + +#ifdef ARDUINO + +#include +#include +#include +#include +#include +#include +#include + +#include "Hardware/TDeck/MapTileStore.h" +#include "Hardware/TDeck/MapTileStoreSD.h" + +namespace UI { +namespace LXMF { + +class MapScreen { +public: + using BackCallback = std::function; + + enum : std::size_t { + TILE_COUNT = Pyxis::MapScreenPresenter::TILE_SLOT_COUNT, + MARKER_COUNT = 33, + MAX_COMPLETIONS_PER_TICK = 1, + READ_CHUNK_BYTES = 4096 + }; + enum : std::uint32_t { + MAX_COMPRESSED_TILE_BYTES = 384U * 1024U + }; + + explicit MapScreen(lv_obj_t* parent = nullptr); + ~MapScreen(); + + void set_back_callback(BackCallback callback) { back_callback_ = callback; } + void show(); + void hide(); + + // These methods never call LVGL and are invoked before LVGL_LOCK. + void serviceIo(); + void updateModel(const Pyxis::MapView::Request& request); + + // These methods only mutate the pre-created object pool and are invoked + // while UIManager owns LVGL_LOCK. + void applyFrame(); + bool applyOneCompletion(); + +private: + lv_obj_t* screen_; + lv_obj_t* toolbar_; + lv_obj_t* viewport_; + lv_obj_t* status_label_; + lv_obj_t* attribution_label_; + lv_obj_t* zoom_label_; + lv_obj_t* back_button_; + lv_obj_t* zoom_out_button_; + lv_obj_t* zoom_in_button_; + lv_obj_t* recenter_button_; + lv_obj_t* pan_buttons_[4]; + lv_obj_t* tile_images_[TILE_COUNT]; + lv_img_dsc_t tile_descriptors_[TILE_COUNT]; + lv_color_t* tile_pixels_[TILE_COUNT]; + lv_obj_t* approximation_halos_[MARKER_COUNT]; + lv_obj_t* markers_[MARKER_COUNT]; + lv_obj_t* marker_labels_[MARKER_COUNT]; + + Pyxis::MapScreenPresenter presenter_; + Hardware::TDeck::MapTileStoreSD storage_; + Hardware::TDeck::TileStoreConfig store_config_; + Hardware::TDeck::MapTileStore store_; + std::uint8_t* compressed_staging_; + SemaphoreHandle_t state_mutex_; + TaskHandle_t worker_task_; + std::atomic stop_requested_; + std::atomic worker_exited_; + bool worker_started_; + bool store_initialized_; + bool requests_released_; + bool has_location_fix_; + Telemetry::LocationTelemetry current_location_; + bool dragging_; + lv_point_t last_drag_point_; + BackCallback back_callback_; + + static void workerEntry(void* context); + void workerLoop(); + Pyxis::MapTileLoadResult loadTile(const Pyxis::MapTileRequest& request); + bool startWorker(); + void stopWorker(); + bool lockState(TickType_t ticks = portMAX_DELAY); + void unlockState(); + void setPlaceholder(std::size_t index); + void setStatusFor(Pyxis::MapTileLoadResult result); + void pan(double dx, double dy); + + static MapScreen* fromEvent(lv_event_t* event); + static void onBack(lv_event_t* event); + static void onZoomIn(lv_event_t* event); + static void onZoomOut(lv_event_t* event); + static void onRecenter(lv_event_t* event); + static void onPan(lv_event_t* event); + static void onMapPressed(lv_event_t* event); + static void onMapPressing(lv_event_t* event); + static void onMapReleased(lv_event_t* event); +}; + +static_assert(MapScreen::TILE_COUNT == 6, + "MapScreen owns exactly six permanent tile image objects"); +static_assert(MapScreen::MARKER_COUNT == + static_cast(Pyxis::MapView::MAX_MAP_MARKERS), + "MapScreen marker pool must cover local plus all peer snapshots"); +static_assert(MapScreen::MARKER_COUNT <= 33, + "MapScreen marker object pools must remain bounded"); +static_assert(MapScreen::MAX_COMPRESSED_TILE_BYTES == 384U * 1024U, + "compressed tile staging cap is part of the memory contract"); + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO +#endif diff --git a/lib/tdeck_ui/UI/LXMF/MapScreenPresenter.cpp b/lib/tdeck_ui/UI/LXMF/MapScreenPresenter.cpp new file mode 100644 index 00000000..4e3a8512 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/MapScreenPresenter.cpp @@ -0,0 +1,336 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#include "MapScreenPresenter.h" + +#include +#include + +namespace Pyxis { +namespace { + +bool validTelemetry(const Telemetry::LocationTelemetry& location) { + return location.latitude_e6 >= -90000000 && + location.latitude_e6 <= 90000000 && + location.longitude_e6 >= -180000000 && + location.longitude_e6 <= 180000000; +} + +} // namespace + +MapScreenPresenter::MapScreenPresenter() + : center_{0.0, 0.0}, zoom_(2U), generation_(1U), frame_epoch_(1U), + active_(false), frame_built_for_epoch_(false), frame_{}, slots_{}, + requests_{}, completions_{}, request_head_(0U), request_count_(0U), + completion_head_(0U), completion_count_(0U) { + clearSlots(); +} + +std::uint32_t MapScreenPresenter::advance(std::uint32_t value) { + ++value; + return value == 0U ? 1U : value; +} + +bool MapScreenPresenter::sameKey(const Hardware::TDeck::TileKey& left, + const Hardware::TDeck::TileKey& right) { + return left.zoom == right.zoom && left.x == right.x && left.y == right.y; +} + +Hardware::TDeck::TileKey MapScreenPresenter::keyFor( + const MapProjection::TilePlacement& placement) { + Hardware::TDeck::TileKey key{}; + key.zoom = static_cast(placement.tile.zoom); + key.x = placement.tile.x; + key.y = placement.tile.y; + return key; +} + +void MapScreenPresenter::clearSlots() { + for (std::size_t index = 0; index < TILE_SLOT_COUNT; ++index) { + slots_[index].state = MapTileSlot::EMPTY; + slots_[index].key = Hardware::TDeck::TileKey{0U, 0U, 0U}; + slots_[index].token = advance(slots_[index].token); + slots_[index].screen_x = 0.0; + slots_[index].screen_y = 0.0; + } +} + +void MapScreenPresenter::clearQueues() { + request_head_ = 0U; + request_count_ = 0U; + completion_head_ = 0U; + completion_count_ = 0U; +} + +void MapScreenPresenter::show() { + generation_ = advance(generation_); + frame_epoch_ = advance(frame_epoch_); + active_ = true; + frame_built_for_epoch_ = false; + clearQueues(); + clearSlots(); +} + +void MapScreenPresenter::hide() { + generation_ = advance(generation_); + frame_epoch_ = advance(frame_epoch_); + active_ = false; + frame_built_for_epoch_ = false; + clearQueues(); + clearSlots(); + frame_.tile_count = 0U; + frame_.marker_count = 0U; +} + +void MapScreenPresenter::startNewFrame() { + frame_epoch_ = advance(frame_epoch_); + frame_built_for_epoch_ = false; + request_head_ = 0U; + request_count_ = 0U; + completion_head_ = 0U; + completion_count_ = 0U; +} + +bool MapScreenPresenter::setView(const MapProjection::GeoPoint& center, + std::uint32_t zoom) { + if (!std::isfinite(center.latitude) || !std::isfinite(center.longitude) || + !MapProjection::isValidZoom(zoom)) { + return false; + } + MapProjection::GeoPoint normalized{}; + normalized.latitude = MapProjection::clampLatitude(center.latitude); + normalized.longitude = MapProjection::normalizeLongitude(center.longitude); + if (center_.latitude == normalized.latitude && + center_.longitude == normalized.longitude && zoom_ == zoom) { + return false; + } + center_ = normalized; + zoom_ = zoom; + startNewFrame(); + return true; +} + +bool MapScreenPresenter::panPixels(double delta_x, double delta_y) { + if (!std::isfinite(delta_x) || !std::isfinite(delta_y)) return false; + if (delta_x == 0.0 && delta_y == 0.0) return false; + MapProjection::GlobalPixel pixel{}; + MapProjection::GlobalPixel panned{}; + MapProjection::GeoPoint center{}; + if (MapProjection::latLonToGlobalPixel(center_, zoom_, pixel) != + MapProjection::Status::OK || + MapProjection::panGlobalPixel(pixel, delta_x, delta_y, zoom_, panned) != + MapProjection::Status::OK || + MapProjection::globalPixelToLatLon(panned, zoom_, center) != + MapProjection::Status::OK) { + return false; + } + center_ = center; + startNewFrame(); + return true; +} + +bool MapScreenPresenter::zoomBy(int delta) { + const int proposed = static_cast(zoom_) + delta; + const std::uint32_t bounded = proposed < 0 + ? 0U + : (proposed > static_cast(MapProjection::MAX_ZOOM) + ? static_cast(MapProjection::MAX_ZOOM) + : static_cast(proposed)); + if (bounded == zoom_) return false; + zoom_ = bounded; + startNewFrame(); + return true; +} + +bool MapScreenPresenter::recenter( + bool has_fix, const Telemetry::LocationTelemetry& location) { + if (!has_fix || !validTelemetry(location)) return false; + MapProjection::GeoPoint center{}; + center.latitude = static_cast(location.latitude_e6) / 1000000.0; + center.longitude = static_cast(location.longitude_e6) / 1000000.0; + if (center_.latitude == center.latitude && center_.longitude == center.longitude) { + return true; + } + center_ = center; + startNewFrame(); + return true; +} + +int MapScreenPresenter::findSlot(const Hardware::TDeck::TileKey& key) const { + for (std::size_t index = 0; index < TILE_SLOT_COUNT; ++index) { + if (slots_[index].state != MapTileSlot::EMPTY && + sameKey(slots_[index].key, key)) { + return static_cast(index); + } + } + return -1; +} + +int MapScreenPresenter::findFreeSlot() const { + for (std::size_t index = 0; index < TILE_SLOT_COUNT; ++index) { + if (slots_[index].state == MapTileSlot::EMPTY) { + return static_cast(index); + } + } + return -1; +} + +bool MapScreenPresenter::enqueueRequest(std::size_t slot_index) { + if (request_count_ >= TILE_REQUEST_CAPACITY) return false; + const std::size_t tail = + (request_head_ + request_count_) % TILE_REQUEST_CAPACITY; + MapTileRequest& request = requests_[tail]; + request.generation = generation_; + request.frame_epoch = frame_epoch_; + request.slot_token = slots_[slot_index].token; + request.slot_index = static_cast(slot_index); + request.key = slots_[slot_index].key; + ++request_count_; + return true; +} + +MapView::Result MapScreenPresenter::buildFrame(const MapView::Request& request) { + if (!active_) return MapView::Result::INVALID_ARGUMENT; + MapView::Request effective = request; + effective.center = center_; + effective.zoom = zoom_; + effective.width = VIEWPORT_WIDTH; + effective.height = VIEWPORT_HEIGHT; + effective.include_tile_border = false; + + MapView::Frame candidate{}; + const MapView::Result result = MapView::buildFrame(effective, candidate); + if (result != MapView::Result::OK) return result; + if (candidate.tile_count > TILE_SLOT_COUNT) { + return MapView::Result::VIEWPORT_TOO_LARGE; + } + + if (frame_built_for_epoch_) { + frame_ = candidate; + for (std::size_t tile = 0; tile < candidate.tile_count; ++tile) { + const int slot_index = findSlot(keyFor(candidate.tiles[tile])); + if (slot_index >= 0) { + slots_[static_cast(slot_index)].screen_x = + candidate.tiles[tile].screen_x; + slots_[static_cast(slot_index)].screen_y = + candidate.tiles[tile].screen_y; + } + } + return MapView::Result::OK; + } + + bool keep[TILE_SLOT_COUNT] = {}; + for (std::size_t tile = 0; tile < candidate.tile_count; ++tile) { + const Hardware::TDeck::TileKey key = keyFor(candidate.tiles[tile]); + const int slot_index = findSlot(key); + if (slot_index >= 0 && slots_[static_cast(slot_index)].state == + MapTileSlot::READY) { + const std::size_t slot = static_cast(slot_index); + keep[slot] = true; + slots_[slot].screen_x = candidate.tiles[tile].screen_x; + slots_[slot].screen_y = candidate.tiles[tile].screen_y; + } + } + for (std::size_t index = 0; index < TILE_SLOT_COUNT; ++index) { + if (!keep[index]) { + slots_[index].state = MapTileSlot::EMPTY; + slots_[index].token = advance(slots_[index].token); + } + } + + request_head_ = 0U; + request_count_ = 0U; + for (std::size_t tile = 0; tile < candidate.tile_count; ++tile) { + const Hardware::TDeck::TileKey key = keyFor(candidate.tiles[tile]); + int slot_index = findSlot(key); + if (slot_index < 0) slot_index = findFreeSlot(); + if (slot_index < 0) return MapView::Result::CAPACITY_EXCEEDED; + MapTileSlot& slot = slots_[static_cast(slot_index)]; + slot.key = key; + slot.screen_x = candidate.tiles[tile].screen_x; + slot.screen_y = candidate.tiles[tile].screen_y; + if (slot.state != MapTileSlot::READY) { + slot.state = MapTileSlot::PENDING; + if (!enqueueRequest(static_cast(slot_index))) { + return MapView::Result::CAPACITY_EXCEEDED; + } + } + } + frame_ = candidate; + frame_built_for_epoch_ = true; + return MapView::Result::OK; +} + +bool MapScreenPresenter::takeRequest(MapTileRequest& output) { + if (request_count_ == 0U) return false; + output = requests_[request_head_]; + request_head_ = (request_head_ + 1U) % TILE_REQUEST_CAPACITY; + --request_count_; + return true; +} + +bool MapScreenPresenter::completionMatches( + const MapTileCompletion& completion) const { + if (!active_ || completion.generation != generation_ || + completion.frame_epoch != frame_epoch_ || + completion.slot_index >= TILE_SLOT_COUNT) { + return false; + } + const MapTileSlot& slot = slots_[completion.slot_index]; + return slot.state == MapTileSlot::PENDING && + slot.token == completion.slot_token && + sameKey(slot.key, completion.key); +} + +bool MapScreenPresenter::publishCompletion( + const MapTileCompletion& completion) { + if (!completionMatches(completion) || + completion_count_ >= TILE_COMPLETION_CAPACITY) { + return false; + } + for (std::size_t offset = 0; offset < completion_count_; ++offset) { + const MapTileCompletion& queued = completions_[ + (completion_head_ + offset) % TILE_COMPLETION_CAPACITY]; + if (queued.generation == completion.generation && + queued.frame_epoch == completion.frame_epoch && + queued.slot_token == completion.slot_token && + queued.slot_index == completion.slot_index && + sameKey(queued.key, completion.key)) { + return false; + } + } + const std::size_t tail = + (completion_head_ + completion_count_) % TILE_COMPLETION_CAPACITY; + completions_[tail] = completion; + ++completion_count_; + return true; +} + +MapTileSlot::State MapScreenPresenter::stateFor(MapTileLoadResult result) { + switch (result) { + case MapTileLoadResult::READY: return MapTileSlot::READY; + case MapTileLoadResult::MISS: return MapTileSlot::MISS; + case MapTileLoadResult::STORAGE_UNAVAILABLE: + return MapTileSlot::STORAGE_UNAVAILABLE; + case MapTileLoadResult::INVALID_PNG: return MapTileSlot::INVALID_PNG; + case MapTileLoadResult::TOO_LARGE: return MapTileSlot::TOO_LARGE; + case MapTileLoadResult::IO_ERROR: return MapTileSlot::IO_ERROR; + } + return MapTileSlot::IO_ERROR; +} + +bool MapScreenPresenter::takeApplicableCompletion(MapTileCompletion& output) { + while (completion_count_ != 0U) { + const MapTileCompletion candidate = completions_[completion_head_]; + completion_head_ = + (completion_head_ + 1U) % TILE_COMPLETION_CAPACITY; + --completion_count_; + if (!completionMatches(candidate)) continue; + slots_[candidate.slot_index].state = stateFor(candidate.result); + output = candidate; + return true; + } + return false; +} + +} // namespace Pyxis diff --git a/lib/tdeck_ui/UI/LXMF/MapScreenPresenter.h b/lib/tdeck_ui/UI/LXMF/MapScreenPresenter.h new file mode 100644 index 00000000..6b89af92 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/MapScreenPresenter.h @@ -0,0 +1,145 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#ifndef PYXIS_UI_LXMF_MAP_SCREEN_PRESENTER_H +#define PYXIS_UI_LXMF_MAP_SCREEN_PRESENTER_H + +#include +#include + +#include "MapViewModel.h" +#include "Hardware/TDeck/MapTileStore.h" + +namespace Pyxis { + +enum class MapTileLoadResult : std::uint8_t { + READY, + MISS, + STORAGE_UNAVAILABLE, + INVALID_PNG, + TOO_LARGE, + IO_ERROR +}; + +struct MapTileRequest { + std::uint32_t generation; + std::uint32_t frame_epoch; + std::uint32_t slot_token; + std::uint8_t slot_index; + Hardware::TDeck::TileKey key; +}; + +struct MapTileCompletion { + std::uint32_t generation; + std::uint32_t frame_epoch; + std::uint32_t slot_token; + std::uint8_t slot_index; + Hardware::TDeck::TileKey key; + MapTileLoadResult result; +}; + +struct MapTileSlot { + enum State : std::uint8_t { + EMPTY, + PENDING, + READY, + MISS, + STORAGE_UNAVAILABLE, + INVALID_PNG, + TOO_LARGE, + IO_ERROR + }; + + State state; + Hardware::TDeck::TileKey key; + std::uint32_t token; + double screen_x; + double screen_y; +}; + +/** + * Portable, allocation-free state owner for the 320x208 offline map. + * + * Calls crossing worker/UI task boundaries must be externally serialized. The + * presenter itself contains only fixed arrays, making that lock scope short and + * independent of LVGL, SD, and decoder work. + */ +class MapScreenPresenter { +public: + enum : std::size_t { + TILE_SLOT_COUNT = 6, + TILE_REQUEST_CAPACITY = 6, + TILE_COMPLETION_CAPACITY = 6 + }; + enum : std::uint32_t { + VIEWPORT_WIDTH = 320, + VIEWPORT_HEIGHT = 208 + }; + + MapScreenPresenter(); + + void show(); + void hide(); + bool active() const { return active_; } + + bool setView(const MapProjection::GeoPoint& center, std::uint32_t zoom); + bool panPixels(double delta_x, double delta_y); + bool zoomBy(int delta); + bool recenter(bool has_fix, + const Telemetry::LocationTelemetry& location); + + MapView::Result buildFrame(const MapView::Request& request); + bool takeRequest(MapTileRequest& output); + bool publishCompletion(const MapTileCompletion& completion); + bool takeApplicableCompletion(MapTileCompletion& output); + + const MapView::Frame& frame() const { return frame_; } + const MapTileSlot& slot(std::size_t index) const { return slots_[index]; } + const MapProjection::GeoPoint& center() const { return center_; } + std::uint32_t zoom() const { return zoom_; } + std::uint32_t generation() const { return generation_; } + std::uint32_t frameEpoch() const { return frame_epoch_; } + std::size_t requestCount() const { return request_count_; } + std::size_t completionCount() const { return completion_count_; } + +private: + MapProjection::GeoPoint center_; + std::uint32_t zoom_; + std::uint32_t generation_; + std::uint32_t frame_epoch_; + bool active_; + bool frame_built_for_epoch_; + MapView::Frame frame_; + MapTileSlot slots_[TILE_SLOT_COUNT]; + MapTileRequest requests_[TILE_REQUEST_CAPACITY]; + MapTileCompletion completions_[TILE_COMPLETION_CAPACITY]; + std::size_t request_head_; + std::size_t request_count_; + std::size_t completion_head_; + std::size_t completion_count_; + + static bool sameKey(const Hardware::TDeck::TileKey& left, + const Hardware::TDeck::TileKey& right); + static Hardware::TDeck::TileKey keyFor( + const MapProjection::TilePlacement& placement); + static std::uint32_t advance(std::uint32_t value); + void startNewFrame(); + void clearSlots(); + void clearQueues(); + int findSlot(const Hardware::TDeck::TileKey& key) const; + int findFreeSlot() const; + bool enqueueRequest(std::size_t slot_index); + bool completionMatches(const MapTileCompletion& completion) const; + static MapTileSlot::State stateFor(MapTileLoadResult result); +}; + +static_assert(MapScreenPresenter::TILE_SLOT_COUNT == 6, + "320x208 map viewport requires exactly six reusable tile slots"); +static_assert(MapScreenPresenter::TILE_REQUEST_CAPACITY == 6, + "tile request queue must remain bounded to one per slot"); +static_assert(MapScreenPresenter::TILE_COMPLETION_CAPACITY == 6, + "tile completion queue must remain bounded to one per slot"); + +} // namespace Pyxis + +#endif diff --git a/lib/tdeck_ui/UI/LXMF/MapViewModel.cpp b/lib/tdeck_ui/UI/LXMF/MapViewModel.cpp index 8719d609..5e2f1778 100644 --- a/lib/tdeck_ui/UI/LXMF/MapViewModel.cpp +++ b/lib/tdeck_ui/UI/LXMF/MapViewModel.cpp @@ -6,6 +6,9 @@ namespace Pyxis { namespace MapView { namespace { +const double EARTH_CIRCUMFERENCE_METERS = 40075016.68557849; +const double DEGREES_TO_RADIANS = 0.017453292519943295; + bool validLocation(const Telemetry::LocationTelemetry& location) { return location.latitude_e6 >= -90000000 && location.latitude_e6 <= 90000000 && @@ -49,8 +52,9 @@ void appendMarker(const Telemetry::LocationTelemetry& location, std::uint32_t zoom, Frame& output) { MapProjection::MarkerProjection projected{}; + const MapProjection::GeoPoint point = pointFromLocation(location); if (MapProjection::projectMarker( - pointFromLocation(location), viewport, zoom, projected) != + point, viewport, zoom, projected) != MapProjection::Status::OK || !projected.visible) { return; @@ -62,6 +66,19 @@ void appendMarker(const Telemetry::LocationTelemetry& location, marker.screen_y = projected.screen_y; marker.has_approx_radius = has_approx_radius; marker.approx_radius_meters = approx_radius_meters; + marker.approx_radius_pixels = 0.0; + if (has_approx_radius && approx_radius_meters != 0U) { + const double world_pixels = static_cast(MapProjection::TILE_SIZE) * + static_cast(MapProjection::tileCount(zoom)); + const double meters_per_pixel = + std::cos(MapProjection::clampLatitude(point.latitude) * + DEGREES_TO_RADIANS) * + EARTH_CIRCUMFERENCE_METERS / world_pixels; + if (meters_per_pixel > 0.0 && std::isfinite(meters_per_pixel)) { + marker.approx_radius_pixels = + static_cast(approx_radius_meters) / meters_per_pixel; + } + } } } // namespace diff --git a/lib/tdeck_ui/UI/LXMF/MapViewModel.h b/lib/tdeck_ui/UI/LXMF/MapViewModel.h index 9caebacb..872aa094 100644 --- a/lib/tdeck_ui/UI/LXMF/MapViewModel.h +++ b/lib/tdeck_ui/UI/LXMF/MapViewModel.h @@ -34,6 +34,7 @@ struct Marker { double screen_y = 0.0; bool has_approx_radius = false; std::uint32_t approx_radius_meters = 0; + double approx_radius_pixels = 0.0; }; struct Request { diff --git a/lib/tdeck_ui/UI/LXMF/NavigationStack.h b/lib/tdeck_ui/UI/LXMF/NavigationStack.h index f83378d3..cda02d49 100644 --- a/lib/tdeck_ui/UI/LXMF/NavigationStack.h +++ b/lib/tdeck_ui/UI/LXMF/NavigationStack.h @@ -8,6 +8,7 @@ namespace UI::LXMF { enum class Route { HOME, MESSAGES, + MAP, CHAT, COMPOSE, NETWORK, diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 526b76de..57bc873c 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -238,6 +238,7 @@ UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router, _settings_screen(nullptr), _propagation_nodes_screen(nullptr), _call_screen(nullptr), + _map_screen(nullptr), _propagation_manager(nullptr), _ble_interface(nullptr), _initialized(false), @@ -272,6 +273,9 @@ UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router, } UIManager::~UIManager() { + // Joins the sole SD/decoder worker before any map buffers are released. + if (_map_screen) delete _map_screen; + _map_screen = nullptr; releaseLocationObject(_location_persistence_controller); releaseLocationObject(_location_transaction); releaseLocationObject(_location_storage); @@ -330,6 +334,7 @@ bool UIManager::init() { _settings_screen = new SettingsScreen(); _propagation_nodes_screen = new PropagationNodesScreen(); _call_screen = new CallScreen(); + _map_screen = new MapScreen(); _home_screen->set_messages_callback([this]() { show_conversation_list(); }); _home_screen->set_nomadnet_callback([this]() { show_nomadnet(); }); @@ -365,6 +370,14 @@ bool UIManager::init() { [this]() { on_new_message(); } ); + _conversation_list_screen->set_map_callback( + [this]() { show_map(); } + ); + + _map_screen->set_back_callback( + [this]() { on_back_from_map(); } + ); + _conversation_list_screen->set_sync_callback( [this]() { on_propagation_sync(); } ); @@ -632,6 +645,31 @@ void UIManager::update() { } else if (location_result == Telemetry::DispatchResult::CEASE_QUEUED) { INFO("Location cease queued"); } + + // Build the fixed map model and service its worker before LVGL_LOCK. All + // authenticated ingress drains on this router-owner loop, so this fixed + // peer snapshot cannot race PeerLocationStore::apply(). + if (_navigation.current() == Route::MAP && _map_screen) { + Telemetry::PeerLocationRecord peers[Telemetry::MAX_PEER_LOCATIONS]{}; + static constexpr uint64_t MAP_PEER_MAX_AGE_MS = + 24ULL * 60ULL * 60ULL * 1000ULL; + const std::size_t peer_count = _peer_locations.snapshot( + wall_now_millis, MAP_PEER_MAX_AGE_MS, peers, + Telemetry::MAX_PEER_LOCATIONS); + Pyxis::MapView::Request map_request{}; + map_request.center = {0.0, 0.0}; + map_request.zoom = 2U; + map_request.width = Pyxis::MapScreenPresenter::VIEWPORT_WIDTH; + map_request.height = Pyxis::MapScreenPresenter::VIEWPORT_HEIGHT; + map_request.include_tile_border = false; + map_request.has_local_location = current_location_valid; + map_request.local_location = current_location; + map_request.peers = peers; + map_request.peer_count = peer_count; + map_request.wall_now_millis = wall_now_millis; + _map_screen->updateModel(map_request); + _map_screen->serviceIo(); + } LVGL_LOCK(); // Outgoing starts are initiated here while the recursive LVGL mutex is @@ -649,7 +687,11 @@ void UIManager::update() { } } - + if (_navigation.current() == Route::MAP && _map_screen) { + // One predecoded completion at most per tick, then fixed-pool positions. + (void)_map_screen->applyOneCompletion(); + _map_screen->applyFrame(); + } // Consume UI commands unconditionally. LVGL callbacks only publish into // the mailbox; loopTask remains the sole owner of the audio pipeline. const uint32_t generation = call_current_generation(); @@ -713,6 +755,7 @@ void UIManager::hide_all_screens() { if (_settings_screen) _settings_screen->hide(); if (_propagation_nodes_screen) _propagation_nodes_screen->hide(); if (_call_screen) _call_screen->hide(); + if (_map_screen) _map_screen->hide(); } void UIManager::show_home() { @@ -810,6 +853,9 @@ void UIManager::render_route(Route route) { _last_conversation_refresh_ms = millis(); _conversation_list_screen->show(); break; + case Route::MAP: + _map_screen->show(); + break; case Route::CHAT: _chat_screen->load_conversation(_current_peer_hash, _store); _chat_screen->show(); @@ -944,6 +990,15 @@ void UIManager::on_new_message() { show_compose(); } +void UIManager::show_map() { + INFO("Showing offline map"); + navigate(Route::MAP); +} + +void UIManager::on_back_from_map() { + back(); +} + void UIManager::show_settings() { INFO("Showing settings screen"); navigate(Route::SETTINGS); @@ -2053,7 +2108,14 @@ void UIManager::call_initiate(const Bytes& peer_hash) { _call_screen->set_peer(peer_dest.hash()); _call_screen->set_state(CallScreen::CallState::CONNECTING); _call_screen->set_muted(false); +<<<<<<< HEAD navigate(Route::CALL); +======= + _call_screen->show(); + _chat_screen->hide(); + if (_map_screen) _map_screen->hide(); + _current_screen = SCREEN_CALL; +>>>>>>> 7dae8e7 (feat: add bounded offline map screen) lxst_breadcrumb(5, ESP.getFreeHeap()); @@ -2908,7 +2970,13 @@ void UIManager::call_update() { _call_screen->set_peer(_call_peer_hash); _call_screen->set_state(CallScreen::CallState::INCOMING_RINGING); _call_screen->set_muted(false); +<<<<<<< HEAD navigate(Route::CALL); +======= + _call_screen->show(); + if (_map_screen) _map_screen->hide(); + _current_screen = SCREEN_CALL; +>>>>>>> 7dae8e7 (feat: add bounded offline map screen) // Play notification tone if (_settings_screen) { diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index 5a349e74..22ed2e1b 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -37,6 +37,7 @@ #include "CallLinkOwnership.h" #include "CallLivenessWatchdog.h" #include "LXSTSignalParser.h" +#include "MapScreen.h" #include "LXMF/LXMRouter.h" #include "LXMF/PropagationNodeManager.h" #include "LXMF/MessageStore.h" @@ -137,6 +138,7 @@ public: * Show compose new message screen */ void show_compose(); + void show_map(); /** * Show announce list screen @@ -370,6 +372,7 @@ private: CallScreen* _call_screen; std::function _radio_activity_snapshot_provider; RadioActivityScreen::RadioConfig _radio_activity_config; + MapScreen* _map_screen; ::LXMF::PropagationNodeManager* _propagation_manager; RNS::Interface* _ble_interface; @@ -427,6 +430,7 @@ private: void on_call_from_chat(); bool on_send_message_from_compose(const RNS::Bytes& dest_hash, const String& message); void on_cancel_compose(); + void on_back_from_map(); void on_announce_selected(const RNS::Bytes& dest_hash); void on_back_from_announces(); void on_back_from_status(); diff --git a/tests/build_scripts/test_map_screen_contract.py b/tests/build_scripts/test_map_screen_contract.py new file mode 100644 index 00000000..7b6abbf3 --- /dev/null +++ b/tests/build_scripts/test_map_screen_contract.py @@ -0,0 +1,99 @@ +"""Static boundedness and lock-boundary contracts for the offline map UI.""" +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[2] +UI = ROOT / "lib/tdeck_ui/UI/LXMF" +HW = ROOT / "lib/tdeck_ui/Hardware/TDeck" + + +def text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def function_body(source: str, signature: str) -> str: + start = source.index(signature) + brace = source.index("{", start) + depth = 0 + for index in range(brace, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return source[brace + 1:index] + raise AssertionError(f"unterminated function {signature}") + + +def test_fixed_pool_and_cache_contracts(): + presenter = text(UI / "MapScreenPresenter.h") + screen_h = text(UI / "MapScreen.h") + screen_cpp = text(UI / "MapScreen.cpp") + lv_conf = text(ROOT / "lib/lv_conf.h") + assert "TILE_SLOT_COUNT = 6" in presenter + assert "TILE_REQUEST_CAPACITY = 6" in presenter + assert "TILE_COMPLETION_CAPACITY = 6" in presenter + assert "MARKER_COUNT = 33" in screen_h + assert "approximation_halos_[MARKER_COUNT]" in screen_h + assert "VIEWPORT_WIDTH = 320" in presenter + assert "VIEWPORT_HEIGHT = 208" in presenter + assert "MAX_COMPRESSED_TILE_BYTES = 384U * 1024U" in screen_h + assert re.search(r"#define\s+LV_IMG_CACHE_DEF_SIZE\s+0\b", lv_conf) + assert "std::vector" not in presenter + screen_h + screen_cpp + assert "std::map" not in presenter + screen_h + screen_cpp + assert "static_assert(MapTileStore::HARD_MAX_ENTRIES == 128" in screen_cpp + assert "© OpenStreetMap contributors" in screen_cpp + assert "worker_exited_" in screen_h + + +def test_worker_predecodes_and_render_path_has_no_io(): + source = text(UI / "MapScreen.cpp") + assert "lodepng_decode24" in source + assert "beginGet" in source and "readGetChunk" in source + assert 'lv_img_set_src(tile_images_[index], &tile_descriptors_[index])' in source + assert 'lv_img_set_src(tile_images_[index], "' not in source + for signature in ("void MapScreen::applyFrame()", + "bool MapScreen::applyOneCompletion()"): + body = function_body(source, signature) + for forbidden in ("SDAccess", "SD.", "beginGet", "readGetChunk", + "lodepng", "new ", "delete ", "lv_obj_create"): + assert forbidden not in body + assert "MAX_COMPLETIONS_PER_TICK = 1" in text(UI / "MapScreen.h") + + +def test_sd_adapter_never_mounts_or_formats(): + source = text(HW / "MapTileStoreSD.cpp") + for forbidden in ("SD.begin", "SD.format", "LittleFS"): + assert forbidden not in source + + +def test_ui_manager_services_before_lock_and_hides_map_everywhere(): + source = text(UI / "UIManager.cpp") + update = function_body(source, "void UIManager::update()") + assert update.index("_map_screen->serviceIo()") < update.index("LVGL_LOCK();") + assert update.index("_map_screen->updateModel") < update.index("LVGL_LOCK();") + assert update.index("_map_screen->applyOneCompletion()") > update.index("LVGL_LOCK();") + assert "SCREEN_MAP" in text(UI / "UIManager.h") + assert "void UIManager::show_map()" in source + navigation = [ + "show_conversation_list", "show_chat", "show_compose", "show_announces", + "show_status", "show_settings", "show_propagation_nodes", + ] + for name in navigation: + body = function_body(source, f"void UIManager::{name}(") + assert "_map_screen->hide()" in body + map_body = function_body(source, "void UIManager::show_map()") + for screen in ("_conversation_list_screen", "_chat_screen", "_compose_screen", + "_announce_list_screen", "_status_screen", "_qr_screen", + "_settings_screen", "_propagation_nodes_screen", "_call_screen"): + assert screen in map_body + + +def test_conversation_navigation_has_five_buttons(): + header = text(UI / "ConversationListScreen.h") + source = text(UI / "ConversationListScreen.cpp") + assert "using MapCallback = std::function;" in header + assert "set_map_callback" in header and "_map_callback" in header + assert "LV_SYMBOL_GPS" in function_body(source, "void ConversationListScreen::create_bottom_nav()") + assert "i < 5" in source + assert "52, 28" in source diff --git a/tests/native/test_map_screen_presenter.cpp b/tests/native/test_map_screen_presenter.cpp new file mode 100644 index 00000000..e662e5a6 --- /dev/null +++ b/tests/native/test_map_screen_presenter.cpp @@ -0,0 +1,235 @@ +#include +#include +#include +#include +#include + +#include "UI/LXMF/MapScreenPresenter.h" + +namespace { +int passed = 0; +int failures = 0; +#define CHECK(expr) do { if (expr) { ++passed; } else { ++failures; std::cerr << "FAIL line " << __LINE__ << ": " #expr << '\n'; } } while (false) + +using Pyxis::MapScreenPresenter; +using Pyxis::MapTileCompletion; +using Pyxis::MapTileLoadResult; +using Pyxis::MapTileRequest; +using Pyxis::MapTileSlot; + +Pyxis::MapView::Request requestAt(double latitude, double longitude, std::uint32_t zoom) { + Pyxis::MapView::Request request{}; + request.center = {latitude, longitude}; + request.zoom = zoom; + request.width = MapScreenPresenter::VIEWPORT_WIDTH; + request.height = MapScreenPresenter::VIEWPORT_HEIGHT; + request.include_tile_border = false; + return request; +} + +bool sameKey(const Hardware::TDeck::TileKey& a, + const Hardware::TDeck::TileKey& b) { + return a.zoom == b.zoom && a.x == b.x && a.y == b.y; +} + +MapTileCompletion completionFor(const MapTileRequest& request, + MapTileLoadResult result) { + 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 = result; + return completion; +} + +void fixedCapacityAndDedupe() { + MapScreenPresenter presenter; + presenter.show(); + CHECK(presenter.buildFrame(requestAt(0.0, 0.0, 3)) == + Pyxis::MapView::Result::OK); + CHECK(presenter.frame().tile_count <= MapScreenPresenter::TILE_SLOT_COUNT); + CHECK(presenter.requestCount() == presenter.frame().tile_count); + + MapTileRequest requests[MapScreenPresenter::TILE_REQUEST_CAPACITY]{}; + std::size_t count = 0; + while (presenter.takeRequest(requests[count])) ++count; + CHECK(count == presenter.frame().tile_count); + CHECK(!presenter.takeRequest(requests[0])); + CHECK(presenter.buildFrame(requestAt(0.0, 0.0, 3)) == + Pyxis::MapView::Result::OK); + CHECK(presenter.requestCount() == 0); + for (std::size_t a = 0; a < count; ++a) { + for (std::size_t b = a + 1; b < count; ++b) { + CHECK(!sameKey(requests[a].key, requests[b].key)); + } + } +} + +void staleCompletionsRejectedAndAcceptedOnce() { + MapScreenPresenter presenter; + presenter.show(); + CHECK(presenter.buildFrame(requestAt(0.0, 0.0, 4)) == + Pyxis::MapView::Result::OK); + MapTileRequest request{}; + CHECK(presenter.takeRequest(request)); + + MapTileCompletion stale = completionFor(request, MapTileLoadResult::READY); + ++stale.generation; + CHECK(!presenter.publishCompletion(stale)); + stale = completionFor(request, MapTileLoadResult::READY); + ++stale.frame_epoch; + CHECK(!presenter.publishCompletion(stale)); + stale = completionFor(request, MapTileLoadResult::READY); + ++stale.slot_token; + CHECK(!presenter.publishCompletion(stale)); + stale = completionFor(request, MapTileLoadResult::READY); + stale.key.x ^= 1U; + CHECK(!presenter.publishCompletion(stale)); + MapTileCompletion output{}; + CHECK(!presenter.takeApplicableCompletion(output)); + + const MapTileCompletion good = completionFor(request, MapTileLoadResult::READY); + CHECK(presenter.publishCompletion(good)); + CHECK(presenter.takeApplicableCompletion(output)); + CHECK(output.slot_index == request.slot_index); + CHECK(presenter.slot(request.slot_index).state == MapTileSlot::READY); + CHECK(!presenter.takeApplicableCompletion(output)); + CHECK(!presenter.publishCompletion(good)); +} + +void newestFrameReusesSlotsAndPurgesOldRequests() { + MapScreenPresenter presenter; + presenter.show(); + CHECK(presenter.buildFrame(requestAt(0.0, 0.0, 4)) == + Pyxis::MapView::Result::OK); + const std::uint32_t first_epoch = presenter.frameEpoch(); + const std::uint32_t tokens_before[MapScreenPresenter::TILE_SLOT_COUNT] = { + presenter.slot(0).token, presenter.slot(1).token, + presenter.slot(2).token, presenter.slot(3).token, + presenter.slot(4).token, presenter.slot(5).token}; + CHECK(presenter.panPixels(300.0, 0.0)); + CHECK(presenter.frameEpoch() != first_epoch); + CHECK(presenter.buildFrame(requestAt(99.0, 99.0, 1)) == + Pyxis::MapView::Result::OK); + CHECK(presenter.requestCount() <= MapScreenPresenter::TILE_REQUEST_CAPACITY); + std::size_t occupied = 0; + bool token_advanced = false; + for (std::size_t i = 0; i < MapScreenPresenter::TILE_SLOT_COUNT; ++i) { + if (presenter.slot(i).state != MapTileSlot::EMPTY) ++occupied; + if (presenter.slot(i).token != tokens_before[i]) token_advanced = true; + } + CHECK(occupied == presenter.frame().tile_count); + CHECK(token_advanced); + MapTileRequest next{}; + while (presenter.takeRequest(next)) { + CHECK(next.frame_epoch == presenter.frameEpoch()); + CHECK(next.generation == presenter.generation()); + } +} + +void generationPanZoomAndRecenterBounds() { + MapScreenPresenter presenter; + const std::uint32_t initial_generation = presenter.generation(); + presenter.show(); + CHECK(presenter.generation() != initial_generation); + const std::uint32_t shown_generation = presenter.generation(); + presenter.hide(); + CHECK(presenter.generation() != shown_generation); + CHECK(!presenter.active()); + presenter.show(); + + CHECK(presenter.setView({0.0, 179.99}, 22)); + CHECK(presenter.panPixels(100000.0, 1000000000.0)); + CHECK(presenter.center().longitude >= -180.0); + CHECK(presenter.center().longitude < 180.0); + CHECK(std::fabs(presenter.center().latitude) <= + Pyxis::MapProjection::WEB_MERCATOR_MAX_LATITUDE + 1e-9); + CHECK(presenter.zoomBy(-100)); + CHECK(presenter.zoom() == 0U); + CHECK(!presenter.zoomBy(-1)); + CHECK(presenter.zoomBy(100)); + CHECK(presenter.zoom() == 22U); + CHECK(!presenter.zoomBy(1)); + + Telemetry::LocationTelemetry fix{}; + fix.latitude_e6 = 51500000; + fix.longitude_e6 = -114000; + CHECK(!presenter.recenter(false, fix)); + CHECK(presenter.recenter(true, fix)); + CHECK(std::fabs(presenter.center().latitude - 51.5) < 1e-9); + CHECK(std::fabs(presenter.center().longitude + 0.114) < 1e-9); +} + +void resultStatesAndQueueLimit() { + MapScreenPresenter presenter; + presenter.show(); + CHECK(presenter.buildFrame(requestAt(0.0, 0.0, 5)) == + Pyxis::MapView::Result::OK); + MapTileRequest requests[MapScreenPresenter::TILE_REQUEST_CAPACITY]{}; + std::size_t count = 0; + while (count < MapScreenPresenter::TILE_REQUEST_CAPACITY && + presenter.takeRequest(requests[count])) ++count; + CHECK(count <= MapScreenPresenter::TILE_REQUEST_CAPACITY); + const MapTileLoadResult results[] = { + MapTileLoadResult::MISS, + MapTileLoadResult::STORAGE_UNAVAILABLE, + MapTileLoadResult::INVALID_PNG, + MapTileLoadResult::TOO_LARGE, + MapTileLoadResult::IO_ERROR, + MapTileLoadResult::READY}; + for (std::size_t i = 0; i < count; ++i) { + CHECK(presenter.publishCompletion(completionFor(requests[i], results[i]))); + } + CHECK(presenter.completionCount() == count); + MapTileCompletion completion{}; + std::size_t applied = 0; + while (presenter.takeApplicableCompletion(completion)) ++applied; + CHECK(applied == count); + CHECK(presenter.completionCount() == 0); +} + +void deterministicHundredThousandOperationStress() { + MapScreenPresenter presenter; + presenter.show(); + std::uint32_t state = 0x12345678U; + for (std::size_t operation = 0; operation < 100000U; ++operation) { + state = state * 1664525U + 1013904223U; + if ((state & 15U) == 0U) { + presenter.zoomBy((state & 16U) ? 1 : -1); + } else { + const double dx = static_cast((state >> 8) & 127U) - 63; + const double dy = static_cast((state >> 16) & 127U) - 63; + (void)presenter.panPixels(dx, dy); + } + Pyxis::MapView::Request request = + requestAt(presenter.center().latitude, + presenter.center().longitude, + presenter.zoom()); + CHECK(presenter.buildFrame(request) == Pyxis::MapView::Result::OK); + CHECK(presenter.frame().tile_count <= MapScreenPresenter::TILE_SLOT_COUNT); + CHECK(presenter.requestCount() <= MapScreenPresenter::TILE_REQUEST_CAPACITY); + CHECK(presenter.completionCount() <= MapScreenPresenter::TILE_COMPLETION_CAPACITY); + MapTileRequest tile_request{}; + if (presenter.takeRequest(tile_request)) { + CHECK(presenter.publishCompletion( + completionFor(tile_request, MapTileLoadResult::READY))); + MapTileCompletion completion{}; + CHECK(presenter.takeApplicableCompletion(completion)); + } + } +} +} // namespace + +int main() { + fixedCapacityAndDedupe(); + staleCompletionsRejectedAndAcceptedOnce(); + newestFrameReusesSlotsAndPurgesOldRequests(); + generationPanZoomAndRecenterBounds(); + resultStatesAndQueueLimit(); + deterministicHundredThousandOperationStress(); + std::cout << "map screen presenter: " << passed << " passed, " + << failures << " failed\n"; + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/native/test_map_screen_presenter.py b/tests/native/test_map_screen_presenter.py new file mode 100644 index 00000000..d4d5a1d2 --- /dev/null +++ b/tests/native/test_map_screen_presenter.py @@ -0,0 +1,42 @@ +"""Compile the presenter as strict C++11 and run 100k operations under ASan/UBSan.""" +from __future__ import annotations + +import os +from pathlib import Path +import subprocess + +from native_test import find_cxx + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def test_map_screen_presenter_cpp11_sanitized(tmp_path): + binary = tmp_path / "test_map_screen_presenter" + command = [ + find_cxx(), + "-std=c++11", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + "-fsanitize=address,undefined", + "-fno-omit-frame-pointer", + f"-I{ROOT / 'lib/tdeck_ui'}", + str(HERE / "test_map_screen_presenter.cpp"), + str(ROOT / "lib/tdeck_ui/UI/LXMF/MapScreenPresenter.cpp"), + str(ROOT / "lib/tdeck_ui/UI/LXMF/MapViewModel.cpp"), + str(ROOT / "lib/tdeck_ui/UI/LXMF/MapProjection.cpp"), + "-o", + str(binary), + ] + compiled = subprocess.run(command, capture_output=True, text=True, timeout=60) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + env = os.environ.copy() + env["ASAN_OPTIONS"] = "detect_leaks=1:halt_on_error=1" + env["UBSAN_OPTIONS"] = "halt_on_error=1:print_stacktrace=1" + ran = subprocess.run( + [str(binary)], capture_output=True, text=True, timeout=180, env=env + ) + assert ran.returncode == 0, ran.stdout + ran.stderr + assert "0 failed" in ran.stdout diff --git a/tests/native/test_map_view_model.cpp b/tests/native/test_map_view_model.cpp index 05e9bfc4..c2112f95 100644 --- a/tests/native/test_map_view_model.cpp +++ b/tests/native/test_map_view_model.cpp @@ -69,6 +69,28 @@ void buildsBoundedTilesAndVisibleMarkers() { CHECK(samePeer(frame.markers[1].peer, peer(1))); CHECK(frame.markers[1].has_approx_radius); CHECK(frame.markers[1].approx_radius_meters == 0); + CHECK(frame.markers[1].approx_radius_pixels == 0.0); +} + +void projectsApproximateRadiusToPixels() { + Pyxis::MapView::Request request{}; + request.center = {0.0, 0.0}; + request.zoom = 10; + request.width = 320; + request.height = 200; + request.wall_now_millis = 1700000000000ULL; + Telemetry::PeerLocationRecord peer_record{}; + peer_record.peer = peer(9); + peer_record.location = fix(0.0, 0.0); + peer_record.has_approx_radius = true; + peer_record.approx_radius_meters = 100000; + request.peers = &peer_record; + request.peer_count = 1; + Pyxis::MapView::Frame frame{}; + CHECK(Pyxis::MapView::buildFrame(request, frame) == Pyxis::MapView::Result::OK); + CHECK(frame.marker_count == 1); + CHECK(frame.markers[0].approx_radius_pixels > 600.0); + CHECK(frame.markers[0].approx_radius_pixels < 700.0); } void rejectsInvalidAndLeavesOutputUnchanged() { @@ -103,6 +125,7 @@ void rejectsInvalidAndLeavesOutputUnchanged() { int main() { buildsBoundedTilesAndVisibleMarkers(); + projectsApproximateRadiusToPixels(); rejectsInvalidAndLeavesOutputUnchanged(); std::cout << "map view model: " << passed << " passed, " << failures << " failed\n";