Files
pyxis/lib/tdeck_ui/UI/LXMF/MapScreen.cpp
T
Pike 902df6554c ui(map): contrast pin labels against active basemap
Marker (pin) labels previously inherited the app's default text color,
which reads white and disappears on light basemaps. Set each label's
text color in applyFrame() against the active style: black by default
(light basemaps osm-bright/positron/toner) and white only on the one
dark basemap (dark-matter). Re-evaluated every frame so a style switch
re-colors visible labels on the next applied frame.

Add a contract test pinning the dark-basemap detection and the
black-by-default / white-on-dark ternary.
2026-09-02 21:20:24 +00:00

1032 lines
45 KiB
C++

// 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 <esp_heap_caps.h>
#include <esp_system.h>
#define LODEPNG_NO_COMPILE_CPP
extern "C" {
#include <src/extra/libs/png/lodepng.h>
}
#include <cmath>
#include <cstdio>
#include <cstring>
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:
AtomicStopSource(const std::atomic<bool>& stop,
const std::atomic<std::uint32_t>& frame_epoch,
std::uint32_t expected_frame_epoch)
: stop_(stop), frame_epoch_(frame_epoch),
expected_frame_epoch_(expected_frame_epoch) {}
bool stopRequested() const override {
return stop_.load(std::memory_order_acquire) ||
frame_epoch_.load(std::memory_order_acquire) != expected_frame_epoch_;
}
private:
const std::atomic<bool>& stop_;
const std::atomic<std::uint32_t>& frame_epoch_;
std::uint32_t expected_frame_epoch_;
};
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";
}
// The allowlist is otherwise light basemaps (osm-bright, positron, toner).
// Marker labels sit directly on those tiles with no backing plate, so black
// reads on light backgrounds and white only on the one dark basemap.
bool isDarkBasemap(const char* style_id) {
return style_id != nullptr && std::strcmp(style_id, "dark-matter") == 0;
}
} // 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),
requested_frame_epoch_(0U),
compressed_staging_(nullptr), worker_pixels_(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 mandatory compressed and RGB565 worker staging before optional
// decoded-cache entries. Stale work must never touch a visible LVGL buffer.
compressed_staging_ = static_cast<std::uint8_t*>(heap_caps_malloc(
MAX_COMPRESSED_TILE_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
worker_pixels_ = static_cast<lv_color_t*>(heap_caps_malloc(
TILE_PIXEL_COUNT * sizeof(lv_color_t),
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<lv_color_t*>(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<const std::uint8_t*>(
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<std::uint16_t*>(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<void*>(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 (worker_pixels_) heap_caps_free(worker_pixels_);
worker_pixels_ = 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_);
}
void MapScreen::publishFrameEpoch() {
requested_frame_epoch_.store(presenter_.frameEpoch(), std::memory_order_release);
if (worker_task_) xTaskNotifyGive(worker_task_);
}
bool MapScreen::startWorker() {
if (worker_started_) return true;
if (!state_mutex_ || !compressed_staging_ || !worker_pixels_) 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<MapScreen*>(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<StyleActivationCommitContext*>(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();
publishFrameEpoch();
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)) {
if (completion.result == Pyxis::MapTileLoadResult::READY &&
(completion.slot_index >= TILE_COUNT ||
!tile_pixels_[completion.slot_index])) {
completion.result = Pyxis::MapTileLoadResult::IO_ERROR;
}
const bool published = presenter_.publishCompletion(completion);
if (published && completion.result == Pyxis::MapTileLoadResult::READY) {
std::memcpy(tile_pixels_[completion.slot_index], worker_pixels_,
TILE_PIXEL_COUNT * sizeof(lv_color_t));
}
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 (worker_pixels_ && decoded_tile_cache_.get(
request.key, reinterpret_cast<std::uint16_t*>(worker_pixels_),
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_, requested_frame_epoch_, request.frame_epoch);
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::CANCELLED) {
return Pyxis::MapTileLoadResult::CANCELLED;
}
if (read != Pyxis::MapTileStreamResult::OK) {
return Pyxis::MapTileLoadResult::IO_ERROR;
}
if (stop.stopRequested()) return Pyxis::MapTileLoadResult::CANCELLED;
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 (stop.stopRequested()) {
lv_mem_free(rgb);
return Pyxis::MapTileLoadResult::CANCELLED;
}
if (!worker_pixels_) {
lv_mem_free(rgb);
return Pyxis::MapTileLoadResult::IO_ERROR;
}
for (std::size_t index = 0; index < TILE_PIXEL_COUNT; ++index) {
if ((index & 255U) == 0U && stop.stopRequested()) {
lv_mem_free(rgb);
return Pyxis::MapTileLoadResult::CANCELLED;
}
const std::size_t source = index * 3U;
worker_pixels_[index] = lv_color_make(rgb[source], rgb[source + 1U],
rgb[source + 2U]);
}
lv_mem_free(rgb);
if (stop.stopRequested()) return Pyxis::MapTileLoadResult::CANCELLED;
(void)decoded_tile_cache_.put(
request.key, reinterpret_cast<const std::uint16_t*>(worker_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);
publishFrameEpoch();
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<lv_coord_t>(std::lround(slot.screen_x)),
static_cast<lv_coord_t>(std::lround(slot.screen_y)));
lv_obj_clear_flag(tile_images_[index], LV_OBJ_FLAG_HIDDEN);
} else {
setPlaceholder(index);
}
}
// Marker labels float directly over the basemap tiles (no backing plate),
// so their ink must contrast the active style: black on light basemaps,
// white on the one dark basemap. Re-evaluated every frame so a style
// switch re-colors any visible labels on the next applied frame.
const lv_color_t marker_label_color =
isDarkBasemap(style_selector_.activeId())
? lv_color_white() : lv_color_black();
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<lv_coord_t>(std::lround(marker.screen_x));
const lv_coord_t y = static_cast<lv_coord_t>(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<lv_coord_t>(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_style_text_color(marker_labels_[index], marker_label_color, 0);
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<unsigned long>(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 detached under LVGL_LOCK before requests are
// released. The worker still publishes only from its private staging buffer.
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<lv_coord_t>(std::lround(slot.screen_x)),
static_cast<lv_coord_t>(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();
publishFrameEpoch();
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();
publishFrameEpoch();
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<MapScreen*>(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);
publishFrameEpoch();
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->publishFrameEpoch();
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->publishFrameEpoch();
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->publishFrameEpoch();
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<std::size_t>(
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<double>(screen->last_drag_point_.x - point.x);
const double dy = static_cast<double>(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