diff --git a/lib/tdeck_ui/Hardware/TDeck/Display.cpp b/lib/tdeck_ui/Hardware/TDeck/Display.cpp index dccf4297..dae2b911 100644 --- a/lib/tdeck_ui/Hardware/TDeck/Display.cpp +++ b/lib/tdeck_ui/Hardware/TDeck/Display.cpp @@ -26,11 +26,16 @@ bool Display::_hw_initialized = false; volatile uint32_t Display::_flush_count = 0; volatile uint32_t Display::_last_flush_ms = 0; uint32_t Display::_last_health_log_ms = 0; +DisplayRefreshRetry Display::_refresh_retry; void Display::set_spi_mutex(SemaphoreHandle_t mutex) { _spi_mutex = mutex; } +bool Display::consume_refresh_retry() { + return _refresh_retry.consume(); +} + bool Display::init() { if (_initialized) { return true; @@ -271,7 +276,10 @@ void Display::draw_rect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t col void Display::lvgl_flush_cb(lv_disp_drv_t* drv, const lv_area_t* area, lv_color_t* color_p) { if (_spi_mutex && xSemaphoreTake(_spi_mutex, pdMS_TO_TICKS(100)) != pdTRUE) { - // Skip this frame — LVGL will retry next tick + // LVGL clears the invalidated area after flush_ready(), so explicitly + // request a redraw after the current refresh cycle. Otherwise stale + // pixels remain indefinitely when SD/LoRa owns the shared SPI bus. + _refresh_retry.mark_failed(); lv_disp_flush_ready(drv); return; } diff --git a/lib/tdeck_ui/Hardware/TDeck/Display.h b/lib/tdeck_ui/Hardware/TDeck/Display.h index d44c1e78..52d4f5d3 100644 --- a/lib/tdeck_ui/Hardware/TDeck/Display.h +++ b/lib/tdeck_ui/Hardware/TDeck/Display.h @@ -5,6 +5,7 @@ #define HARDWARE_TDECK_DISPLAY_H #include "Config.h" +#include "DisplayRefreshRetry.h" #ifdef ARDUINO #include @@ -86,6 +87,9 @@ public: */ static void lvgl_flush_cb(lv_disp_drv_t* drv, const lv_area_t* area, lv_color_t* color_p); + /** Consume a redraw requested by a flush that lost the shared SPI bus. */ + static bool consume_refresh_retry(); + private: // Show boot splash image, called from init_hardware_only() static void show_splash(); @@ -154,6 +158,7 @@ private: static volatile uint32_t _flush_count; static volatile uint32_t _last_flush_ms; static uint32_t _last_health_log_ms; + static DisplayRefreshRetry _refresh_retry; public: // Check display health (call from main loop periodically) diff --git a/lib/tdeck_ui/Hardware/TDeck/DisplayRefreshRetry.h b/lib/tdeck_ui/Hardware/TDeck/DisplayRefreshRetry.h new file mode 100644 index 00000000..1c1f9612 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/DisplayRefreshRetry.h @@ -0,0 +1,30 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef HARDWARE_TDECK_DISPLAY_REFRESH_RETRY_H +#define HARDWARE_TDECK_DISPLAY_REFRESH_RETRY_H + +namespace Hardware { +namespace TDeck { + +// Coalesces one or more unflushed LVGL regions into a single redraw request. +// The flush callback and consumer run on the LVGL task, so no synchronization +// or allocation is required. +class DisplayRefreshRetry { +public: + void mark_failed() { _pending = true; } + + bool consume() { + const bool pending = _pending; + _pending = false; + return pending; + } + +private: + bool _pending = false; +}; + +} // namespace TDeck +} // namespace Hardware + +#endif // HARDWARE_TDECK_DISPLAY_REFRESH_RETRY_H diff --git a/lib/tdeck_ui/Hardware/TDeck/Trackball.cpp b/lib/tdeck_ui/Hardware/TDeck/Trackball.cpp index b6ce81e1..1017b2ca 100644 --- a/lib/tdeck_ui/Hardware/TDeck/Trackball.cpp +++ b/lib/tdeck_ui/Hardware/TDeck/Trackball.cpp @@ -71,17 +71,6 @@ lv_obj_t* group_navigation_root(lv_group_t* group, lv_obj_t* focused) { return root; } -void normalize_group_focus_state(lv_group_t* group) { - lv_obj_t* focused = lv_group_get_focused(group); - lv_obj_t** node = static_cast(_lv_ll_get_head(&group->obj_ll)); - while (node) { - if (*node != focused) { - lv_obj_clear_state(*node, LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY); - } - node = static_cast(_lv_ll_get_next(&group->obj_ll, node)); - } -} - bool can_scroll(lv_obj_t* object, NavigationDirection direction) { if (!object || !object_is_visible(object) || !lv_obj_has_flag(object, LV_OBJ_FLAG_SCROLLABLE)) return false; @@ -149,7 +138,6 @@ void scroll_one_step(lv_obj_t* object, NavigationDirection direction) { bool navigate_or_scroll(lv_group_t* group, NavigationDirection direction) { if (!group) return false; if (group->frozen) return false; - normalize_group_focus_state(group); lv_obj_t* focused = lv_group_get_focused(group); // A clipped focus target remains the spatial anchor while its container // scrolls. Only a truly hidden/stale target needs insertion-order refocus. @@ -158,7 +146,6 @@ bool navigate_or_scroll(lv_group_t* group, NavigationDirection direction) { while (node) { if (object_is_focus_candidate(*node)) { lv_group_focus_obj(*node); - normalize_group_focus_state(group); return true; } node = static_cast(_lv_ll_get_next(&group->obj_ll, node)); @@ -192,7 +179,6 @@ bool navigate_or_scroll(lv_group_t* group, NavigationDirection direction) { scroll_one_step(ancestor, direction); } else { lv_group_focus_obj(target); - normalize_group_focus_state(group); } return true; } diff --git a/lib/tdeck_ui/UI/LVGL/LVGLInit.cpp b/lib/tdeck_ui/UI/LVGL/LVGLInit.cpp index 75e09ef6..bed61852 100644 --- a/lib/tdeck_ui/UI/LVGL/LVGLInit.cpp +++ b/lib/tdeck_ui/UI/LVGL/LVGLInit.cpp @@ -19,6 +19,19 @@ using namespace Hardware::TDeck; namespace UI { namespace LVGL { +namespace { + +void retry_failed_display_flush() { + lv_obj_t* screen = lv_scr_act(); + if (screen && Display::consume_refresh_retry()) { + // The previous refresh cycle has completed, so invalidation is safe + // here and guarantees that an acknowledged-but-unwritten area is redrawn. + lv_obj_invalidate(screen); + } +} + +} // namespace + bool LVGLInit::_initialized = false; lv_disp_t* LVGLInit::_display = nullptr; lv_indev_t* LVGLInit::_keyboard = nullptr; @@ -149,6 +162,7 @@ void LVGLInit::task_handler() { } lv_task_handler(); + retry_failed_display_flush(); } void LVGLInit::lvgl_task(void* param) { @@ -171,6 +185,7 @@ void LVGLInit::lvgl_task(void* param) { xSemaphoreTakeRecursive(_mutex, portMAX_DELAY); #endif lv_task_handler(); + retry_failed_display_flush(); xSemaphoreGiveRecursive(_mutex); // Feed watchdog and yield to other tasks diff --git a/tests/native/test_display_refresh_retry.cpp b/tests/native/test_display_refresh_retry.cpp new file mode 100644 index 00000000..b510ed59 --- /dev/null +++ b/tests/native/test_display_refresh_retry.cpp @@ -0,0 +1,22 @@ +#include + +#include "lib/tdeck_ui/Hardware/TDeck/DisplayRefreshRetry.h" + +int main() { + Hardware::TDeck::DisplayRefreshRetry retry; + + assert(!retry.consume()); + + retry.mark_failed(); + assert(retry.consume()); + assert(!retry.consume()); + + // Multiple dropped regions before the refresh cycle completes coalesce + // into one full-screen retry on the next LVGL cycle. + retry.mark_failed(); + retry.mark_failed(); + assert(retry.consume()); + assert(!retry.consume()); + + return 0; +} diff --git a/tests/native/test_display_refresh_retry.py b/tests/native/test_display_refresh_retry.py new file mode 100644 index 00000000..7b2f17d4 --- /dev/null +++ b/tests/native/test_display_refresh_retry.py @@ -0,0 +1,48 @@ +"""Regression coverage for lossless display refresh after SPI contention.""" + +from pathlib import Path +import shutil +import subprocess + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_display_refresh_retry_state(tmp_path): + cxx = shutil.which("clang++") or shutil.which("g++") + if not cxx: + raise RuntimeError("A C++17 compiler is required") + + binary = tmp_path / "display_refresh_retry" + subprocess.run( + [ + cxx, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-I", + str(ROOT), + str(ROOT / "tests/native/test_display_refresh_retry.cpp"), + "-o", + str(binary), + ], + check=True, + cwd=ROOT, + ) + subprocess.run([str(binary)], check=True) + + +def test_display_retry_is_wired_after_lvgl_refresh(): + display = (ROOT / "lib/tdeck_ui/Hardware/TDeck/Display.cpp").read_text() + lvgl = (ROOT / "lib/tdeck_ui/UI/LVGL/LVGLInit.cpp").read_text() + trackball = (ROOT / "lib/tdeck_ui/Hardware/TDeck/Trackball.cpp").read_text() + + assert "refresh_retry.mark_failed();" in display + assert "Skip this frame — LVGL will retry next tick" not in display + assert "Display::consume_refresh_retry()" in lvgl + assert "lv_obj_invalidate(screen)" in lvgl + + # Focus state belongs to LVGL's group machinery. The display retry fixes + # stale LCD pixels without mutating non-owner widget state. + assert "normalize_group_focus_state" not in trackball diff --git a/tests/native/test_trackball_navigation.py b/tests/native/test_trackball_navigation.py index ac1ce75d..7c346544 100644 --- a/tests/native/test_trackball_navigation.py +++ b/tests/native/test_trackball_navigation.py @@ -70,11 +70,6 @@ def test_trackball_and_nomadnet_directional_integration_contract(): assert "accum_y > 0 ? NavigationDirection::UP : NavigationDirection::DOWN" in trackball assert "accum_x > 0 ? NavigationDirection::RIGHT : NavigationDirection::LEFT" in trackball assert trackball.count("last_key_time = now;\n accum_x = 0;\n accum_y = 0;") == 2 - # One LVGL group has exactly one focus owner. Normalize stale focus states - # observed physically so two buttons cannot remain highlighted together. - assert "normalize_group_focus_state(group);" in trackball - assert "LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY" in trackball - assert "LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY | LV_STATE_EDITED" not in trackball # NomadNet's document viewport must remain a bounded vertical scroll target; # trackball movement reaches it through the generic LVGL navigation helper.