From 0d7d644ab2d088cc7374c7ef4a0ac27f117573d4 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 26 Jul 2026 13:36:32 +0200 Subject: [PATCH 1/7] Replace tt_lock with file_mutex --- Apps/Diceware/main/Source/Diceware.cpp | 23 +++---- .../main/Source/EpubReaderAsync.cpp | 16 ++--- .../EspNowBridge/main/Source/EspNowBridge.cpp | 1 - Apps/TodoList/main/Source/TodoList.cpp | 68 +++++++++---------- 4 files changed, 48 insertions(+), 60 deletions(-) diff --git a/Apps/Diceware/main/Source/Diceware.cpp b/Apps/Diceware/main/Source/Diceware.cpp index 5830935..f3ba674 100644 --- a/Apps/Diceware/main/Source/Diceware.cpp +++ b/Apps/Diceware/main/Source/Diceware.cpp @@ -1,7 +1,7 @@ #include "Diceware.h" #include -#include +#include #include #include @@ -39,18 +39,17 @@ static std::string readWordAtLine(const AppHandle handle, const int lineIndex) { return ""; } - auto lock = tt_lock_alloc_for_path(path); + struct FileMutex mutex; + file_mutex_get(&mutex, path); std::string word; - if (tt_lock_acquire(lock, tt::kernel::MAX_TICKS)) { - FILE* file = fopen(path, "r"); - if (file != nullptr) { - skipNewlines(file, lineIndex); - word = readWord(file); - fclose(file); - } else { ESP_LOGE(TAG, "Failed to open %s", path); } - tt_lock_release(lock); - } else { ESP_LOGE(TAG, "Failed to acquire lock for %s", path); } - tt_lock_free(lock); + file_mutex_lock(&mutex); + FILE* file = fopen(path, "r"); + if (file != nullptr) { + skipNewlines(file, lineIndex); + word = readWord(file); + fclose(file); + } else { ESP_LOGE(TAG, "Failed to open %s", path); } + file_mutex_unlock(&mutex); return word; } diff --git a/Apps/EpubReader/main/Source/EpubReaderAsync.cpp b/Apps/EpubReader/main/Source/EpubReaderAsync.cpp index a912472..655d4fb 100644 --- a/Apps/EpubReader/main/Source/EpubReaderAsync.cpp +++ b/Apps/EpubReader/main/Source/EpubReaderAsync.cpp @@ -1,6 +1,6 @@ #include "EpubReader.h" #include -#include +#include #include #include #include @@ -115,14 +115,9 @@ void EpubReader::backgroundOpenTask(void* data) { // Acquire the filesystem lock before any SD card I/O - prevents concurrent // SDMMC access from the background and LVGL tasks (bus errors 0x107/0x108). - auto lock = tt_lock_alloc_for_path(a->filePath.c_str()); - if (!tt_lock_acquire(lock, tt::kernel::MAX_TICKS)) { - LOG_E(TAG, "FS lock timed out, skipping open: %s", a->filePath.c_str()); - tt_lock_free(lock); - lv_async_call(asyncOpenComplete, a); - vTaskDelete(nullptr); - return; - } + struct FileMutex mutex; + file_mutex_get(&mutex, a->filePath.c_str()); + file_mutex_lock(&mutex); if (isTextFile(a->filePath)) { // Read the entire text file here (under the lock) so asyncOpenComplete @@ -146,8 +141,7 @@ void EpubReader::backgroundOpenTask(void* data) { a->epub = EpubService::open(a->filePath); } - tt_lock_release(lock); - tt_lock_free(lock); + file_mutex_unlock(&mutex); // Signal the LVGL task that the work is done lv_async_call(asyncOpenComplete, a); diff --git a/Apps/EspNowBridge/main/Source/EspNowBridge.cpp b/Apps/EspNowBridge/main/Source/EspNowBridge.cpp index a56fa21..d8120cf 100644 --- a/Apps/EspNowBridge/main/Source/EspNowBridge.cpp +++ b/Apps/EspNowBridge/main/Source/EspNowBridge.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include #include diff --git a/Apps/TodoList/main/Source/TodoList.cpp b/Apps/TodoList/main/Source/TodoList.cpp index a7d3f9f..72ce485 100644 --- a/Apps/TodoList/main/Source/TodoList.cpp +++ b/Apps/TodoList/main/Source/TodoList.cpp @@ -1,6 +1,6 @@ #include "TodoList.h" #include -#include +#include #include #include #include @@ -60,52 +60,48 @@ void TodoList::saveTodos() { char savePath[256]; if (!getSaveFilePath(savePath, sizeof(savePath))) return; - auto lock = tt_lock_alloc_for_path(savePath); - if (!lock) return; - if (tt_lock_acquire(lock, tt::kernel::MAX_TICKS)) { - FILE* f = fopen(savePath, "w"); - if (f) { - for (int i = 0; i < count; i++) { - fprintf(f, "%c %s\n", items[i].done ? '+' : '-', items[i].text); - } - fclose(f); + struct FileMutex mutex; + file_mutex_get(&mutex, savePath); + file_mutex_lock(&mutex); + FILE* f = fopen(savePath, "w"); + if (f) { + for (int i = 0; i < count; i++) { + fprintf(f, "%c %s\n", items[i].done ? '+' : '-', items[i].text); } - tt_lock_release(lock); + fclose(f); } - tt_lock_free(lock); + file_mutex_unlock(&mutex); } void TodoList::loadTodos() { char savePath[256]; if (!getSaveFilePath(savePath, sizeof(savePath))) return; - auto lock = tt_lock_alloc_for_path(savePath); - if (!lock) return; - - if (tt_lock_acquire(lock, tt::kernel::MAX_TICKS)) { - count = 0; - FILE* f = fopen(savePath, "r"); - if (f) { - char line[MAX_TEXT_LEN + 4]; - while (count < MAX_TODOS && fgets(line, sizeof(line), f)) { - size_t len = strlen(line); - while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) { - line[--len] = '\0'; - } - - if (len < 3 || line[1] != ' ') continue; - - TodoItem* item = &items[count]; - item->done = (line[0] == '+'); - strncpy(item->text, &line[2], MAX_TEXT_LEN - 1); - item->text[MAX_TEXT_LEN - 1] = '\0'; - count++; + struct FileMutex mutex; + file_mutex_get(&mutex, savePath); + + file_mutex_lock(&mutex); + count = 0; + FILE* f = fopen(savePath, "r"); + if (f) { + char line[MAX_TEXT_LEN + 4]; + while (count < MAX_TODOS && fgets(line, sizeof(line), f)) { + size_t len = strlen(line); + while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) { + line[--len] = '\0'; } - fclose(f); + + if (len < 3 || line[1] != ' ') continue; + + TodoItem* item = &items[count]; + item->done = (line[0] == '+'); + strncpy(item->text, &line[2], MAX_TEXT_LEN - 1); + item->text[MAX_TEXT_LEN - 1] = '\0'; + count++; } - tt_lock_release(lock); + fclose(f); } - tt_lock_free(lock); + file_mutex_unlock(&mutex); } /* ── UI Helpers ───────────────────────────────────────────────────── */ From 1651ebcf255b117e112b99e75463e9661a3c7399 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 26 Jul 2026 13:40:53 +0200 Subject: [PATCH 2/7] Replace tt_lvgl_keyboard.h with device.h and keyboard.h usages --- Apps/Breakout/main/Source/Breakout.cpp | 5 +++-- Apps/Magic8Ball/main/Source/Magic8Ball.cpp | 9 +++++---- Apps/MediaKeys/main/Source/MediaKeys.cpp | 10 +++++----- Apps/MediaKeys/main/Source/MediaKeys.h | 2 +- Apps/Snake/main/Source/SnakeUi.c | 7 ++++--- Apps/TodoList/main/Source/TodoList.cpp | 1 - Apps/TwoEleven/main/Source/TwoElevenUi.c | 7 ++++--- 7 files changed, 22 insertions(+), 19 deletions(-) diff --git a/Apps/Breakout/main/Source/Breakout.cpp b/Apps/Breakout/main/Source/Breakout.cpp index e7101ae..5bdd8ac 100644 --- a/Apps/Breakout/main/Source/Breakout.cpp +++ b/Apps/Breakout/main/Source/Breakout.cpp @@ -10,7 +10,8 @@ #include #include #include -#include +#include +#include #include #include @@ -1329,7 +1330,7 @@ void Breakout::updateMessage() { case GameState::Ready: { char buf[64]; const char* input_hint = "Touch"; - if (tt_lvgl_hardware_keyboard_is_available()) { + if (device_has_active_by_type(&KEYBOARD_TYPE)) { input_hint = "Space"; } if (level > 1) { diff --git a/Apps/Magic8Ball/main/Source/Magic8Ball.cpp b/Apps/Magic8Ball/main/Source/Magic8Ball.cpp index 02b4ecb..f3c9c49 100644 --- a/Apps/Magic8Ball/main/Source/Magic8Ball.cpp +++ b/Apps/Magic8Ball/main/Source/Magic8Ball.cpp @@ -1,6 +1,7 @@ #include "Magic8Ball.h" #include -#include +#include +#include #include #include @@ -35,7 +36,7 @@ static const char* responses[] = { #define NUM_RESPONSES (sizeof(responses) / sizeof(responses[0])) static const char* getInputHint() { - if (tt_lvgl_hardware_keyboard_is_available()) { + if (device_has_active_by_type(&KEYBOARD_TYPE)) { return "Touch or Space to ask Q to exit"; } return "Touch the ball to ask"; @@ -141,7 +142,7 @@ void Magic8Ball::onShow(AppHandle app, lv_obj_t* parent) { lv_obj_add_event_cb(ballObj, onBallClick, LV_EVENT_CLICKED, this); /* Keyboard support - no editing mode needed, just focus the ball */ - if (tt_lvgl_hardware_keyboard_is_available()) { + if (device_has_active_by_type(&KEYBOARD_TYPE)) { lv_group_t* grp = lv_group_get_default(); if (grp) { lv_group_add_obj(grp, ballObj); @@ -152,7 +153,7 @@ void Magic8Ball::onShow(AppHandle app, lv_obj_t* parent) { } void Magic8Ball::onHide(AppHandle app) { - if (tt_lvgl_hardware_keyboard_is_available() && ballObj) { + if (device_has_active_by_type(&KEYBOARD_TYPE) && ballObj) { lv_group_remove_obj(ballObj); } answerLabel = nullptr; diff --git a/Apps/MediaKeys/main/Source/MediaKeys.cpp b/Apps/MediaKeys/main/Source/MediaKeys.cpp index bd25559..6fca94d 100644 --- a/Apps/MediaKeys/main/Source/MediaKeys.cpp +++ b/Apps/MediaKeys/main/Source/MediaKeys.cpp @@ -165,7 +165,7 @@ void MediaKeys::btEventCallback(struct Device* /*device*/, void* context, struct // Radio dropped while we were active - revert UI LOG_I(TAG, "BT radio turned off, disabling HID"); if (lvgl_try_lock(1000)) { - if (tt_lvgl_hardware_keyboard_is_available()) self->exitKeyMode(); + if (device_has_active_by_type(&KEYBOARD_TYPE)) self->exitKeyMode(); self->_hidDevice = nullptr; self->_isEnabled = false; self->_radioEnabling = false; @@ -208,7 +208,7 @@ void MediaKeys::startHid() { } if (_mainWrapper) lv_obj_remove_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN); - if (tt_lvgl_hardware_keyboard_is_available()) enterKeyMode(); + if (device_has_active_by_type(&KEYBOARD_TYPE)) enterKeyMode(); } void MediaKeys::teardownBt() { @@ -276,7 +276,7 @@ void MediaKeys::handleSwitchToggle(bool enabled) { } } else { _radioEnabling = false; - if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode(); + if (device_has_active_by_type(&KEYBOARD_TYPE)) exitKeyMode(); // Explicit user toggle-off: stop HID cleanly (safe here since we're on the // LVGL task and the user intentionally disabled, so no race with app teardown). if (_hidDevice) bluetooth_hid_device_stop(_hidDevice); @@ -342,7 +342,7 @@ void MediaKeys::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_add_event_cb(_buttonMatrix, onButtonPressed, LV_EVENT_VALUE_CHANGED, this); // Physical keyboard support: key events on the matrix (entered when BT enabled, Q/Esc exits) - if (tt_lvgl_hardware_keyboard_is_available()) { + if (device_has_active_by_type(&KEYBOARD_TYPE)) { lv_obj_add_event_cb(_buttonMatrix, onKeyEvent, LV_EVENT_KEY, this); _keyHighlightTimer = lv_timer_create(onKeyHighlightTimer, 150, this); lv_timer_pause(_keyHighlightTimer); @@ -364,7 +364,7 @@ void MediaKeys::onShow(AppHandle appHandle, lv_obj_t* parent) { void MediaKeys::onHide(AppHandle /*appHandle*/) { _radioEnabling = false; _isEnabled = false; - if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode(); + if (device_has_active_by_type(&KEYBOARD_TYPE)) exitKeyMode(); teardownBt(); if (_keyHighlightTimer) { lv_timer_delete(_keyHighlightTimer); diff --git a/Apps/MediaKeys/main/Source/MediaKeys.h b/Apps/MediaKeys/main/Source/MediaKeys.h index eedc5be..3fd70ff 100644 --- a/Apps/MediaKeys/main/Source/MediaKeys.h +++ b/Apps/MediaKeys/main/Source/MediaKeys.h @@ -5,8 +5,8 @@ #include #include #include +#include #include -#include #include class MediaKeys final : public App { diff --git a/Apps/Snake/main/Source/SnakeUi.c b/Apps/Snake/main/Source/SnakeUi.c index e828460..9f448ab 100644 --- a/Apps/Snake/main/Source/SnakeUi.c +++ b/Apps/Snake/main/Source/SnakeUi.c @@ -7,7 +7,8 @@ #include #include #include -#include +#include +#include // Forward declarations static void game_play_event(lv_event_t* e); @@ -36,7 +37,7 @@ static void delete_event(lv_event_t* e) { } // Restore edit mode and remove from group before cleanup - if (tt_lvgl_hardware_keyboard_is_available()) { + if (device_has_active_by_type(&KEYBOARD_TYPE)) { lv_group_t* group = lv_group_get_default(); if (group) lv_group_set_editing(group, false); lv_group_remove_obj(game->container); @@ -397,7 +398,7 @@ lv_obj_t* snake_create(lv_obj_t* parent, uint16_t cell_size, bool wall_collision lv_obj_add_event_cb(obj, delete_event, LV_EVENT_DELETE, NULL); // Set up keyboard focus if available - if (tt_lvgl_hardware_keyboard_is_available()) { + if (device_has_active_by_type(&KEYBOARD_TYPE)) { lv_group_t* group = lv_group_get_default(); if (group) { lv_group_add_obj(group, game->container); diff --git a/Apps/TodoList/main/Source/TodoList.cpp b/Apps/TodoList/main/Source/TodoList.cpp index 72ce485..cd4d8bd 100644 --- a/Apps/TodoList/main/Source/TodoList.cpp +++ b/Apps/TodoList/main/Source/TodoList.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include #include #include diff --git a/Apps/TwoEleven/main/Source/TwoElevenUi.c b/Apps/TwoEleven/main/Source/TwoElevenUi.c index e342301..ff76ed0 100644 --- a/Apps/TwoEleven/main/Source/TwoElevenUi.c +++ b/Apps/TwoEleven/main/Source/TwoElevenUi.c @@ -3,7 +3,8 @@ #include "TwoElevenHelpers.h" #include #include -#include +#include +#include static void game_play_event(lv_event_t * e); static void btnm_event_cb(lv_event_t * e); @@ -18,7 +19,7 @@ static void delete_event(lv_event_t * e) twoeleven_t * game_2048 = (twoeleven_t *)lv_obj_get_user_data(obj); if (game_2048) { // Restore edit mode and remove from group before cleanup - if (tt_lvgl_hardware_keyboard_is_available()) { + if (device_has_active_by_type(&KEYBOARD_TYPE)) { lv_group_t* group = lv_group_get_default(); if (group) lv_group_set_editing(group, false); lv_group_remove_obj(game_2048->btnm); @@ -140,7 +141,7 @@ lv_obj_t * twoeleven_create(lv_obj_t * parent, uint16_t matrix_size) lv_obj_add_event_cb(game_2048->btnm, btnm_event_cb, LV_EVENT_DRAW_TASK_ADDED, NULL); lv_obj_add_event_cb(obj, delete_event, LV_EVENT_DELETE, NULL); - if (tt_lvgl_hardware_keyboard_is_available()) { + if (device_has_active_by_type(&KEYBOARD_TYPE)) { lv_group_t* group = lv_group_get_default(); if (group) { lv_group_add_obj(group, game_2048->btnm); From a59caec1024f6d080a4f5935ae72778bbd3e5b70 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 26 Jul 2026 17:09:32 +0200 Subject: [PATCH 3/7] Update apps for SKD LVGL updates --- Apps/Brainfuck/main/Source/Brainfuck.cpp | 8 ++++---- Apps/Brainfuck/manifest.properties | 4 ++-- Apps/Breakout/main/Source/Breakout.cpp | 6 +++--- Apps/Breakout/manifest.properties | 4 ++-- Apps/Calculator/main/Source/Calculator.cpp | 4 ++-- Apps/Calculator/manifest.properties | 4 ++-- Apps/Diceware/main/Source/Diceware.cpp | 6 +++--- Apps/Diceware/manifest.properties | 4 ++-- Apps/EpubReader/main/Source/EpubReader.cpp | 4 ++-- Apps/EpubReader/main/Source/EpubReaderAsync.cpp | 4 ++-- Apps/EpubReader/main/Source/EpubReaderUI.cpp | 16 ++++++++-------- Apps/EpubReader/manifest.properties | 4 ++-- Apps/EspNowBridge/main/Source/EspNowBridge.cpp | 4 ++-- Apps/EspNowBridge/manifest.properties | 4 ++-- Apps/GPIO/main/Source/Gpio.cpp | 4 ++-- Apps/GPIO/manifest.properties | 4 ++-- Apps/GraphicsDemo/manifest.properties | 4 ++-- Apps/HelloWorld/main/Source/main.c | 4 ++-- Apps/HelloWorld/manifest.properties | 4 ++-- Apps/M5UnitTest/main/Source/M5UnitTest.cpp | 1 - Apps/M5UnitTest/main/Source/TestListView.cpp | 6 +++--- Apps/M5UnitTest/main/Source/TestListView.h | 2 +- Apps/M5UnitTest/main/Source/TestUnit8Encoder.cpp | 2 +- .../main/Source/TestUnitByteButton.cpp | 2 +- Apps/M5UnitTest/main/Source/TestUnitCardKB2.cpp | 2 +- .../main/Source/TestUnitDualButton.cpp | 2 +- .../M5UnitTest/main/Source/TestUnitJoystick2.cpp | 2 +- Apps/M5UnitTest/main/Source/TestUnitLcd.cpp | 2 +- Apps/M5UnitTest/main/Source/TestUnitLcdGfx.cpp | 2 +- Apps/M5UnitTest/main/Source/TestUnitMidi.cpp | 2 +- Apps/M5UnitTest/main/Source/TestUnitPaHub.cpp | 2 +- Apps/M5UnitTest/main/Source/TestUnitRfid2.cpp | 3 +-- Apps/M5UnitTest/main/Source/TestUnitScroll.cpp | 2 +- Apps/M5UnitTest/main/Source/TestViewBase.cpp | 9 ++++----- Apps/M5UnitTest/main/Source/UiScale.h | 2 +- Apps/M5UnitTest/manifest.properties | 4 ++-- Apps/Magic8Ball/main/Source/Magic8Ball.cpp | 4 ++-- Apps/Magic8Ball/manifest.properties | 4 ++-- Apps/MediaKeys/main/Source/MediaKeys.cpp | 8 ++++---- Apps/MediaKeys/manifest.properties | 4 ++-- Apps/MystifyDemo/manifest.properties | 4 ++-- Apps/SerialConsole/main/Source/SerialConsole.cpp | 6 +++--- Apps/SerialConsole/manifest.properties | 4 ++-- Apps/Snake/main/Source/Snake.cpp | 6 +++--- Apps/Snake/manifest.properties | 4 ++-- Apps/TamaTac/main/Source/TamaTac.cpp | 10 +++++----- Apps/TamaTac/manifest.properties | 4 ++-- Apps/TodoList/main/Source/TodoList.cpp | 6 +++--- Apps/TodoList/manifest.properties | 4 ++-- Apps/TwoEleven/main/Source/TwoEleven.cpp | 6 +++--- Apps/TwoEleven/manifest.properties | 4 ++-- 51 files changed, 109 insertions(+), 112 deletions(-) diff --git a/Apps/Brainfuck/main/Source/Brainfuck.cpp b/Apps/Brainfuck/main/Source/Brainfuck.cpp index 3fff0d3..ae1416b 100644 --- a/Apps/Brainfuck/main/Source/Brainfuck.cpp +++ b/Apps/Brainfuck/main/Source/Brainfuck.cpp @@ -1,6 +1,6 @@ #include "Brainfuck.h" #include -#include +#include #include #include #include @@ -399,11 +399,11 @@ void Brainfuck::onShow(AppHandle app, lv_obj_t* parent) { lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app); + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Brainfuck interpreter"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); - clrBtn = tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_TRASH, onClearClicked, nullptr); + clrBtn = lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_TRASH, onClearClicked, nullptr); lv_obj_add_flag(clrBtn, LV_OBJ_FLAG_HIDDEN); - tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onExamplesClicked, nullptr); + lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onExamplesClicked, nullptr); lv_obj_t* cont = lv_obj_create(parent); lv_obj_set_width(cont, LV_PCT(100)); diff --git a/Apps/Brainfuck/manifest.properties b/Apps/Brainfuck/manifest.properties index 2ebec5e..7b2cd74 100644 --- a/Apps/Brainfuck/manifest.properties +++ b/Apps/Brainfuck/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.brainfuck -app.version.name=0.5.0 -app.version.code=5 +app.version.name=0.6.0 +app.version.code=6 app.name=Brainfuck interpreter app.description=Brainfuck esoteric language interpreter diff --git a/Apps/Breakout/main/Source/Breakout.cpp b/Apps/Breakout/main/Source/Breakout.cpp index 5bdd8ac..ef6242e 100644 --- a/Apps/Breakout/main/Source/Breakout.cpp +++ b/Apps/Breakout/main/Source/Breakout.cpp @@ -7,14 +7,14 @@ #include #include -#include +#include #include #include #include #include #include -#include +#include constexpr auto* TAG = "Breakout"; @@ -129,7 +129,7 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) { } // Toolbar - lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle); + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Breakout"); // Score wrapper in toolbar lv_obj_t* scoreWrap = lv_obj_create(toolbar); diff --git a/Apps/Breakout/manifest.properties b/Apps/Breakout/manifest.properties index c20e64e..8fc27d4 100644 --- a/Apps/Breakout/manifest.properties +++ b/Apps/Breakout/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.breakout -app.version.name=0.6.0 -app.version.code=6 +app.version.name=0.7.0 +app.version.code=7 app.name=Breakout app.description=Classic brick-breaking arcade game diff --git a/Apps/Calculator/main/Source/Calculator.cpp b/Apps/Calculator/main/Source/Calculator.cpp index 956407a..3a3e081 100644 --- a/Apps/Calculator/main/Source/Calculator.cpp +++ b/Apps/Calculator/main/Source/Calculator.cpp @@ -2,7 +2,7 @@ #include #include -#include +#include #include #include @@ -148,7 +148,7 @@ void Calculator::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle); + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Calculator"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_t* wrapper = lv_obj_create(parent); diff --git a/Apps/Calculator/manifest.properties b/Apps/Calculator/manifest.properties index 4745112..56bcdc3 100644 --- a/Apps/Calculator/manifest.properties +++ b/Apps/Calculator/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.calculator -app.version.name=0.6.0 -app.version.code=6 +app.version.name=0.7.0 +app.version.code=7 app.name=Calculator diff --git a/Apps/Diceware/main/Source/Diceware.cpp b/Apps/Diceware/main/Source/Diceware.cpp index f3ba674..c455c00 100644 --- a/Apps/Diceware/main/Source/Diceware.cpp +++ b/Apps/Diceware/main/Source/Diceware.cpp @@ -3,7 +3,7 @@ #include #include #include -#include +#include #include #include @@ -122,8 +122,8 @@ void Diceware::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - auto* toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle); - tt_lvgl_toolbar_add_text_button_action(toolbar, "?", onHelpClicked, nullptr); + auto* toolbar = lvgl_toolbar_create(parent, "Diceware"); + lvgl_toolbar_add_text_button_action(toolbar, "?", onHelpClicked, nullptr); auto* wrapper = lv_obj_create(parent); lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); diff --git a/Apps/Diceware/manifest.properties b/Apps/Diceware/manifest.properties index 8ddd873..90ac937 100644 --- a/Apps/Diceware/manifest.properties +++ b/Apps/Diceware/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.diceware -app.version.name=0.7.0 -app.version.code=7 +app.version.name=0.8.0 +app.version.code=8 app.name=Diceware diff --git a/Apps/EpubReader/main/Source/EpubReader.cpp b/Apps/EpubReader/main/Source/EpubReader.cpp index 3adb76f..3551a34 100644 --- a/Apps/EpubReader/main/Source/EpubReader.cpp +++ b/Apps/EpubReader/main/Source/EpubReader.cpp @@ -1,7 +1,7 @@ #include "EpubReader.h" #include "HtmlStrip.h" // stripHtmlToText #include -#include +#include #include #include #include @@ -239,7 +239,7 @@ void EpubReader::onShow(AppHandle app, lv_obj_t* parent) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); - toolbar_ = tt_lvgl_toolbar_create_for_app(parent, app); + toolbar_ = lvgl_toolbar_create(parent, "Epub Reader"); wrapperWidget_ = lv_obj_create(parent); lv_obj_set_width(wrapperWidget_, LV_PCT(100)); diff --git a/Apps/EpubReader/main/Source/EpubReaderAsync.cpp b/Apps/EpubReader/main/Source/EpubReaderAsync.cpp index 655d4fb..d3fddfc 100644 --- a/Apps/EpubReader/main/Source/EpubReaderAsync.cpp +++ b/Apps/EpubReader/main/Source/EpubReaderAsync.cpp @@ -1,5 +1,5 @@ #include "EpubReader.h" -#include +#include #include #include #include @@ -43,7 +43,7 @@ void EpubReader::spawnOpenTask(EpubReader* self, bool restore) { // Show a brief placeholder so old content doesn't linger during the open lv_obj_clean(self->wrapperWidget_); - tt_lvgl_toolbar_clear_actions(self->toolbar_); + lvgl_toolbar_clear_actions(self->toolbar_); lv_obj_t* lbl = lv_label_create(self->wrapperWidget_); lv_obj_set_style_pad_all(lbl, 8, 0); lv_label_set_text(lbl, restore ? "Loading..." : "Opening..."); diff --git a/Apps/EpubReader/main/Source/EpubReaderUI.cpp b/Apps/EpubReader/main/Source/EpubReaderUI.cpp index 755f82f..485b8d9 100644 --- a/Apps/EpubReader/main/Source/EpubReaderUI.cpp +++ b/Apps/EpubReader/main/Source/EpubReaderUI.cpp @@ -1,5 +1,5 @@ #include "EpubReader.h" -#include +#include #include #include #include @@ -37,20 +37,20 @@ static void setListBtnLongMode(lv_obj_t* btn, lv_label_long_mode_t mode) { // --------------------------------------------------------------------------- void EpubReader::setReaderToolbarButtons() { - tt_lvgl_toolbar_clear_actions(toolbar_); - tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_PREV, onPrevPressed, this); + lvgl_toolbar_clear_actions(toolbar_); + lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_PREV, onPrevPressed, this); if (!textMode_) { - tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_LIST, onTocPressed, this); + lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_LIST, onTocPressed, this); } - tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_NEXT, onNextPressed, this); - tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_DIRECTORY, onBrowsePressed, this); + lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_NEXT, onNextPressed, this); + lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_DIRECTORY, onBrowsePressed, this); } void EpubReader::setBrowserToolbarButtons() { - tt_lvgl_toolbar_clear_actions(toolbar_); + lvgl_toolbar_clear_actions(toolbar_); // Show "Use Folder" button when the current browse path isn't already the saved books folder if (browsePath_ != booksPath_) { - tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_DIRECTORY, onSetBooksFolder, this); + lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_DIRECTORY, onSetBooksFolder, this); } } diff --git a/Apps/EpubReader/manifest.properties b/Apps/EpubReader/manifest.properties index 66d82b1..e546dc8 100644 --- a/Apps/EpubReader/manifest.properties +++ b/Apps/EpubReader/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32s3,esp32p4 app.id=one.tactility.epubreader -app.version.name=0.4.0 -app.version.code=4 +app.version.name=0.5.0 +app.version.code=5 app.name=Epub Reader app.description=Epub and text file reader. Requires PSRAM! diff --git a/Apps/EspNowBridge/main/Source/EspNowBridge.cpp b/Apps/EspNowBridge/main/Source/EspNowBridge.cpp index d8120cf..3f979e7 100644 --- a/Apps/EspNowBridge/main/Source/EspNowBridge.cpp +++ b/Apps/EspNowBridge/main/Source/EspNowBridge.cpp @@ -9,7 +9,7 @@ #include #include #include -#include +#include #include #include @@ -655,7 +655,7 @@ void EspNowBridge::onShow(AppHandle app, lv_obj_t* parent) { lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app); + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "ESP-NOW Bridge"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); auto* wrapper = lv_obj_create(parent); diff --git a/Apps/EspNowBridge/manifest.properties b/Apps/EspNowBridge/manifest.properties index 2c1e956..6f5379d 100644 --- a/Apps/EspNowBridge/manifest.properties +++ b/Apps/EspNowBridge/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32p4 app.id=one.tactility.espnowbridge -app.version.name=0.2.0 -app.version.code=2 +app.version.name=0.3.0 +app.version.code=3 app.name=ESP-NOW Bridge app.description=Companion app for updating P4 device C6 co-processor firmware to enable ESP-NOW bridge support. diff --git a/Apps/GPIO/main/Source/Gpio.cpp b/Apps/GPIO/main/Source/Gpio.cpp index a41aa9e..d5ee764 100644 --- a/Apps/GPIO/main/Source/Gpio.cpp +++ b/Apps/GPIO/main/Source/Gpio.cpp @@ -2,7 +2,7 @@ #include -#include +#include #include @@ -78,7 +78,7 @@ void Gpio::onShow(AppHandle app, lv_obj_t* parent) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - auto* toolbar = tt_lvgl_toolbar_create_for_app(parent, app); + auto* toolbar = lvgl_toolbar_create(parent, "GPIO"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); // Main content wrapper, enables scrolling content without scrolling the toolbar diff --git a/Apps/GPIO/manifest.properties b/Apps/GPIO/manifest.properties index cbe5dce..0c06976 100644 --- a/Apps/GPIO/manifest.properties +++ b/Apps/GPIO/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.gpio -app.version.name=0.8.0 -app.version.code=8 +app.version.name=0.9.0 +app.version.code=9 app.name=GPIO diff --git a/Apps/GraphicsDemo/manifest.properties b/Apps/GraphicsDemo/manifest.properties index cc3c7fd..ffccd94 100644 --- a/Apps/GraphicsDemo/manifest.properties +++ b/Apps/GraphicsDemo/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.graphicsdemo -app.version.name=0.6.0 -app.version.code=6 +app.version.name=0.7.0 +app.version.code=7 app.name=Graphics Demo diff --git a/Apps/HelloWorld/main/Source/main.c b/Apps/HelloWorld/main/Source/main.c index c1a5742..ade6222 100644 --- a/Apps/HelloWorld/main/Source/main.c +++ b/Apps/HelloWorld/main/Source/main.c @@ -1,12 +1,12 @@ #include -#include +#include /** * Note: LVGL and Tactility methods need to be exposed manually from TactilityC/Source/tt_init.cpp * Only C is supported for now (C++ symbols fail to link) */ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { - lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app); + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Hello World"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_t* label = lv_label_create(parent); diff --git a/Apps/HelloWorld/manifest.properties b/Apps/HelloWorld/manifest.properties index 4f4e73c..97b5b3b 100644 --- a/Apps/HelloWorld/manifest.properties +++ b/Apps/HelloWorld/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.helloworld -app.version.name=0.6.0 -app.version.code=6 +app.version.name=0.7.0 +app.version.code=7 app.name=Hello World diff --git a/Apps/M5UnitTest/main/Source/M5UnitTest.cpp b/Apps/M5UnitTest/main/Source/M5UnitTest.cpp index 7d6e57c..1165756 100644 --- a/Apps/M5UnitTest/main/Source/M5UnitTest.cpp +++ b/Apps/M5UnitTest/main/Source/M5UnitTest.cpp @@ -14,7 +14,6 @@ #include "TestUnitLcdGfx.h" #include -#include #include constexpr auto* TAG = "M5UnitTest"; diff --git a/Apps/M5UnitTest/main/Source/TestListView.cpp b/Apps/M5UnitTest/main/Source/TestListView.cpp index 2eb3a05..8ba5ddc 100644 --- a/Apps/M5UnitTest/main/Source/TestListView.cpp +++ b/Apps/M5UnitTest/main/Source/TestListView.cpp @@ -1,13 +1,13 @@ #include "TestListView.h" #include "M5UnitTest.h" #include "UiScale.h" -#include -#include +#include +#include void TestListView::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { app_ = app; - tt_lvgl_toolbar_create_for_app(parent, handle); + lvgl_toolbar_create(parent, "M5 Unit Test"); list_ = lv_list_create(parent); lv_obj_set_width(list_, LV_PCT(100)); diff --git a/Apps/M5UnitTest/main/Source/TestListView.h b/Apps/M5UnitTest/main/Source/TestListView.h index 54bf940..5f3b32c 100644 --- a/Apps/M5UnitTest/main/Source/TestListView.h +++ b/Apps/M5UnitTest/main/Source/TestListView.h @@ -2,7 +2,7 @@ #include #include -#include +#include #include class M5UnitTest; diff --git a/Apps/M5UnitTest/main/Source/TestUnit8Encoder.cpp b/Apps/M5UnitTest/main/Source/TestUnit8Encoder.cpp index 8ca0b4c..04e36a0 100644 --- a/Apps/M5UnitTest/main/Source/TestUnit8Encoder.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnit8Encoder.cpp @@ -2,7 +2,7 @@ #include "GroveLookup.h" #include "UiScale.h" #include -#include +#include #include diff --git a/Apps/M5UnitTest/main/Source/TestUnitByteButton.cpp b/Apps/M5UnitTest/main/Source/TestUnitByteButton.cpp index fcef8be..301bc52 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitByteButton.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitByteButton.cpp @@ -2,7 +2,7 @@ #include "GroveLookup.h" #include "UiScale.h" #include -#include +#include #include void TestUnitByteButton::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { diff --git a/Apps/M5UnitTest/main/Source/TestUnitCardKB2.cpp b/Apps/M5UnitTest/main/Source/TestUnitCardKB2.cpp index 7f4a97c..132519a 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitCardKB2.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitCardKB2.cpp @@ -3,7 +3,7 @@ #include "UiScale.h" #include #include -#include +#include #include // --------------------------------------------------------------------------- diff --git a/Apps/M5UnitTest/main/Source/TestUnitDualButton.cpp b/Apps/M5UnitTest/main/Source/TestUnitDualButton.cpp index fed447d..64a32e5 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitDualButton.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitDualButton.cpp @@ -1,7 +1,7 @@ #include "TestUnitDualButton.h" #include "UiScale.h" #include -#include +#include static constexpr gpio_pin_t PIN_MIN = 0; static constexpr gpio_pin_t PIN_MAX = 57; diff --git a/Apps/M5UnitTest/main/Source/TestUnitJoystick2.cpp b/Apps/M5UnitTest/main/Source/TestUnitJoystick2.cpp index 2c62596..4a6c7b7 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitJoystick2.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitJoystick2.cpp @@ -2,7 +2,7 @@ #include "GroveLookup.h" #include "UiScale.h" #include -#include +#include #include #include diff --git a/Apps/M5UnitTest/main/Source/TestUnitLcd.cpp b/Apps/M5UnitTest/main/Source/TestUnitLcd.cpp index 393ce86..b4a9c25 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitLcd.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitLcd.cpp @@ -2,7 +2,7 @@ #include "GroveLookup.h" #include "UiScale.h" #include -#include +#include void TestUnitLcd::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { app_ = app; diff --git a/Apps/M5UnitTest/main/Source/TestUnitLcdGfx.cpp b/Apps/M5UnitTest/main/Source/TestUnitLcdGfx.cpp index c288f3e..5088753 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitLcdGfx.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitLcdGfx.cpp @@ -2,7 +2,7 @@ #include "GroveLookup.h" #include "UiScale.h" #include -#include +#include #include #include #include diff --git a/Apps/M5UnitTest/main/Source/TestUnitMidi.cpp b/Apps/M5UnitTest/main/Source/TestUnitMidi.cpp index 3dc0e34..31ef15d 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitMidi.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitMidi.cpp @@ -2,7 +2,7 @@ #include "GroveLookup.h" #include "UiScale.h" #include -#include +#include void TestUnitMidi::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { app_ = app; diff --git a/Apps/M5UnitTest/main/Source/TestUnitPaHub.cpp b/Apps/M5UnitTest/main/Source/TestUnitPaHub.cpp index f13162d..805a46f 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitPaHub.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitPaHub.cpp @@ -3,7 +3,7 @@ #include "UiScale.h" #include #include -#include +#include void TestUnitPaHub::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { app_ = app; diff --git a/Apps/M5UnitTest/main/Source/TestUnitRfid2.cpp b/Apps/M5UnitTest/main/Source/TestUnitRfid2.cpp index 617b092..bc2b309 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitRfid2.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitRfid2.cpp @@ -2,8 +2,7 @@ #include "GroveLookup.h" #include "UiScale.h" #include -#include -#include +#include #include #include #include diff --git a/Apps/M5UnitTest/main/Source/TestUnitScroll.cpp b/Apps/M5UnitTest/main/Source/TestUnitScroll.cpp index a5596ca..57a0977 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitScroll.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitScroll.cpp @@ -2,7 +2,7 @@ #include "GroveLookup.h" #include "UiScale.h" #include -#include +#include void TestUnitScroll::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { app_ = app; diff --git a/Apps/M5UnitTest/main/Source/TestViewBase.cpp b/Apps/M5UnitTest/main/Source/TestViewBase.cpp index e3ea71c..46e30ce 100644 --- a/Apps/M5UnitTest/main/Source/TestViewBase.cpp +++ b/Apps/M5UnitTest/main/Source/TestViewBase.cpp @@ -1,13 +1,12 @@ #include "TestViewBase.h" #include "M5UnitTest.h" #include "UiScale.h" -#include -#include +#include +#include lv_obj_t* TestViewBase::createToolbar(lv_obj_t* parent, AppHandle handle, const char* title) { - lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, handle); - tt_lvgl_toolbar_set_title(toolbar, title); - tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LEFT, onBackClicked, this); + lv_obj_t* toolbar = lvgl_toolbar_create(parent, title); + lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LEFT, onBackClicked, this); return toolbar; } diff --git a/Apps/M5UnitTest/main/Source/UiScale.h b/Apps/M5UnitTest/main/Source/UiScale.h index 9bbb4e0..a35ae13 100644 --- a/Apps/M5UnitTest/main/Source/UiScale.h +++ b/Apps/M5UnitTest/main/Source/UiScale.h @@ -1,6 +1,6 @@ #pragma once #include -#include +#include // Device screen widths in default (portrait) orientation: // tiny < 200 : small OLEDs, custom breadboard devices diff --git a/Apps/M5UnitTest/manifest.properties b/Apps/M5UnitTest/manifest.properties index 5c747be..03b6951 100644 --- a/Apps/M5UnitTest/manifest.properties +++ b/Apps/M5UnitTest/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32s3,esp32p4 app.id=one.tactility.m5unittest -app.version.name=0.4.0 -app.version.code=4 +app.version.name=0.5.0 +app.version.code=5 app.name=M5 Unit Test diff --git a/Apps/Magic8Ball/main/Source/Magic8Ball.cpp b/Apps/Magic8Ball/main/Source/Magic8Ball.cpp index f3c9c49..940b67c 100644 --- a/Apps/Magic8Ball/main/Source/Magic8Ball.cpp +++ b/Apps/Magic8Ball/main/Source/Magic8Ball.cpp @@ -1,5 +1,5 @@ #include "Magic8Ball.h" -#include +#include #include #include #include @@ -93,7 +93,7 @@ void Magic8Ball::onShow(AppHandle app, lv_obj_t* parent) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); /* Toolbar */ - lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app); + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Magic 8-Ball"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); /* Main container */ diff --git a/Apps/Magic8Ball/manifest.properties b/Apps/Magic8Ball/manifest.properties index 6c7d091..ced441d 100644 --- a/Apps/Magic8Ball/manifest.properties +++ b/Apps/Magic8Ball/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.magic8ball -app.version.name=0.5.0 -app.version.code=5 +app.version.name=0.6.0 +app.version.code=6 app.name=Magic 8-Ball diff --git a/Apps/MediaKeys/main/Source/MediaKeys.cpp b/Apps/MediaKeys/main/Source/MediaKeys.cpp index 6fca94d..e55a7bb 100644 --- a/Apps/MediaKeys/main/Source/MediaKeys.cpp +++ b/Apps/MediaKeys/main/Source/MediaKeys.cpp @@ -4,9 +4,9 @@ #include #include #include -#include +#include #include -#include +#include static const char* TAG = "MediaKeys"; @@ -307,10 +307,10 @@ void MediaKeys::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle); + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Media Keys"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); - _switchWidget = tt_lvgl_toolbar_add_switch_action(toolbar); + _switchWidget = lvgl_toolbar_add_switch_action(toolbar); lv_obj_add_event_cb(_switchWidget, onSwitchToggled, LV_EVENT_VALUE_CHANGED, this); _mainWrapper = lv_obj_create(parent); diff --git a/Apps/MediaKeys/manifest.properties b/Apps/MediaKeys/manifest.properties index 3a36717..2c02be9 100644 --- a/Apps/MediaKeys/manifest.properties +++ b/Apps/MediaKeys/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32s3,esp32p4 app.id=one.tactility.mediakeys -app.version.name=0.5.0 -app.version.code=5 +app.version.name=0.6.0 +app.version.code=6 app.name=Media Keys app.description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus. diff --git a/Apps/MystifyDemo/manifest.properties b/Apps/MystifyDemo/manifest.properties index ffc5e8d..80f6484 100644 --- a/Apps/MystifyDemo/manifest.properties +++ b/Apps/MystifyDemo/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.mystifydemo -app.version.name=0.7.0 -app.version.code=7 +app.version.name=0.8.0 +app.version.code=8 app.name=Mystify Demo diff --git a/Apps/SerialConsole/main/Source/SerialConsole.cpp b/Apps/SerialConsole/main/Source/SerialConsole.cpp index cd32f5c..8eefbc9 100644 --- a/Apps/SerialConsole/main/Source/SerialConsole.cpp +++ b/Apps/SerialConsole/main/Source/SerialConsole.cpp @@ -1,5 +1,5 @@ #include "SerialConsole.h" -#include +#include constexpr auto* TAG = "SerialMonitor"; @@ -43,9 +43,9 @@ void SerialConsole::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - auto* toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle); + auto* toolbar = lvgl_toolbar_create(parent, "Serial Console"); - disconnectButton = tt_lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_POWER, onDisconnectPressed, this); + disconnectButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_POWER, onDisconnectPressed, this); lv_obj_add_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN); wrapperWidget = lv_obj_create(parent); diff --git a/Apps/SerialConsole/manifest.properties b/Apps/SerialConsole/manifest.properties index 2d439cc..bb1579b 100644 --- a/Apps/SerialConsole/manifest.properties +++ b/Apps/SerialConsole/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.serialconsole -app.version.name=0.8.0 -app.version.code=8 +app.version.name=0.9.0 +app.version.code=9 app.name=Serial Console diff --git a/Apps/Snake/main/Source/Snake.cpp b/Apps/Snake/main/Source/Snake.cpp index 619b4e1..22fdcec 100644 --- a/Apps/Snake/main/Source/Snake.cpp +++ b/Apps/Snake/main/Source/Snake.cpp @@ -5,7 +5,7 @@ #include "Snake.h" #include -#include +#include #include #include #include @@ -13,7 +13,7 @@ #include #include -#include +#include constexpr auto* TAG = "Snake"; @@ -245,7 +245,7 @@ void Snake::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); // Create toolbar - toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle); + toolbar = lvgl_toolbar_create(parent, "Snake"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); // Create main wrapper diff --git a/Apps/Snake/manifest.properties b/Apps/Snake/manifest.properties index c79d87b..4df898c 100644 --- a/Apps/Snake/manifest.properties +++ b/Apps/Snake/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.snake -app.version.name=0.9.0 -app.version.code=9 +app.version.name=0.10.0 +app.version.code=10 app.name=Snake app.description=Classic Snake game diff --git a/Apps/TamaTac/main/Source/TamaTac.cpp b/Apps/TamaTac/main/Source/TamaTac.cpp index b60a703..1a8a57a 100644 --- a/Apps/TamaTac/main/Source/TamaTac.cpp +++ b/Apps/TamaTac/main/Source/TamaTac.cpp @@ -5,7 +5,7 @@ #include "TamaTac.h" #include "SpriteData.h" -#include +#include #include #include #include @@ -86,11 +86,11 @@ void TamaTac::onShow(AppHandle context, lv_obj_t* parent) { lv_obj_set_style_pad_all(parent, 0, 0); lv_obj_set_style_pad_row(parent, 0, 0); - toolbar = tt_lvgl_toolbar_create_for_app(parent, context); + toolbar = lvgl_toolbar_create(parent, "TamaTac"); - menuButton = tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onMenuClicked, this); - tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_TRASH, onCleanClicked, this); - tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_REFRESH, onResetClicked, this); + menuButton = lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onMenuClicked, this); + lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_TRASH, onCleanClicked, this); + lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_REFRESH, onResetClicked, this); wrapperWidget = lv_obj_create(parent); lv_obj_set_width(wrapperWidget, LV_PCT(100)); diff --git a/Apps/TamaTac/manifest.properties b/Apps/TamaTac/manifest.properties index 17c2ada..d66c1f4 100644 --- a/Apps/TamaTac/manifest.properties +++ b/Apps/TamaTac/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.tamatac -app.version.name=0.4.0 -app.version.code=4 +app.version.name=0.5.0 +app.version.code=5 app.name=TamaTac app.description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM. diff --git a/Apps/TodoList/main/Source/TodoList.cpp b/Apps/TodoList/main/Source/TodoList.cpp index cd4d8bd..b08ddb4 100644 --- a/Apps/TodoList/main/Source/TodoList.cpp +++ b/Apps/TodoList/main/Source/TodoList.cpp @@ -2,9 +2,9 @@ #include #include #include -#include +#include #include -#include +#include #include #include #include @@ -272,7 +272,7 @@ void TodoList::onShow(AppHandle app, lv_obj_t* parent) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); /* Toolbar */ - lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app); + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Todo List"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_t* countWrapper = lv_obj_create(toolbar); diff --git a/Apps/TodoList/manifest.properties b/Apps/TodoList/manifest.properties index e48ee7f..fa45ee9 100644 --- a/Apps/TodoList/manifest.properties +++ b/Apps/TodoList/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.todolist -app.version.name=0.6.0 -app.version.code=6 +app.version.name=0.7.0 +app.version.code=7 app.name=Todo List app.description=Simple task list manager diff --git a/Apps/TwoEleven/main/Source/TwoEleven.cpp b/Apps/TwoEleven/main/Source/TwoEleven.cpp index 4874b8a..932d90d 100644 --- a/Apps/TwoEleven/main/Source/TwoEleven.cpp +++ b/Apps/TwoEleven/main/Source/TwoEleven.cpp @@ -5,12 +5,12 @@ #include "TwoEleven.h" #include -#include +#include #include #include #include #include -#include +#include #include constexpr auto* TAG = "TwoEleven"; @@ -257,7 +257,7 @@ void TwoEleven::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); // Create toolbar - toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle); + toolbar = lvgl_toolbar_create(parent, "2048"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); // Create main wrapper diff --git a/Apps/TwoEleven/manifest.properties b/Apps/TwoEleven/manifest.properties index 110c25c..279875c 100644 --- a/Apps/TwoEleven/manifest.properties +++ b/Apps/TwoEleven/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.twoeleven -app.version.name=0.8.0 -app.version.code=8 +app.version.name=0.9.0 +app.version.code=9 app.name=2048 app.description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert). From 227558848e3be55cae9829c70c7bab9fa18fe68a Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 9 Aug 2026 17:47:24 +0200 Subject: [PATCH 4/7] First batch of migrated apps --- Apps/Brainfuck/CMakeLists.txt | 12 +- Apps/Brainfuck/main/CMakeLists.txt | 3 - Apps/Brainfuck/main/Source/Brainfuck.cpp | 266 +-- Apps/Brainfuck/main/Source/Brainfuck.h | 44 +- Apps/Brainfuck/main/Source/main.cpp | 40 +- Apps/Breakout/CMakeLists.txt | 10 +- Apps/Breakout/main/CMakeLists.txt | 2 +- Apps/Breakout/main/Source/Breakout.cpp | 1421 +++++++++-------- Apps/Breakout/main/Source/Breakout.h | 69 +- Apps/Breakout/main/Source/main.cpp | 40 +- Apps/Calculator/CMakeLists.txt | 10 +- Apps/Calculator/main/CMakeLists.txt | 3 - Apps/Calculator/main/Source/Calculator.cpp | 134 +- Apps/Calculator/main/Source/Calculator.h | 28 +- Apps/Calculator/main/Source/main.cpp | 34 +- Apps/Diceware/CMakeLists.txt | 10 +- Apps/Diceware/main/CMakeLists.txt | 3 - Apps/Diceware/main/Source/Diceware.cpp | 127 +- Apps/Diceware/main/Source/Diceware.h | 32 +- Apps/Diceware/main/Source/main.cpp | 45 +- Apps/EpubReader/CMakeLists.txt | 10 +- Apps/EpubReader/main/CMakeLists.txt | 3 - Apps/EpubReader/main/Source/EpubReader.cpp | 279 ++-- Apps/EpubReader/main/Source/EpubReader.h | 162 +- .../main/Source/EpubReaderAsync.cpp | 291 ++-- Apps/EpubReader/main/Source/EpubReaderUI.cpp | 171 +- Apps/EpubReader/main/Source/main.cpp | 75 +- Apps/EspNowBridge/CMakeLists.txt | 10 +- Apps/EspNowBridge/main/CMakeLists.txt | 3 - .../EspNowBridge/main/Source/EspNowBridge.cpp | 489 +++--- Apps/EspNowBridge/main/Source/EspNowBridge.h | 131 +- Apps/EspNowBridge/main/Source/main.cpp | 62 +- Apps/GPIO/CMakeLists.txt | 10 +- Apps/GPIO/main/CMakeLists.txt | 3 - Apps/GPIO/main/Source/Gpio.cpp | 85 +- Apps/GPIO/main/Source/Gpio.h | 39 +- Apps/GPIO/main/Source/main.cpp | 37 +- Apps/GraphicsDemo/CMakeLists.txt | 10 +- Apps/GraphicsDemo/main/CMakeLists.txt | 4 +- .../main/Include/drivers/DisplayDriver.h | 2 +- Apps/GraphicsDemo/main/Source/Main.cpp | 66 +- Apps/HelloWorld/CMakeLists.txt | 10 +- Apps/HelloWorld/main/Source/main.c | 41 +- Apps/M5UnitTest/CMakeLists.txt | 10 +- Apps/M5UnitTest/main/CMakeLists.txt | 2 +- Apps/M5UnitTest/main/Source/M5UnitTest.cpp | 166 +- Apps/M5UnitTest/main/Source/M5UnitTest.h | 50 +- Apps/M5UnitTest/main/Source/TestListView.cpp | 74 +- Apps/M5UnitTest/main/Source/TestListView.h | 48 +- .../main/Source/TestUnit8Encoder.cpp | 173 +- .../M5UnitTest/main/Source/TestUnit8Encoder.h | 15 +- .../main/Source/TestUnitByteButton.cpp | 116 +- .../main/Source/TestUnitByteButton.h | 15 +- .../main/Source/TestUnitCardKB2.cpp | 296 ++-- Apps/M5UnitTest/main/Source/TestUnitCardKB2.h | 40 +- .../main/Source/TestUnitDualButton.cpp | 305 ++-- .../main/Source/TestUnitDualButton.h | 22 +- .../main/Source/TestUnitJoystick2.cpp | 190 +-- .../main/Source/TestUnitJoystick2.h | 15 +- Apps/M5UnitTest/main/Source/TestUnitLcd.cpp | 211 +-- Apps/M5UnitTest/main/Source/TestUnitLcd.h | 19 +- .../M5UnitTest/main/Source/TestUnitLcdGfx.cpp | 589 +++---- Apps/M5UnitTest/main/Source/TestUnitLcdGfx.h | 41 +- Apps/M5UnitTest/main/Source/TestUnitMidi.cpp | 179 ++- Apps/M5UnitTest/main/Source/TestUnitMidi.h | 19 +- Apps/M5UnitTest/main/Source/TestUnitPaHub.cpp | 151 +- Apps/M5UnitTest/main/Source/TestUnitPaHub.h | 15 +- Apps/M5UnitTest/main/Source/TestUnitRfid2.cpp | 282 ++-- Apps/M5UnitTest/main/Source/TestUnitRfid2.h | 19 +- .../M5UnitTest/main/Source/TestUnitScroll.cpp | 140 +- Apps/M5UnitTest/main/Source/TestUnitScroll.h | 17 +- Apps/M5UnitTest/main/Source/TestViewBase.cpp | 24 +- Apps/M5UnitTest/main/Source/TestViewBase.h | 39 +- Apps/M5UnitTest/main/Source/main.cpp | 36 +- tactility.py | 8 +- 75 files changed, 3985 insertions(+), 3667 deletions(-) diff --git a/Apps/Brainfuck/CMakeLists.txt b/Apps/Brainfuck/CMakeLists.txt index 7622851..79c2d14 100644 --- a/Apps/Brainfuck/CMakeLists.txt +++ b/Apps/Brainfuck/CMakeLists.txt @@ -6,11 +6,19 @@ if (DEFINED ENV{TACTILITY_SDK_PATH}) set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH}) else() set(TACTILITY_SDK_PATH "../../release/TactilitySDK") - message(WARNING "TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}") + message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}") endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(Brainfuck) tactility_project(Brainfuck) diff --git a/Apps/Brainfuck/main/CMakeLists.txt b/Apps/Brainfuck/main/CMakeLists.txt index 0309010..3d04446 100644 --- a/Apps/Brainfuck/main/CMakeLists.txt +++ b/Apps/Brainfuck/main/CMakeLists.txt @@ -4,8 +4,5 @@ file(GLOB_RECURSE SOURCE_FILES idf_component_register( SRCS ${SOURCE_FILES} - # Library headers must be included directly, - # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include REQUIRES TactilitySDK ) diff --git a/Apps/Brainfuck/main/Source/Brainfuck.cpp b/Apps/Brainfuck/main/Source/Brainfuck.cpp index ae1416b..9b2075f 100644 --- a/Apps/Brainfuck/main/Source/Brainfuck.cpp +++ b/Apps/Brainfuck/main/Source/Brainfuck.cpp @@ -1,12 +1,17 @@ #include "Brainfuck.h" -#include + +#include #include + #include #include #include #include #include +/** Must match manifest.properties' app.id */ +static constexpr const char* APP_ID = "one.tactility.brainfuck"; + /* ── Built-in examples ────────────────────────────────────────────── */ struct BfExample { @@ -67,15 +72,14 @@ static const BfExample examples[] = { static constexpr int NUM_EXAMPLES = sizeof(examples) / sizeof(examples[0]); -/* ── App handle for user data path ────────────────────────────────── */ - -static AppHandle s_appHandle = nullptr; +/** File-scope pointer to the current window's Context, for callbacks that only carry an + * index/path via lv_event's user_data (single-window app, same limitation as before). */ +static Context* g_ctx = nullptr; static bool getScriptDir(char* buf, size_t bufSize) { - if (!s_appHandle) return false; - size_t size = bufSize; - tt_app_get_user_data_path(s_appHandle, buf, &size); - if (size == 0) return false; + if (app_paths_get_user_data_directory(APP_ID, buf, bufSize) != ERROR_NONE) { + return false; + } for (char* p = buf + 1; *p; ++p) { if (*p == '/') { *p = '\0'; mkdir(buf, 0755); *p = '/'; } } @@ -83,9 +87,6 @@ static bool getScriptDir(char* buf, size_t bufSize) { return true; } -static char** scriptPaths = nullptr; -static int scriptCount = 0; - static int ciStrcmp(const char* a, const char* b) { while (*a && *b) { int ca = (*a >= 'A' && *a <= 'Z') ? *a + 32 : *a; @@ -96,21 +97,18 @@ static int ciStrcmp(const char* a, const char* b) { return (unsigned char)*a - (unsigned char)*b; } -static void freeScriptPaths() { - for (int i = 0; i < scriptCount; i++) { - free(scriptPaths[i]); +static void freeScriptPaths(Context* ctx) { + for (int i = 0; i < ctx->scriptCount; i++) { + free(ctx->scriptPaths[i]); } - free(scriptPaths); - scriptPaths = nullptr; - scriptCount = 0; + free(ctx->scriptPaths); + ctx->scriptPaths = nullptr; + ctx->scriptCount = 0; } -/* File-scope instance pointer for index-based and path-based callbacks */ -static Brainfuck* g_instance = nullptr; - /* ── VM Logic ─────────────────────────────────────────────────────── */ -void Brainfuck::bfInit() { +static void bfInit(BfVM& vm) { memset(&vm, 0, sizeof(BfVM)); } @@ -125,7 +123,7 @@ static int bfFindBracket(const char* code, int pos, int dir) { return pos; } -void Brainfuck::bfRun(const char* code) { +static void bfRun(BfVM& vm, const char* code) { int len = strlen(code); while (vm.pc < len && !vm.error) { @@ -190,15 +188,15 @@ void Brainfuck::bfRun(const char* code) { } } -void Brainfuck::runCode(const char* code) { - if (!outputTa) return; - bfInit(); - bfRun(code); +static void runCode(Context* ctx, const char* code) { + if (!ctx->outputTa) return; + bfInit(ctx->vm); + bfRun(ctx->vm, code); constexpr int resultSize = MAX_OUTPUT + 128; char* result = (char*)malloc(resultSize); if (!result) { - lv_textarea_set_text(outputTa, "Out of memory"); + lv_textarea_set_text(ctx->outputTa, "Out of memory"); return; } int pos = 0; @@ -210,93 +208,94 @@ void Brainfuck::runCode(const char* code) { pos += (n < remaining) ? n : (remaining - 1); } - if (vm.outLen > 0) { + if (ctx->vm.outLen > 0) { remaining = resultSize - pos; if (remaining > 0) { - int n = snprintf(result + pos, remaining, "%s\n", vm.output); + int n = snprintf(result + pos, remaining, "%s\n", ctx->vm.output); pos += (n < remaining) ? n : (remaining - 1); } } - if (vm.error) { + if (ctx->vm.error) { remaining = resultSize - pos; if (remaining > 0) { - int n = snprintf(result + pos, remaining, "ERROR: %s\n", vm.errorMsg); + int n = snprintf(result + pos, remaining, "ERROR: %s\n", ctx->vm.errorMsg); pos += (n < remaining) ? n : (remaining - 1); } } else { remaining = resultSize - pos; if (remaining > 0) { - int n = snprintf(result + pos, remaining, "OK (%d cycles)\n", vm.cycles); + int n = snprintf(result + pos, remaining, "OK (%d cycles)\n", ctx->vm.cycles); pos += (n < remaining) ? n : (remaining - 1); } } - lv_textarea_set_text(outputTa, result); - lv_obj_scroll_to_y(outputTa, LV_COORD_MAX, LV_ANIM_ON); + lv_textarea_set_text(ctx->outputTa, result); + lv_obj_scroll_to_y(ctx->outputTa, LV_COORD_MAX, LV_ANIM_ON); free(result); } /* ── View management ──────────────────────────────────────────────── */ -void Brainfuck::showMainView() { - state = BfState::Main; - if (examplesList) lv_obj_add_flag(examplesList, LV_OBJ_FLAG_HIDDEN); - if (outputTa) lv_obj_remove_flag(outputTa, LV_OBJ_FLAG_HIDDEN); - if (inputRow) lv_obj_remove_flag(inputRow, LV_OBJ_FLAG_HIDDEN); - if (clrBtn) lv_obj_remove_flag(clrBtn, LV_OBJ_FLAG_HIDDEN); +static void showMainView(Context* ctx) { + ctx->state = BfState::Main; + if (ctx->examplesList) lv_obj_add_flag(ctx->examplesList, LV_OBJ_FLAG_HIDDEN); + if (ctx->outputTa) lv_obj_remove_flag(ctx->outputTa, LV_OBJ_FLAG_HIDDEN); + if (ctx->inputRow) lv_obj_remove_flag(ctx->inputRow, LV_OBJ_FLAG_HIDDEN); + if (ctx->clrBtn) lv_obj_remove_flag(ctx->clrBtn, LV_OBJ_FLAG_HIDDEN); } -void Brainfuck::showExamplesView() { - state = BfState::Examples; - if (outputTa) lv_obj_add_flag(outputTa, LV_OBJ_FLAG_HIDDEN); - if (inputRow) lv_obj_add_flag(inputRow, LV_OBJ_FLAG_HIDDEN); - if (examplesList) lv_obj_remove_flag(examplesList, LV_OBJ_FLAG_HIDDEN); - if (clrBtn) lv_obj_add_flag(clrBtn, LV_OBJ_FLAG_HIDDEN); +static void showExamplesView(Context* ctx) { + ctx->state = BfState::Examples; + if (ctx->outputTa) lv_obj_add_flag(ctx->outputTa, LV_OBJ_FLAG_HIDDEN); + if (ctx->inputRow) lv_obj_add_flag(ctx->inputRow, LV_OBJ_FLAG_HIDDEN); + if (ctx->examplesList) lv_obj_remove_flag(ctx->examplesList, LV_OBJ_FLAG_HIDDEN); + if (ctx->clrBtn) lv_obj_add_flag(ctx->clrBtn, LV_OBJ_FLAG_HIDDEN); } /* ── Callbacks ────────────────────────────────────────────────────── */ -void Brainfuck::onRunClicked(lv_event_t* e) { - if (!g_instance || !g_instance->inputTa) return; - const char* code = lv_textarea_get_text(g_instance->inputTa); +static void onRunClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (!ctx->inputTa) return; + const char* code = lv_textarea_get_text(ctx->inputTa); if (code && code[0]) { - g_instance->runCode(code); + runCode(ctx, code); } } -void Brainfuck::onClearClicked(lv_event_t* e) { - if (!g_instance) return; - if (g_instance->outputTa) lv_textarea_set_text(g_instance->outputTa, ""); - if (g_instance->inputTa) lv_textarea_set_text(g_instance->inputTa, ""); +static void onClearClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx->outputTa) lv_textarea_set_text(ctx->outputTa, ""); + if (ctx->inputTa) lv_textarea_set_text(ctx->inputTa, ""); } -void Brainfuck::onExamplesClicked(lv_event_t* e) { - if (!g_instance) return; - if (g_instance->state == BfState::Examples) { - g_instance->showMainView(); +static void onExamplesClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx->state == BfState::Examples) { + showMainView(ctx); } else { - g_instance->showExamplesView(); + showExamplesView(ctx); } } -void Brainfuck::onExampleSelected(lv_event_t* e) { - if (!g_instance) return; +static void onExampleSelected(lv_event_t* e) { + if (!g_ctx) return; int idx = (int)(intptr_t)lv_event_get_user_data(e); if (idx >= 0 && idx < NUM_EXAMPLES) { - if (g_instance->inputTa) lv_textarea_set_text(g_instance->inputTa, examples[idx].code); - g_instance->showMainView(); - g_instance->runCode(examples[idx].code); + if (g_ctx->inputTa) lv_textarea_set_text(g_ctx->inputTa, examples[idx].code); + showMainView(g_ctx); + runCode(g_ctx, examples[idx].code); } } -void Brainfuck::onFileSelected(lv_event_t* e) { - if (!g_instance) return; +static void onFileSelected(lv_event_t* e) { + if (!g_ctx) return; const char* path = (const char*)lv_event_get_user_data(e); FILE* f = fopen(path, "rb"); if (!f) { - if (g_instance->outputTa) lv_textarea_set_text(g_instance->outputTa, "Cannot open file"); - g_instance->showMainView(); + if (g_ctx->outputTa) lv_textarea_set_text(g_ctx->outputTa, "Cannot open file"); + showMainView(g_ctx); return; } @@ -304,8 +303,8 @@ void Brainfuck::onFileSelected(lv_event_t* e) { long fsize = ftell(f); if (fsize <= 0 || fsize > 32768) { fclose(f); - if (g_instance->outputTa) lv_textarea_set_text(g_instance->outputTa, "File too large or empty"); - g_instance->showMainView(); + if (g_ctx->outputTa) lv_textarea_set_text(g_ctx->outputTa, "File too large or empty"); + showMainView(g_ctx); return; } fseek(f, 0, SEEK_SET); @@ -313,8 +312,8 @@ void Brainfuck::onFileSelected(lv_event_t* e) { char* buf = (char*)malloc(fsize + 1); if (!buf) { fclose(f); - if (g_instance->outputTa) lv_textarea_set_text(g_instance->outputTa, "Out of memory"); - g_instance->showMainView(); + if (g_ctx->outputTa) lv_textarea_set_text(g_ctx->outputTa, "Out of memory"); + showMainView(g_ctx); return; } @@ -322,20 +321,20 @@ void Brainfuck::onFileSelected(lv_event_t* e) { fclose(f); buf[bytesRead] = '\0'; - if (g_instance->inputTa) lv_textarea_set_text(g_instance->inputTa, buf); - g_instance->showMainView(); - g_instance->runCode(buf); + if (g_ctx->inputTa) lv_textarea_set_text(g_ctx->inputTa, buf); + showMainView(g_ctx); + runCode(g_ctx, buf); free(buf); } -void Brainfuck::onInputReady(lv_event_t* e) { +static void onInputReady(lv_event_t* e) { onRunClicked(e); } /* ── Script list building ─────────────────────────────────────────── */ -void Brainfuck::buildScriptList(lv_obj_t* list) { - freeScriptPaths(); +static void buildScriptList(Context* ctx, lv_obj_t* list) { + freeScriptPaths(ctx); for (int i = 0; i < NUM_EXAMPLES; i++) { lv_obj_t* btn = lv_list_add_button(list, LV_SYMBOL_PLAY, examples[i].name); @@ -373,37 +372,37 @@ void Brainfuck::buildScriptList(lv_obj_t* list) { if (!path) break; snprintf(path, pathLen, "%s/%s", scriptDir, name); - char** tmp = (char**)realloc(scriptPaths, sizeof(char*) * (scriptCount + 1)); + char** tmp = (char**)realloc(ctx->scriptPaths, sizeof(char*) * (ctx->scriptCount + 1)); if (!tmp) { free(path); break; } - scriptPaths = tmp; - scriptPaths[scriptCount] = path; + ctx->scriptPaths = tmp; + ctx->scriptPaths[ctx->scriptCount] = path; lv_obj_t* btn = lv_list_add_button(list, LV_SYMBOL_FILE, name); lv_obj_add_event_cb(btn, onFileSelected, LV_EVENT_CLICKED, path); - scriptCount++; + ctx->scriptCount++; } closedir(dir); - if (scriptCount == 0) { + if (ctx->scriptCount == 0) { lv_list_add_text(list, "No custom scripts found on storage"); } } -/* ── Lifecycle ────────────────────────────────────────────────────── */ +/* ── Widget creation ──────────────────────────────────────────────── */ -void Brainfuck::onShow(AppHandle app, lv_obj_t* parent) { - g_instance = this; - s_appHandle = app; - state = BfState::Examples; +void brainfuckCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + g_ctx = ctx; + ctx->state = BfState::Examples; lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Brainfuck interpreter"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); - clrBtn = lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_TRASH, onClearClicked, nullptr); - lv_obj_add_flag(clrBtn, LV_OBJ_FLAG_HIDDEN); - lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onExamplesClicked, nullptr); + ctx->clrBtn = lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_TRASH, onClearClicked, ctx); + lv_obj_add_flag(ctx->clrBtn, LV_OBJ_FLAG_HIDDEN); + lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onExamplesClicked, ctx); lv_obj_t* cont = lv_obj_create(parent); lv_obj_set_width(cont, LV_PCT(100)); @@ -414,51 +413,52 @@ void Brainfuck::onShow(AppHandle app, lv_obj_t* parent) { lv_obj_remove_flag(cont, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_style_border_width(cont, 0, 0); - outputTa = lv_textarea_create(cont); - lv_textarea_set_text(outputTa, ""); - lv_textarea_set_cursor_click_pos(outputTa, false); - lv_obj_add_state(outputTa, LV_STATE_DISABLED); - lv_obj_set_width(outputTa, LV_PCT(100)); - lv_obj_set_flex_grow(outputTa, 1); - lv_obj_set_style_text_font(outputTa, lv_font_get_default(), 0); - lv_obj_add_flag(outputTa, LV_OBJ_FLAG_HIDDEN); - - inputRow = lv_obj_create(cont); - lv_obj_set_size(inputRow, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(inputRow, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(inputRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_all(inputRow, 0, 0); - lv_obj_set_style_pad_gap(inputRow, 4, 0); - lv_obj_remove_flag(inputRow, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_border_width(inputRow, 0, 0); - lv_obj_add_flag(inputRow, LV_OBJ_FLAG_HIDDEN); - - inputTa = lv_textarea_create(inputRow); - lv_textarea_set_placeholder_text(inputTa, "++++++[>++++++++++<-]>+++++."); - lv_textarea_set_one_line(inputTa, false); - lv_obj_set_flex_grow(inputTa, 1); - lv_obj_set_height(inputTa, 50); - lv_obj_set_style_text_font(inputTa, lv_font_get_default(), 0); - lv_obj_add_event_cb(inputTa, onInputReady, LV_EVENT_READY, nullptr); - - lv_obj_t* runBtn = lv_button_create(inputRow); + ctx->outputTa = lv_textarea_create(cont); + lv_textarea_set_text(ctx->outputTa, ""); + lv_textarea_set_cursor_click_pos(ctx->outputTa, false); + lv_obj_add_state(ctx->outputTa, LV_STATE_DISABLED); + lv_obj_set_width(ctx->outputTa, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->outputTa, 1); + lv_obj_set_style_text_font(ctx->outputTa, lv_font_get_default(), 0); + lv_obj_add_flag(ctx->outputTa, LV_OBJ_FLAG_HIDDEN); + + ctx->inputRow = lv_obj_create(cont); + lv_obj_set_size(ctx->inputRow, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(ctx->inputRow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(ctx->inputRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(ctx->inputRow, 0, 0); + lv_obj_set_style_pad_gap(ctx->inputRow, 4, 0); + lv_obj_remove_flag(ctx->inputRow, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(ctx->inputRow, 0, 0); + lv_obj_add_flag(ctx->inputRow, LV_OBJ_FLAG_HIDDEN); + + ctx->inputTa = lv_textarea_create(ctx->inputRow); + lv_textarea_set_placeholder_text(ctx->inputTa, "++++++[>++++++++++<-]>+++++."); + lv_textarea_set_one_line(ctx->inputTa, false); + lv_obj_set_flex_grow(ctx->inputTa, 1); + lv_obj_set_height(ctx->inputTa, 50); + lv_obj_set_style_text_font(ctx->inputTa, lv_font_get_default(), 0); + lv_obj_add_event_cb(ctx->inputTa, onInputReady, LV_EVENT_READY, ctx); + + lv_obj_t* runBtn = lv_button_create(ctx->inputRow); lv_obj_t* runLbl = lv_label_create(runBtn); lv_label_set_text(runLbl, LV_SYMBOL_PLAY); - lv_obj_add_event_cb(runBtn, onRunClicked, LV_EVENT_CLICKED, nullptr); + lv_obj_add_event_cb(runBtn, onRunClicked, LV_EVENT_CLICKED, ctx); - examplesList = lv_list_create(cont); - lv_obj_set_width(examplesList, LV_PCT(100)); - lv_obj_set_flex_grow(examplesList, 1); - buildScriptList(examplesList); + ctx->examplesList = lv_list_create(cont); + lv_obj_set_width(ctx->examplesList, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->examplesList, 1); + buildScriptList(ctx, ctx->examplesList); } -void Brainfuck::onHide(AppHandle app) { - freeScriptPaths(); - outputTa = nullptr; - inputTa = nullptr; - inputRow = nullptr; - examplesList = nullptr; - clrBtn = nullptr; - g_instance = nullptr; - s_appHandle = nullptr; +void brainfuckTeardown(Context* ctx) { + freeScriptPaths(ctx); + ctx->outputTa = nullptr; + ctx->inputTa = nullptr; + ctx->inputRow = nullptr; + ctx->examplesList = nullptr; + ctx->clrBtn = nullptr; + if (g_ctx == ctx) { + g_ctx = nullptr; + } } diff --git a/Apps/Brainfuck/main/Source/Brainfuck.h b/Apps/Brainfuck/main/Source/Brainfuck.h index 0f352d4..e630640 100644 --- a/Apps/Brainfuck/main/Source/Brainfuck.h +++ b/Apps/Brainfuck/main/Source/Brainfuck.h @@ -1,8 +1,7 @@ #pragma once -#include #include -#include +#include constexpr int TAPE_SIZE = 4096; constexpr int MAX_OUTPUT = 2048; @@ -24,38 +23,25 @@ enum class BfState { Examples, }; -class Brainfuck final : public App { +struct Context { + uint32_t appInstanceId; -private: - // UI pointers (nulled in onHide) + BfState state = BfState::Examples; + BfVM vm = {}; + + // UI pointers (nulled on teardown) lv_obj_t* outputTa = nullptr; lv_obj_t* inputTa = nullptr; lv_obj_t* inputRow = nullptr; lv_obj_t* examplesList = nullptr; lv_obj_t* clrBtn = nullptr; - BfState state = BfState::Examples; - BfVM vm = {}; - - // Helper methods - void bfInit(); - void bfRun(const char* code); - void runCode(const char* code); - void buildScriptList(lv_obj_t* list); - - // View management - void showMainView(); - void showExamplesView(); - - // Static callbacks - static void onRunClicked(lv_event_t* e); - static void onClearClicked(lv_event_t* e); - static void onExamplesClicked(lv_event_t* e); - static void onExampleSelected(lv_event_t* e); - static void onFileSelected(lv_event_t* e); - static void onInputReady(lv_event_t* e); - -public: - void onShow(AppHandle context, lv_obj_t* parent) override; - void onHide(AppHandle context) override; + char** scriptPaths = nullptr; + int scriptCount = 0; }; + +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void brainfuckCreateWidgets(lv_obj_t* parent, void* userData); + +/** Releases resources acquired while the window was shown (script path list). Call once the window is torn down. */ +void brainfuckTeardown(Context* ctx); diff --git a/Apps/Brainfuck/main/Source/main.cpp b/Apps/Brainfuck/main/Source/main.cpp index 2ecaa83..78a77c2 100644 --- a/Apps/Brainfuck/main/Source/main.cpp +++ b/Apps/Brainfuck/main/Source/main.cpp @@ -1,10 +1,46 @@ #include "Brainfuck.h" -#include + +#include +#include +#include + +#include + +#include extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + // Heap-allocated: Context embeds BfVM (4096-byte tape + 2048-byte output buffer, ~6.3KB) - + // too large for the 8192-byte app task stack (see app_scheduler.cpp) alongside everything + // else on it. + auto ctx = std::make_unique(); + ctx->appInstanceId = app_instance_id; + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, brainfuckCreateWidgets, ctx.get()); + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(app_instance_id); + should_close = true; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + brainfuckTeardown(ctx.get()); + return 0; } diff --git a/Apps/Breakout/CMakeLists.txt b/Apps/Breakout/CMakeLists.txt index edf19d0..f6dfb19 100644 --- a/Apps/Breakout/CMakeLists.txt +++ b/Apps/Breakout/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(Breakout) tactility_project(Breakout) diff --git a/Apps/Breakout/main/CMakeLists.txt b/Apps/Breakout/main/CMakeLists.txt index 5bf1998..531d730 100644 --- a/Apps/Breakout/main/CMakeLists.txt +++ b/Apps/Breakout/main/CMakeLists.txt @@ -5,6 +5,6 @@ idf_component_register( SRCS ${SOURCE_FILES} ${SFX_ENGINE_FILES} # Library headers must be included directly, # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include ../../../Libraries/SfxEngine/Include + INCLUDE_DIRS ../../../Libraries/SfxEngine/Include REQUIRES TactilitySDK ) diff --git a/Apps/Breakout/main/Source/Breakout.cpp b/Apps/Breakout/main/Source/Breakout.cpp index ef6242e..6051ba2 100644 --- a/Apps/Breakout/main/Source/Breakout.cpp +++ b/Apps/Breakout/main/Source/Breakout.cpp @@ -7,8 +7,11 @@ #include #include + +#include #include -#include +#include + #include #include #include @@ -18,7 +21,9 @@ constexpr auto* TAG = "Breakout"; -static constexpr const char* PREF_NAMESPACE = "Breakout"; +/** Must match manifest.properties' app.id */ +static constexpr const char* APP_ID = "one.tactility.breakout"; + static constexpr const char* PREF_HIGH_SCORE = "high"; static constexpr const char* PREF_SOUND = "sound"; @@ -61,33 +66,43 @@ static constexpr int LASER_COOLDOWN_TICKS = 12; // Amber (Gold) bricks static constexpr int INDESTRUCTIBLE_HITS = 999; +static bool getSettingsPath(char* buf, size_t bufSize) { + return app_paths_get_user_data_path(APP_ID, "settings.properties", buf, bufSize) == ERROR_NONE; +} + static void loadSettings() { - PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE); + char path[192]; + if (!getSettingsPath(path, sizeof(path))) return; + Preferences* prefs = preferences_open(path); if (prefs) { - tt_preferences_opt_int32(prefs, PREF_HIGH_SCORE, &highScore); + preferences_opt_int32(prefs, PREF_HIGH_SCORE, &highScore); int32_t snd = 1; - tt_preferences_opt_int32(prefs, PREF_SOUND, &snd); + preferences_opt_int32(prefs, PREF_SOUND, &snd); soundEnabled = (snd != 0); - tt_preferences_free(prefs); + preferences_close(prefs); } } static void saveHighScore(int32_t score) { if (score <= highScore) return; highScore = score; - PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE); + char path[192]; + if (!getSettingsPath(path, sizeof(path))) return; + Preferences* prefs = preferences_open(path); if (prefs) { - tt_preferences_put_int32(prefs, PREF_HIGH_SCORE, score); - tt_preferences_free(prefs); + preferences_put_int32(prefs, PREF_HIGH_SCORE, score); + preferences_close(prefs); } } static void saveSoundSetting(bool enabled) { soundEnabled = enabled; - PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE); + char path[192]; + if (!getSettingsPath(path, sizeof(path))) return; + Preferences* prefs = preferences_open(path); if (prefs) { - tt_preferences_put_int32(prefs, PREF_SOUND, enabled ? 1 : 0); - tt_preferences_free(prefs); + preferences_put_int32(prefs, PREF_SOUND, enabled ? 1 : 0); + preferences_close(prefs); } } @@ -97,7 +112,44 @@ static uint32_t levelRng(uint32_t& seed) { return (seed >> 16) & 0x7FFF; } -// ── UI Creation ────────────────────────────────────────────── +/* ── Forward declarations (game logic operates on Context*) ─────── */ + +static void startGame(Context* ctx); +static void nextLevel(Context* ctx); +static void resetBall(Context* ctx); +static void launchBall(Context* ctx); +static void update(Context* ctx); +static void checkLaserBrickCollisions(Context* ctx); +static void loseLife(Context* ctx); +static void winLevel(Context* ctx); +static void createBricks(Context* ctx); +static void setupLevelPattern(Context* ctx); +static void refreshBricks(Context* ctx); +static void updateScoreDisplay(Context* ctx); +static void updateMessage(Context* ctx); +static void togglePause(Context* ctx); +static void updateSoundIcon(Context* ctx); + +static void spawnCapsule(Context* ctx, float x, float y); +static void updateCapsules(Context* ctx); +static void activatePowerUp(Context* ctx, PowerUpType type); +static void clearPowerUps(Context* ctx); +static void createCapsuleObjs(Context* ctx); + +static void updateBalls(Context* ctx); +static void splitBalls(Context* ctx); + +static void updateLasers(Context* ctx); +static void fireLaser(Context* ctx); +static void createLaserObjs(Context* ctx); + +static void openExit(Context* ctx); +static void closeExit(Context* ctx); + +static void hitBrick(Context* ctx, int idx); +static int scoreBrick(Context* ctx, int idx); + +/* ── UI Creation ──────────────────────────────────────────────── */ static uint32_t getToolbarHeight(UiDensity uiDensity) { if (uiDensity == LVGL_UI_DENSITY_COMPACT) { @@ -112,20 +164,32 @@ static uint32_t getActionIconPadding(UiDensity uiDensity) { return (uiDensity != LVGL_UI_DENSITY_COMPACT) ? (uint32_t)(toolbar_height * 0.2f) : 8; } -void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) { +/* ── Event Callbacks (declared here so createWidgets can wire them up) ── */ + +static void onTick(lv_timer_t* timer); +static void onPressed(lv_event_t* e); +static void onClicked(lv_event_t* e); +static void onKey(lv_event_t* e); +static void onReenterKeyMode(lv_event_t* e); +static void onPauseClicked(lv_event_t* e); +static void onSoundToggled(lv_event_t* e); + +void breakoutCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_all(parent, 0, 0); lv_obj_set_style_pad_row(parent, 0, 0); // Load settings on first show - if (needsInit) loadSettings(); + if (ctx->needsInit) loadSettings(); // Start sfx engine - if (!sfxEngine) { - sfxEngine = new SfxEngine(); - sfxEngine->start(); - sfxEngine->setEnabled(soundEnabled); + if (!ctx->sfxEngine) { + ctx->sfxEngine = new SfxEngine(); + ctx->sfxEngine->start(); + ctx->sfxEngine->setEnabled(soundEnabled); } // Toolbar @@ -142,10 +206,10 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_set_style_bg_opa(scoreWrap, 0, 0); lv_obj_remove_flag(scoreWrap, LV_OBJ_FLAG_SCROLLABLE); - scoreLabel = lv_label_create(scoreWrap); - lv_obj_set_style_text_font(scoreLabel, lv_font_get_default(), 0); - lv_obj_set_style_text_color(scoreLabel, lv_palette_main(LV_PALETTE_AMBER), 0); - lv_obj_align(scoreLabel, LV_ALIGN_CENTER, 0, 0); + ctx->scoreLabel = lv_label_create(scoreWrap); + lv_obj_set_style_text_font(ctx->scoreLabel, lv_font_get_default(), 0); + lv_obj_set_style_text_color(ctx->scoreLabel, lv_palette_main(LV_PALETTE_AMBER), 0); + lv_obj_align(ctx->scoreLabel, LV_ALIGN_CENTER, 0, 0); // Lives wrapper in toolbar lv_obj_t* livesWrap = lv_obj_create(toolbar); @@ -158,10 +222,10 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_set_style_bg_opa(livesWrap, 0, 0); lv_obj_remove_flag(livesWrap, LV_OBJ_FLAG_SCROLLABLE); - livesLabel = lv_label_create(livesWrap); - lv_obj_set_style_text_font(livesLabel, lv_font_get_default(), 0); - lv_obj_set_style_text_color(livesLabel, lv_palette_main(LV_PALETTE_RED), 0); - lv_obj_align(livesLabel, LV_ALIGN_CENTER, 0, 0); + ctx->livesLabel = lv_label_create(livesWrap); + lv_obj_set_style_text_font(ctx->livesLabel, lv_font_get_default(), 0); + lv_obj_set_style_text_color(ctx->livesLabel, lv_palette_main(LV_PALETTE_RED), 0); + lv_obj_align(ctx->livesLabel, LV_ALIGN_CENTER, 0, 0); auto ui_density = lvgl_get_ui_density(); auto toolbar_height = getToolbarHeight(ui_density); @@ -180,7 +244,7 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_set_size(pauseBtn, toolbar_height - icon_padding, toolbar_height - icon_padding); lv_obj_set_style_pad_all(pauseBtn, 0, LV_STATE_DEFAULT); lv_obj_align(pauseBtn, LV_ALIGN_CENTER, 0, 0); - lv_obj_add_event_cb(pauseBtn, onPauseClicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(pauseBtn, onPauseClicked, LV_EVENT_CLICKED, ctx); lv_obj_t* pauseIcon = lv_label_create(pauseBtn); lv_label_set_text(pauseIcon, LV_SYMBOL_PAUSE); @@ -191,11 +255,11 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_set_size(soundBtn, toolbar_height - icon_padding, toolbar_height - icon_padding); lv_obj_set_style_pad_all(soundBtn, 0, LV_STATE_DEFAULT); lv_obj_align(soundBtn, LV_ALIGN_CENTER, 0, 0); - lv_obj_add_event_cb(soundBtn, onSoundToggled, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(soundBtn, onSoundToggled, LV_EVENT_CLICKED, ctx); - soundBtnIcon = lv_label_create(soundBtn); - lv_obj_align(soundBtnIcon, LV_ALIGN_CENTER, 0, 0); - updateSoundIcon(); + ctx->soundBtnIcon = lv_label_create(soundBtn); + lv_obj_align(ctx->soundBtnIcon, LV_ALIGN_CENTER, 0, 0); + updateSoundIcon(ctx); // Screen size detection (The Book) lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr); @@ -204,267 +268,270 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) { bool isXLarge = (screenW >= 600); // Scaled dimensions - cols = isSmall ? 8 : (isXLarge ? 12 : 10); - rows = isSmall ? 3 : (isXLarge ? 5 : 4); - brickW = isSmall ? 24 : (isXLarge ? 56 : 28); - brickH = isSmall ? 8 : (isXLarge ? 18 : 10); - brickGap = isSmall ? 2 : (isXLarge ? 4 : 2); - ballSize = isSmall ? 6 : (isXLarge ? 14 : 8); - paddleW = isSmall ? 40 : (isXLarge ? 100 : 54); - paddleH = isSmall ? 6 : (isXLarge ? 14 : 8); - baseBallSpeed = isSmall ? 2.0f : (isXLarge ? 4.0f : 2.5f); - paddleSpeed = isSmall ? 16.0f : (isXLarge ? 36.0f : 24.0f); + ctx->cols = isSmall ? 8 : (isXLarge ? 12 : 10); + ctx->rows = isSmall ? 3 : (isXLarge ? 5 : 4); + ctx->brickW = isSmall ? 24 : (isXLarge ? 56 : 28); + ctx->brickH = isSmall ? 8 : (isXLarge ? 18 : 10); + ctx->brickGap = isSmall ? 2 : (isXLarge ? 4 : 2); + ctx->ballSize = isSmall ? 6 : (isXLarge ? 14 : 8); + ctx->paddleW = isSmall ? 40 : (isXLarge ? 100 : 54); + ctx->paddleH = isSmall ? 6 : (isXLarge ? 14 : 8); + ctx->baseBallSpeed = isSmall ? 2.0f : (isXLarge ? 4.0f : 2.5f); + ctx->paddleSpeed = isSmall ? 16.0f : (isXLarge ? 36.0f : 24.0f); int paddleMargin = isSmall ? 2 : (isXLarge ? 8 : 4); int brickTopPad = isSmall ? 4 : (isXLarge ? 12 : 8); // Capsule dimensions - capsuleW = isSmall ? 16 : (isXLarge ? 36 : 22); - capsuleH = isSmall ? 8 : (isXLarge ? 16 : 12); - capsuleFallSpeed = isSmall ? 1.2f : (isXLarge ? 2.5f : 1.8f); + ctx->capsuleW = isSmall ? 16 : (isXLarge ? 36 : 22); + ctx->capsuleH = isSmall ? 8 : (isXLarge ? 16 : 12); + ctx->capsuleFallSpeed = isSmall ? 1.2f : (isXLarge ? 2.5f : 1.8f); // Laser dimensions - laserW = isSmall ? 2 : (isXLarge ? 4 : 3); - laserH = isSmall ? 6 : (isXLarge ? 12 : 8); - laserSpeed = isSmall ? 4.0f : (isXLarge ? 8.0f : 6.0f); + ctx->laserW = isSmall ? 2 : (isXLarge ? 4 : 3); + ctx->laserH = isSmall ? 6 : (isXLarge ? 12 : 8); + ctx->laserSpeed = isSmall ? 4.0f : (isXLarge ? 8.0f : 6.0f); // Store original paddle width for extend reset - originalPaddleW = paddleW; + ctx->originalPaddleW = ctx->paddleW; // Ball speed includes level scaling - ballSpeed = baseBallSpeed + (level - 1) * 0.3f; + ctx->ballSpeed = ctx->baseBallSpeed + (ctx->level - 1) * 0.3f; // Game area - gameArea = lv_obj_create(parent); - lv_obj_set_width(gameArea, LV_PCT(100)); - lv_obj_set_flex_grow(gameArea, 1); - lv_obj_set_style_bg_color(gameArea, lv_color_hex(0x0a0a1e), 0); - lv_obj_set_style_bg_opa(gameArea, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(gameArea, 0, 0); - lv_obj_set_style_pad_all(gameArea, 0, 0); - lv_obj_set_style_radius(gameArea, 0, 0); - lv_obj_remove_flag(gameArea, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(gameArea, LV_OBJ_FLAG_CLICKABLE); + ctx->gameArea = lv_obj_create(parent); + lv_obj_set_width(ctx->gameArea, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->gameArea, 1); + lv_obj_set_style_bg_color(ctx->gameArea, lv_color_hex(0x0a0a1e), 0); + lv_obj_set_style_bg_opa(ctx->gameArea, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(ctx->gameArea, 0, 0); + lv_obj_set_style_pad_all(ctx->gameArea, 0, 0); + lv_obj_set_style_radius(ctx->gameArea, 0, 0); + lv_obj_remove_flag(ctx->gameArea, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(ctx->gameArea, LV_OBJ_FLAG_CLICKABLE); // Force layout to get accurate game area dimensions lv_obj_update_layout(parent); - areaW = lv_obj_get_content_width(gameArea); - areaH = lv_obj_get_content_height(gameArea); - paddleYPos = areaH - paddleH - paddleMargin; + ctx->areaW = lv_obj_get_content_width(ctx->gameArea); + ctx->areaH = lv_obj_get_content_height(ctx->gameArea); + ctx->paddleYPos = ctx->areaH - ctx->paddleH - paddleMargin; // Calculate brick layout (centered horizontally) - int totalBrickW = cols * brickW + (cols - 1) * brickGap; - brickOffsetX = (areaW - totalBrickW) / 2; - brickOffsetY = brickTopPad; + int totalBrickW = ctx->cols * ctx->brickW + (ctx->cols - 1) * ctx->brickGap; + ctx->brickOffsetX = (ctx->areaW - totalBrickW) / 2; + ctx->brickOffsetY = brickTopPad; // Create bricks - createBricks(); + createBricks(ctx); // Create paddle - paddle = lv_obj_create(gameArea); - lv_obj_set_size(paddle, paddleW, paddleH); - lv_obj_set_style_bg_color(paddle, lv_palette_main(LV_PALETTE_LIGHT_BLUE), 0); - lv_obj_set_style_border_width(paddle, 0, 0); - lv_obj_set_style_pad_all(paddle, 0, 0); - lv_obj_set_style_radius(paddle, 2, 0); - lv_obj_remove_flag(paddle, LV_OBJ_FLAG_SCROLLABLE); + ctx->paddle = lv_obj_create(ctx->gameArea); + lv_obj_set_size(ctx->paddle, ctx->paddleW, ctx->paddleH); + lv_obj_set_style_bg_color(ctx->paddle, lv_palette_main(LV_PALETTE_LIGHT_BLUE), 0); + lv_obj_set_style_border_width(ctx->paddle, 0, 0); + lv_obj_set_style_pad_all(ctx->paddle, 0, 0); + lv_obj_set_style_radius(ctx->paddle, 2, 0); + lv_obj_remove_flag(ctx->paddle, LV_OBJ_FLAG_SCROLLABLE); // Create balls (primary + extras for split) for (int i = 0; i < MAX_BALLS; i++) { - balls[i].obj = lv_obj_create(gameArea); - lv_obj_set_size(balls[i].obj, ballSize, ballSize); - lv_obj_set_style_bg_color(balls[i].obj, lv_color_white(), 0); - lv_obj_set_style_border_width(balls[i].obj, 0, 0); - lv_obj_set_style_pad_all(balls[i].obj, 0, 0); - lv_obj_set_style_radius(balls[i].obj, LV_RADIUS_CIRCLE, 0); - lv_obj_remove_flag(balls[i].obj, LV_OBJ_FLAG_SCROLLABLE); + ctx->balls[i].obj = lv_obj_create(ctx->gameArea); + lv_obj_set_size(ctx->balls[i].obj, ctx->ballSize, ctx->ballSize); + lv_obj_set_style_bg_color(ctx->balls[i].obj, lv_color_white(), 0); + lv_obj_set_style_border_width(ctx->balls[i].obj, 0, 0); + lv_obj_set_style_pad_all(ctx->balls[i].obj, 0, 0); + lv_obj_set_style_radius(ctx->balls[i].obj, LV_RADIUS_CIRCLE, 0); + lv_obj_remove_flag(ctx->balls[i].obj, LV_OBJ_FLAG_SCROLLABLE); if (i > 0) { - lv_obj_add_flag(balls[i].obj, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(ctx->balls[i].obj, LV_OBJ_FLAG_HIDDEN); } } // Create capsule objects (pre-created, hidden) - createCapsuleObjs(); + createCapsuleObjs(ctx); // Create laser objects (pre-created, hidden) - createLaserObjs(); + createLaserObjs(ctx); // BreakOut exit indicator at paddle level (hidden by default) - int exitH = paddleH * 3; - exitIndicator = lv_obj_create(gameArea); - lv_obj_set_size(exitIndicator, 6, exitH); - lv_obj_set_style_bg_color(exitIndicator, lv_color_hex(0xFF44AA), 0); - lv_obj_set_style_bg_opa(exitIndicator, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(exitIndicator, 0, 0); - lv_obj_set_style_radius(exitIndicator, 0, 0); - lv_obj_remove_flag(exitIndicator, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_pos(exitIndicator, areaW - 6, paddleYPos - exitH / 2 + paddleH / 2); - lv_obj_add_flag(exitIndicator, LV_OBJ_FLAG_HIDDEN); + int exitH = ctx->paddleH * 3; + ctx->exitIndicator = lv_obj_create(ctx->gameArea); + lv_obj_set_size(ctx->exitIndicator, 6, exitH); + lv_obj_set_style_bg_color(ctx->exitIndicator, lv_color_hex(0xFF44AA), 0); + lv_obj_set_style_bg_opa(ctx->exitIndicator, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(ctx->exitIndicator, 0, 0); + lv_obj_set_style_radius(ctx->exitIndicator, 0, 0); + lv_obj_remove_flag(ctx->exitIndicator, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_pos(ctx->exitIndicator, ctx->areaW - 6, ctx->paddleYPos - exitH / 2 + ctx->paddleH / 2); + lv_obj_add_flag(ctx->exitIndicator, LV_OBJ_FLAG_HIDDEN); // Message overlay (centered in game area) - messageLabel = lv_label_create(gameArea); - lv_obj_set_style_text_color(messageLabel, lv_color_white(), 0); - lv_obj_set_style_text_align(messageLabel, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_center(messageLabel); + ctx->messageLabel = lv_label_create(ctx->gameArea); + lv_obj_set_style_text_color(ctx->messageLabel, lv_color_white(), 0); + lv_obj_set_style_text_align(ctx->messageLabel, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_center(ctx->messageLabel); // Initialize or restore - if (needsInit) { - paddleX = (areaW - paddleW) / 2.0f; - startGame(); - needsInit = false; + if (ctx->needsInit) { + ctx->paddleX = (ctx->areaW - ctx->paddleW) / 2.0f; + startGame(ctx); + ctx->needsInit = false; } else { // Restore visual positions from saved state // Re-apply extended paddle width if still active - if (extendActive && paddle) lv_obj_set_width(paddle, paddleW); - lv_obj_set_pos(paddle, (int)paddleX, paddleYPos); + if (ctx->extendActive && ctx->paddle) lv_obj_set_width(ctx->paddle, ctx->paddleW); + lv_obj_set_pos(ctx->paddle, (int)ctx->paddleX, ctx->paddleYPos); for (int i = 0; i < MAX_BALLS; i++) { - if (balls[i].active && balls[i].obj) { - lv_obj_set_pos(balls[i].obj, (int)balls[i].x, (int)balls[i].y); - lv_obj_clear_flag(balls[i].obj, LV_OBJ_FLAG_HIDDEN); + if (ctx->balls[i].active && ctx->balls[i].obj) { + lv_obj_set_pos(ctx->balls[i].obj, (int)ctx->balls[i].x, (int)ctx->balls[i].y); + lv_obj_clear_flag(ctx->balls[i].obj, LV_OBJ_FLAG_HIDDEN); } } - // Restore falling capsules - for (int i = 0; i < MAX_CAPSULES; i++) { - if (capsules[i].active && capsuleObjs[i]) { - lv_obj_set_pos(capsuleObjs[i], (int)capsules[i].x, (int)capsules[i].y); - lv_obj_clear_flag(capsuleObjs[i], LV_OBJ_FLAG_HIDDEN); + // Restore falling capsules + for (int i = 0; i < MAX_CAPSULES; i++) { + if (ctx->capsules[i].active && ctx->capsuleObjs[i]) { + lv_obj_set_pos(ctx->capsuleObjs[i], (int)ctx->capsules[i].x, (int)ctx->capsules[i].y); + lv_obj_clear_flag(ctx->capsuleObjs[i], LV_OBJ_FLAG_HIDDEN); + } } - } - // Restore exit indicator - if (exitOpen && exitIndicator) lv_obj_clear_flag(exitIndicator, LV_OBJ_FLAG_HIDDEN); - updateScoreDisplay(); - updateMessage(); + // Restore exit indicator + if (ctx->exitOpen && ctx->exitIndicator) lv_obj_clear_flag(ctx->exitIndicator, LV_OBJ_FLAG_HIDDEN); + updateScoreDisplay(ctx); + updateMessage(ctx); } // Input handlers - lv_obj_add_event_cb(gameArea, onPressed, LV_EVENT_PRESSING, this); - lv_obj_add_event_cb(gameArea, onClicked, LV_EVENT_SHORT_CLICKED, this); - lv_obj_add_event_cb(gameArea, onKey, LV_EVENT_KEY, this); - lv_obj_add_event_cb(gameArea, onReenterKeyMode, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(ctx->gameArea, onPressed, LV_EVENT_PRESSING, ctx); + lv_obj_add_event_cb(ctx->gameArea, onClicked, LV_EVENT_SHORT_CLICKED, ctx); + lv_obj_add_event_cb(ctx->gameArea, onKey, LV_EVENT_KEY, ctx); + lv_obj_add_event_cb(ctx->gameArea, onReenterKeyMode, LV_EVENT_CLICKED, ctx); // Keyboard focus - explicit enter/exit, no focus/defocus handlers lv_group_t* group = lv_group_get_default(); if (group) { - lv_group_add_obj(group, gameArea); - lv_group_focus_obj(gameArea); + lv_group_add_obj(group, ctx->gameArea); + lv_group_focus_obj(ctx->gameArea); lv_group_set_editing(group, true); } // Start game timer - gameTimer = lv_timer_create(onTick, TICK_MS, this); + ctx->gameTimer = lv_timer_create(onTick, TICK_MS, ctx); } -void Breakout::onHide(AppHandle appHandle) { - if (gameTimer) { - lv_timer_delete(gameTimer); - gameTimer = nullptr; +void breakoutTeardown(Context* ctx) { + if (ctx->gameTimer) { + lv_timer_delete(ctx->gameTimer); + ctx->gameTimer = nullptr; } - if (gameArea) { + if (ctx->gameArea) { lv_group_t* group = lv_group_get_default(); if (group) lv_group_set_editing(group, false); - lv_group_remove_obj(gameArea); + lv_group_remove_obj(ctx->gameArea); } - gameArea = nullptr; - paddle = nullptr; - for (int i = 0; i < MAX_BRICKS; i++) bricks[i] = nullptr; - for (int i = 0; i < MAX_BALLS; i++) balls[i].obj = nullptr; + ctx->gameArea = nullptr; + ctx->paddle = nullptr; + for (int i = 0; i < MAX_BRICKS; i++) ctx->bricks[i] = nullptr; + for (int i = 0; i < MAX_BALLS; i++) ctx->balls[i].obj = nullptr; for (int i = 0; i < MAX_CAPSULES; i++) { - capsuleObjs[i] = nullptr; - capsuleLabels[i] = nullptr; + ctx->capsuleObjs[i] = nullptr; + ctx->capsuleLabels[i] = nullptr; } - for (int i = 0; i < MAX_LASERS; i++) lasers[i].obj = nullptr; - exitIndicator = nullptr; - scoreLabel = nullptr; - livesLabel = nullptr; - messageLabel = nullptr; - soundBtnIcon = nullptr; + for (int i = 0; i < MAX_LASERS; i++) ctx->lasers[i].obj = nullptr; + ctx->exitIndicator = nullptr; + ctx->scoreLabel = nullptr; + ctx->livesLabel = nullptr; + ctx->messageLabel = nullptr; + ctx->soundBtnIcon = nullptr; // Clean up sfx engine - if (sfxEngine) { - sfxEngine->stop(); - delete sfxEngine; - sfxEngine = nullptr; + if (ctx->sfxEngine) { + ctx->sfxEngine->stop(); + delete ctx->sfxEngine; + ctx->sfxEngine = nullptr; } } -// ── Capsule & Laser Object Creation ───────────────────────── +/* ── Capsule & Laser Object Creation ─────────────────────────── */ -void Breakout::createCapsuleObjs() { +static void createCapsuleObjs(Context* ctx) { for (int i = 0; i < MAX_CAPSULES; i++) { - capsuleObjs[i] = lv_obj_create(gameArea); - lv_obj_set_size(capsuleObjs[i], capsuleW, capsuleH); - lv_obj_set_style_border_width(capsuleObjs[i], 1, 0); - lv_obj_set_style_border_color(capsuleObjs[i], lv_color_white(), 0); - lv_obj_set_style_pad_all(capsuleObjs[i], 0, 0); - lv_obj_set_style_radius(capsuleObjs[i], 3, 0); - lv_obj_remove_flag(capsuleObjs[i], LV_OBJ_FLAG_SCROLLABLE); - lv_obj_remove_flag(capsuleObjs[i], LV_OBJ_FLAG_CLICKABLE); - lv_obj_add_flag(capsuleObjs[i], LV_OBJ_FLAG_HIDDEN); - - capsuleLabels[i] = lv_label_create(capsuleObjs[i]); - lv_obj_set_style_text_color(capsuleLabels[i], lv_color_white(), 0); - lv_obj_center(capsuleLabels[i]); + ctx->capsuleObjs[i] = lv_obj_create(ctx->gameArea); + lv_obj_set_size(ctx->capsuleObjs[i], ctx->capsuleW, ctx->capsuleH); + lv_obj_set_style_border_width(ctx->capsuleObjs[i], 1, 0); + lv_obj_set_style_border_color(ctx->capsuleObjs[i], lv_color_white(), 0); + lv_obj_set_style_pad_all(ctx->capsuleObjs[i], 0, 0); + lv_obj_set_style_radius(ctx->capsuleObjs[i], 3, 0); + lv_obj_remove_flag(ctx->capsuleObjs[i], LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(ctx->capsuleObjs[i], LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_flag(ctx->capsuleObjs[i], LV_OBJ_FLAG_HIDDEN); + + ctx->capsuleLabels[i] = lv_label_create(ctx->capsuleObjs[i]); + lv_obj_set_style_text_color(ctx->capsuleLabels[i], lv_color_white(), 0); + lv_obj_center(ctx->capsuleLabels[i]); } } -void Breakout::createLaserObjs() { +static void createLaserObjs(Context* ctx) { for (int i = 0; i < MAX_LASERS; i++) { - lasers[i].obj = lv_obj_create(gameArea); - lv_obj_set_size(lasers[i].obj, laserW, laserH); - lv_obj_set_style_bg_color(lasers[i].obj, lv_color_hex(0xFF4444), 0); - lv_obj_set_style_bg_opa(lasers[i].obj, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(lasers[i].obj, 0, 0); - lv_obj_set_style_pad_all(lasers[i].obj, 0, 0); - lv_obj_set_style_radius(lasers[i].obj, 0, 0); - lv_obj_remove_flag(lasers[i].obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(lasers[i].obj, LV_OBJ_FLAG_HIDDEN); - lasers[i].active = false; + ctx->lasers[i].obj = lv_obj_create(ctx->gameArea); + lv_obj_set_size(ctx->lasers[i].obj, ctx->laserW, ctx->laserH); + lv_obj_set_style_bg_color(ctx->lasers[i].obj, lv_color_hex(0xFF4444), 0); + lv_obj_set_style_bg_opa(ctx->lasers[i].obj, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(ctx->lasers[i].obj, 0, 0); + lv_obj_set_style_pad_all(ctx->lasers[i].obj, 0, 0); + lv_obj_set_style_radius(ctx->lasers[i].obj, 0, 0); + lv_obj_remove_flag(ctx->lasers[i].obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(ctx->lasers[i].obj, LV_OBJ_FLAG_HIDDEN); + ctx->lasers[i].active = false; } } -// ── Brick Creation ─────────────────────────────────────────── +/* ── Brick Creation ───────────────────────────────────────────── */ -void Breakout::createBricks() { - for (int r = 0; r < rows; r++) { - for (int c = 0; c < cols; c++) { - int idx = r * cols + c; +static void createBricks(Context* ctx) { + for (int r = 0; r < ctx->rows; r++) { + for (int c = 0; c < ctx->cols; c++) { + int idx = r * ctx->cols + c; if (idx >= MAX_BRICKS) continue; - bricks[idx] = lv_obj_create(gameArea); - lv_obj_set_size(bricks[idx], brickW, brickH); - lv_obj_set_style_border_width(bricks[idx], 0, 0); - lv_obj_set_style_pad_all(bricks[idx], 0, 0); - lv_obj_set_style_radius(bricks[idx], 2, 0); - lv_obj_remove_flag(bricks[idx], LV_OBJ_FLAG_SCROLLABLE); - lv_obj_remove_flag(bricks[idx], LV_OBJ_FLAG_CLICKABLE); - - int x = brickOffsetX + c * (brickW + brickGap); - int y = brickOffsetY + r * (brickH + brickGap); - lv_obj_set_pos(bricks[idx], x, y); + ctx->bricks[idx] = lv_obj_create(ctx->gameArea); + lv_obj_set_size(ctx->bricks[idx], ctx->brickW, ctx->brickH); + lv_obj_set_style_border_width(ctx->bricks[idx], 0, 0); + lv_obj_set_style_pad_all(ctx->bricks[idx], 0, 0); + lv_obj_set_style_radius(ctx->bricks[idx], 2, 0); + lv_obj_remove_flag(ctx->bricks[idx], LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(ctx->bricks[idx], LV_OBJ_FLAG_CLICKABLE); + + int x = ctx->brickOffsetX + c * (ctx->brickW + ctx->brickGap); + int y = ctx->brickOffsetY + r * (ctx->brickH + ctx->brickGap); + lv_obj_set_pos(ctx->bricks[idx], x, y); } } - refreshBricks(); + refreshBricks(ctx); } -void Breakout::setupLevelPattern() { - int total = cols * rows; +static void setupLevelPattern(Context* ctx) { + int total = ctx->cols * ctx->rows; int numPatterns = 12; // Initialize all bricks for (int i = 0; i < total; i++) { - brickAlive[i] = true; - brickType[i] = BrickType::Normal; - brickHits[i] = 1; + ctx->brickAlive[i] = true; + ctx->brickType[i] = BrickType::Normal; + ctx->brickHits[i] = 1; } - int colorOffset = (level - 1) % 8; - for (int r = 0; r < rows; r++) { - for (int c = 0; c < cols; c++) { - brickColorIndex[r * cols + c] = (r + colorOffset) % 8; + int colorOffset = (ctx->level - 1) % 8; + for (int r = 0; r < ctx->rows; r++) { + for (int c = 0; c < ctx->cols; c++) { + ctx->brickColorIndex[r * ctx->cols + c] = (r + colorOffset) % 8; } } - if (level <= numPatterns) { + if (ctx->level <= numPatterns) { // Hardcoded patterns - int pattern = (level - 1) % numPatterns; + int pattern = (ctx->level - 1) % numPatterns; + int rows = ctx->rows; + int cols = ctx->cols; + bool* brickAlive = ctx->brickAlive; switch (pattern) { case 0: // Full grid break; @@ -548,621 +615,621 @@ void Breakout::setupLevelPattern() { } } else { // Procedural generation (levels 13+) - uint32_t seed = (uint32_t)level * 2654435761u; - int density = 50 + (level * 2); + uint32_t seed = (uint32_t)ctx->level * 2654435761u; + int density = 50 + (ctx->level * 2); if (density > 85) density = 85; for (int i = 0; i < total; i++) { int roll = (int)(levelRng(seed) % 100); - brickAlive[i] = (roll < density); + ctx->brickAlive[i] = (roll < density); } // Ensure at least some bricks exist int aliveCount = 0; - for (int i = 0; i < total; i++) if (brickAlive[i]) aliveCount++; + for (int i = 0; i < total; i++) if (ctx->brickAlive[i]) aliveCount++; if (aliveCount < 5) { for (int i = 0; i < total && aliveCount < 8; i++) { - if (!brickAlive[i]) { brickAlive[i] = true; aliveCount++; } + if (!ctx->brickAlive[i]) { ctx->brickAlive[i] = true; aliveCount++; } } } } // Add Silver bricks (level 3+) - if (level >= 3) { - int silverCount = (level - 2); - if (silverCount > rows) silverCount = rows; - int silverHits = (level >= 7) ? 3 : 2; - uint32_t seed = (uint32_t)level * 31337u; + if (ctx->level >= 3) { + int silverCount = (ctx->level - 2); + if (silverCount > ctx->rows) silverCount = ctx->rows; + int silverHits = (ctx->level >= 7) ? 3 : 2; + uint32_t seed = (uint32_t)ctx->level * 31337u; int placed = 0; for (int attempt = 0; attempt < total * 2 && placed < silverCount; attempt++) { int idx = (int)(levelRng(seed) % total); - if (brickAlive[idx] && brickType[idx] == BrickType::Normal) { - brickType[idx] = BrickType::Silver; - brickHits[idx] = silverHits; + if (ctx->brickAlive[idx] && ctx->brickType[idx] == BrickType::Normal) { + ctx->brickType[idx] = BrickType::Silver; + ctx->brickHits[idx] = silverHits; placed++; } } } // Add Gold bricks (level 5+) - if (level >= 5) { - int goldCount = (level - 4) / 2; + if (ctx->level >= 5) { + int goldCount = (ctx->level - 4) / 2; if (goldCount > 4) goldCount = 4; - uint32_t seed = (uint32_t)level * 48271u; + uint32_t seed = (uint32_t)ctx->level * 48271u; int placed = 0; for (int attempt = 0; attempt < total * 2 && placed < goldCount; attempt++) { int idx = (int)(levelRng(seed) % total); - if (brickAlive[idx] && brickType[idx] == BrickType::Normal) { - brickType[idx] = BrickType::Gold; - brickHits[idx] = INDESTRUCTIBLE_HITS; // indestructible + if (ctx->brickAlive[idx] && ctx->brickType[idx] == BrickType::Normal) { + ctx->brickType[idx] = BrickType::Gold; + ctx->brickHits[idx] = INDESTRUCTIBLE_HITS; // indestructible placed++; } } } // Count alive bricks (excluding Gold) - bricksRemaining = 0; - destroyedCount = 0; + ctx->bricksRemaining = 0; + ctx->destroyedCount = 0; for (int i = 0; i < total; i++) { - if (brickAlive[i] && brickType[i] != BrickType::Gold) bricksRemaining++; + if (ctx->brickAlive[i] && ctx->brickType[i] != BrickType::Gold) ctx->bricksRemaining++; } } -void Breakout::refreshBricks() { - for (int r = 0; r < rows; r++) { - for (int c = 0; c < cols; c++) { - int idx = r * cols + c; - if (!bricks[idx]) continue; +static void refreshBricks(Context* ctx) { + for (int r = 0; r < ctx->rows; r++) { + for (int c = 0; c < ctx->cols; c++) { + int idx = r * ctx->cols + c; + if (!ctx->bricks[idx]) continue; - if (!brickAlive[idx]) { - lv_obj_add_flag(bricks[idx], LV_OBJ_FLAG_HIDDEN); + if (!ctx->brickAlive[idx]) { + lv_obj_add_flag(ctx->bricks[idx], LV_OBJ_FLAG_HIDDEN); continue; } - lv_obj_clear_flag(bricks[idx], LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(ctx->bricks[idx], LV_OBJ_FLAG_HIDDEN); - switch (brickType[idx]) { + switch (ctx->brickType[idx]) { case BrickType::Normal: { - lv_palette_t color = BRICK_COLORS[brickColorIndex[idx]]; - lv_obj_set_style_bg_color(bricks[idx], lv_palette_main(color), 0); - lv_obj_set_style_border_width(bricks[idx], 0, 0); + lv_palette_t color = BRICK_COLORS[ctx->brickColorIndex[idx]]; + lv_obj_set_style_bg_color(ctx->bricks[idx], lv_palette_main(color), 0); + lv_obj_set_style_border_width(ctx->bricks[idx], 0, 0); break; } case BrickType::Silver: { - int darken = 3 - brickHits[idx]; // more hits taken = darker + int darken = 3 - ctx->brickHits[idx]; // more hits taken = darker if (darken < 0) darken = 0; if (darken > 3) darken = 3; - lv_obj_set_style_bg_color(bricks[idx], lv_palette_darken(LV_PALETTE_GREY, darken), 0); - lv_obj_set_style_border_width(bricks[idx], 1, 0); - lv_obj_set_style_border_color(bricks[idx], lv_color_white(), 0); + lv_obj_set_style_bg_color(ctx->bricks[idx], lv_palette_darken(LV_PALETTE_GREY, darken), 0); + lv_obj_set_style_border_width(ctx->bricks[idx], 1, 0); + lv_obj_set_style_border_color(ctx->bricks[idx], lv_color_white(), 0); break; } case BrickType::Gold: - lv_obj_set_style_bg_color(bricks[idx], lv_palette_main(LV_PALETTE_AMBER), 0); - lv_obj_set_style_border_width(bricks[idx], 1, 0); - lv_obj_set_style_border_color(bricks[idx], lv_palette_lighten(LV_PALETTE_AMBER, 2), 0); + lv_obj_set_style_bg_color(ctx->bricks[idx], lv_palette_main(LV_PALETTE_AMBER), 0); + lv_obj_set_style_border_width(ctx->bricks[idx], 1, 0); + lv_obj_set_style_border_color(ctx->bricks[idx], lv_palette_lighten(LV_PALETTE_AMBER, 2), 0); break; } } } } -// ── Brick Hit Logic ────────────────────────────────────────── +/* ── Brick Hit Logic ──────────────────────────────────────────── */ -int Breakout::scoreBrick(int idx) { - switch (brickType[idx]) { +static int scoreBrick(Context* ctx, int idx) { + switch (ctx->brickType[idx]) { case BrickType::Silver: - return 50 * level; + return 50 * ctx->level; case BrickType::Gold: return 0; // can't be destroyed case BrickType::Normal: default: - return COLOR_SCORES[brickColorIndex[idx] % 8]; + return COLOR_SCORES[ctx->brickColorIndex[idx] % 8]; } } -void Breakout::hitBrick(int idx) { - if (!brickAlive[idx]) return; +static void hitBrick(Context* ctx, int idx) { + if (!ctx->brickAlive[idx]) return; - if (brickType[idx] == BrickType::Gold) { + if (ctx->brickType[idx] == BrickType::Gold) { // Bounce but don't damage - if (sfxEngine) sfxEngine->play(SfxId::Click); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Click); return; } - brickHits[idx]--; - if (brickHits[idx] <= 0) { + ctx->brickHits[idx]--; + if (ctx->brickHits[idx] <= 0) { // Brick destroyed - brickAlive[idx] = false; - if (bricks[idx]) lv_obj_add_flag(bricks[idx], LV_OBJ_FLAG_HIDDEN); - score += scoreBrick(idx); - if (brickType[idx] != BrickType::Gold) { - bricksRemaining--; - destroyedCount++; + ctx->brickAlive[idx] = false; + if (ctx->bricks[idx]) lv_obj_add_flag(ctx->bricks[idx], LV_OBJ_FLAG_HIDDEN); + ctx->score += scoreBrick(ctx, idx); + if (ctx->brickType[idx] != BrickType::Gold) { + ctx->bricksRemaining--; + ctx->destroyedCount++; } // Speed up ball slightly every 5 bricks destroyed - if (destroyedCount % 5 == 0) { - ballSpeed += 0.15f; + if (ctx->destroyedCount % 5 == 0) { + ctx->ballSpeed += 0.15f; // Scale active ball velocities for (int b = 0; b < MAX_BALLS; b++) { - if (!balls[b].active) continue; - float curSpd = std::sqrt(balls[b].vx * balls[b].vx + balls[b].vy * balls[b].vy); + if (!ctx->balls[b].active) continue; + float curSpd = std::sqrt(ctx->balls[b].vx * ctx->balls[b].vx + ctx->balls[b].vy * ctx->balls[b].vy); if (curSpd > 0.01f) { - float scale = ballSpeed / curSpd; - balls[b].vx *= scale; - balls[b].vy *= scale; + float scale = ctx->ballSpeed / curSpd; + ctx->balls[b].vx *= scale; + ctx->balls[b].vy *= scale; } } } - updateScoreDisplay(); - if (sfxEngine) sfxEngine->play(SfxId::BrickHit); + updateScoreDisplay(ctx); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::BrickHit); // Try to spawn capsule (only when single ball) - if (activeBallCount <= 1) { - int bx = brickOffsetX + (idx % cols) * (brickW + brickGap); - int by = brickOffsetY + (idx / cols) * (brickH + brickGap); + if (ctx->activeBallCount <= 1) { + int bx = ctx->brickOffsetX + (idx % ctx->cols) * (ctx->brickW + ctx->brickGap); + int by = ctx->brickOffsetY + (idx / ctx->cols) * (ctx->brickH + ctx->brickGap); if ((int)(esp_random() % 100) < CAPSULE_DROP_CHANCE) { - spawnCapsule((float)bx, (float)by); + spawnCapsule(ctx, (float)bx, (float)by); } } - if (bricksRemaining <= 0) { - winLevel(); + if (ctx->bricksRemaining <= 0) { + winLevel(ctx); } } else { // Multi-hit brick took damage (Silver) - if (bricks[idx]) { - int darken = 3 - brickHits[idx]; + if (ctx->bricks[idx]) { + int darken = 3 - ctx->brickHits[idx]; if (darken < 0) darken = 0; if (darken > 3) darken = 3; - lv_obj_set_style_bg_color(bricks[idx], lv_palette_darken(LV_PALETTE_GREY, darken), 0); + lv_obj_set_style_bg_color(ctx->bricks[idx], lv_palette_darken(LV_PALETTE_GREY, darken), 0); } - if (sfxEngine) sfxEngine->play(SfxId::Click); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Click); } } -// ── Game State Management ──────────────────────────────────── +/* ── Game State Management ────────────────────────────────────── */ -void Breakout::startGame() { - score = 0; - lives = INITIAL_LIVES; - level = 1; - state = GameState::Ready; - ballSpeed = baseBallSpeed; +static void startGame(Context* ctx) { + ctx->score = 0; + ctx->lives = INITIAL_LIVES; + ctx->level = 1; + ctx->state = GameState::Ready; + ctx->ballSpeed = ctx->baseBallSpeed; - clearPowerUps(); - setupLevelPattern(); - refreshBricks(); + clearPowerUps(ctx); + setupLevelPattern(ctx); + refreshBricks(ctx); - paddleX = (areaW - paddleW) / 2.0f; - if (paddle) lv_obj_set_pos(paddle, (int)paddleX, paddleYPos); + ctx->paddleX = (ctx->areaW - ctx->paddleW) / 2.0f; + if (ctx->paddle) lv_obj_set_pos(ctx->paddle, (int)ctx->paddleX, ctx->paddleYPos); - resetBall(); - updateScoreDisplay(); - updateMessage(); + resetBall(ctx); + updateScoreDisplay(ctx); + updateMessage(ctx); - if (sfxEngine) sfxEngine->play(SfxId::Confirm); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Confirm); } -void Breakout::nextLevel() { - level++; +static void nextLevel(Context* ctx) { + ctx->level++; // Speed up ball each level - ballSpeed = baseBallSpeed + (level - 1) * 0.3f; + ctx->ballSpeed = ctx->baseBallSpeed + (ctx->level - 1) * 0.3f; - clearPowerUps(); - setupLevelPattern(); - refreshBricks(); + clearPowerUps(ctx); + setupLevelPattern(ctx); + refreshBricks(ctx); - state = GameState::Ready; - paddleX = (areaW - paddleW) / 2.0f; - if (paddle) lv_obj_set_pos(paddle, (int)paddleX, paddleYPos); + ctx->state = GameState::Ready; + ctx->paddleX = (ctx->areaW - ctx->paddleW) / 2.0f; + if (ctx->paddle) lv_obj_set_pos(ctx->paddle, (int)ctx->paddleX, ctx->paddleYPos); - resetBall(); - updateScoreDisplay(); - updateMessage(); + resetBall(ctx); + updateScoreDisplay(ctx); + updateMessage(ctx); - if (sfxEngine) sfxEngine->play(SfxId::LevelUp); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::LevelUp); } -void Breakout::resetBall() { +static void resetBall(Context* ctx) { // Reset to single ball for (int i = 1; i < MAX_BALLS; i++) { - balls[i].active = false; - if (balls[i].obj) lv_obj_add_flag(balls[i].obj, LV_OBJ_FLAG_HIDDEN); + ctx->balls[i].active = false; + if (ctx->balls[i].obj) lv_obj_add_flag(ctx->balls[i].obj, LV_OBJ_FLAG_HIDDEN); } - activeBallCount = 1; - - balls[0].active = true; - balls[0].x = paddleX + paddleW / 2.0f - ballSize / 2.0f; - balls[0].y = (float)(paddleYPos - ballSize - 2); - balls[0].vx = 0; - balls[0].vy = 0; - if (balls[0].obj) { - lv_obj_set_pos(balls[0].obj, (int)balls[0].x, (int)balls[0].y); - lv_obj_clear_flag(balls[0].obj, LV_OBJ_FLAG_HIDDEN); + ctx->activeBallCount = 1; + + ctx->balls[0].active = true; + ctx->balls[0].x = ctx->paddleX + ctx->paddleW / 2.0f - ctx->ballSize / 2.0f; + ctx->balls[0].y = (float)(ctx->paddleYPos - ctx->ballSize - 2); + ctx->balls[0].vx = 0; + ctx->balls[0].vy = 0; + if (ctx->balls[0].obj) { + lv_obj_set_pos(ctx->balls[0].obj, (int)ctx->balls[0].x, (int)ctx->balls[0].y); + lv_obj_clear_flag(ctx->balls[0].obj, LV_OBJ_FLAG_HIDDEN); } } -void Breakout::launchBall() { - if (catchActive && catchBallIndex >= 0) { +static void launchBall(Context* ctx) { + if (ctx->catchActive && ctx->catchBallIndex >= 0) { // Release caught ball - BallState& b = balls[catchBallIndex]; - b.vx = (esp_random() % 2 ? 1.0f : -1.0f) * ballSpeed * 0.7f; - b.vy = -ballSpeed; - catchBallIndex = -1; - catchAutoReleaseTicks = 0; - if (sfxEngine) sfxEngine->play(SfxId::Confirm); + BallState& b = ctx->balls[ctx->catchBallIndex]; + b.vx = (esp_random() % 2 ? 1.0f : -1.0f) * ctx->ballSpeed * 0.7f; + b.vy = -ctx->ballSpeed; + ctx->catchBallIndex = -1; + ctx->catchAutoReleaseTicks = 0; + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Confirm); return; } - balls[0].vx = (esp_random() % 2 ? 1.0f : -1.0f) * ballSpeed * 0.7f; - balls[0].vy = -ballSpeed; - state = GameState::Playing; - updateMessage(); + ctx->balls[0].vx = (esp_random() % 2 ? 1.0f : -1.0f) * ctx->ballSpeed * 0.7f; + ctx->balls[0].vy = -ctx->ballSpeed; + ctx->state = GameState::Playing; + updateMessage(ctx); - if (sfxEngine) sfxEngine->play(SfxId::Confirm); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Confirm); } -void Breakout::loseLife() { - lives--; - clearPowerUps(); +static void loseLife(Context* ctx) { + ctx->lives--; + clearPowerUps(ctx); - if (sfxEngine) sfxEngine->play(SfxId::Hurt); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Hurt); - if (lives <= 0) { - state = GameState::GameOver; + if (ctx->lives <= 0) { + ctx->state = GameState::GameOver; for (int i = 0; i < MAX_BALLS; i++) { - balls[i].vx = 0; - balls[i].vy = 0; + ctx->balls[i].vx = 0; + ctx->balls[i].vy = 0; } - if (sfxEngine) sfxEngine->play(SfxId::GameOver); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::GameOver); } else { - state = GameState::Ready; - paddleX = (areaW - paddleW) / 2.0f; - if (paddle) lv_obj_set_pos(paddle, (int)paddleX, paddleYPos); - resetBall(); + ctx->state = GameState::Ready; + ctx->paddleX = (ctx->areaW - ctx->paddleW) / 2.0f; + if (ctx->paddle) lv_obj_set_pos(ctx->paddle, (int)ctx->paddleX, ctx->paddleYPos); + resetBall(ctx); } - updateScoreDisplay(); - updateMessage(); - if (lives <= 0) { - saveHighScore(score); + updateScoreDisplay(ctx); + updateMessage(ctx); + if (ctx->lives <= 0) { + saveHighScore(ctx->score); } } -void Breakout::winLevel() { - saveHighScore(score); - nextLevel(); +static void winLevel(Context* ctx) { + saveHighScore(ctx->score); + nextLevel(ctx); } -void Breakout::togglePause() { - if (state == GameState::Playing) { - state = GameState::Paused; - updateMessage(); - } else if (state == GameState::Paused) { - state = GameState::Playing; - updateMessage(); +static void togglePause(Context* ctx) { + if (ctx->state == GameState::Playing) { + ctx->state = GameState::Paused; + updateMessage(ctx); + } else if (ctx->state == GameState::Paused) { + ctx->state = GameState::Playing; + updateMessage(ctx); } } -// ── Power-Up System ────────────────────────────────────────── +/* ── Power-Up System ──────────────────────────────────────────── */ -void Breakout::spawnCapsule(float x, float y) { +static void spawnCapsule(Context* ctx, float x, float y) { for (int i = 0; i < MAX_CAPSULES; i++) { - if (!capsules[i].active) { - capsules[i].active = true; - capsules[i].x = x; - capsules[i].y = y; + if (!ctx->capsules[i].active) { + ctx->capsules[i].active = true; + ctx->capsules[i].x = x; + ctx->capsules[i].y = y; // Random power-up type (ExtraLife rarer) int roll = (int)(esp_random() % 100); if (roll < 5) { - capsules[i].type = PowerUpType::ExtraLife; + ctx->capsules[i].type = PowerUpType::ExtraLife; } else if (roll < 18) { - capsules[i].type = PowerUpType::Laser; + ctx->capsules[i].type = PowerUpType::Laser; } else if (roll < 33) { - capsules[i].type = PowerUpType::Extend; + ctx->capsules[i].type = PowerUpType::Extend; } else if (roll < 48) { - capsules[i].type = PowerUpType::Catch; + ctx->capsules[i].type = PowerUpType::Catch; } else if (roll < 63) { - capsules[i].type = PowerUpType::Slow; + ctx->capsules[i].type = PowerUpType::Slow; } else if (roll < 78) { - capsules[i].type = PowerUpType::Split; + ctx->capsules[i].type = PowerUpType::Split; } else { - capsules[i].type = PowerUpType::BreakOut; + ctx->capsules[i].type = PowerUpType::BreakOut; } - int typeIdx = static_cast(capsules[i].type); - if (capsuleObjs[i]) { - lv_obj_set_style_bg_color(capsuleObjs[i], lv_color_hex(CAPSULE_COLORS[typeIdx]), 0); - lv_obj_set_pos(capsuleObjs[i], (int)x, (int)y); - lv_obj_clear_flag(capsuleObjs[i], LV_OBJ_FLAG_HIDDEN); + int typeIdx = static_cast(ctx->capsules[i].type); + if (ctx->capsuleObjs[i]) { + lv_obj_set_style_bg_color(ctx->capsuleObjs[i], lv_color_hex(CAPSULE_COLORS[typeIdx]), 0); + lv_obj_set_pos(ctx->capsuleObjs[i], (int)x, (int)y); + lv_obj_clear_flag(ctx->capsuleObjs[i], LV_OBJ_FLAG_HIDDEN); } - if (capsuleLabels[i]) { - lv_label_set_text(capsuleLabels[i], CAPSULE_LETTERS[typeIdx]); + if (ctx->capsuleLabels[i]) { + lv_label_set_text(ctx->capsuleLabels[i], CAPSULE_LETTERS[typeIdx]); } return; } } } -void Breakout::updateCapsules() { +static void updateCapsules(Context* ctx) { for (int i = 0; i < MAX_CAPSULES; i++) { - if (!capsules[i].active) continue; + if (!ctx->capsules[i].active) continue; - capsules[i].y += capsuleFallSpeed; + ctx->capsules[i].y += ctx->capsuleFallSpeed; // Off screen - if (capsules[i].y > areaH) { - capsules[i].active = false; - if (capsuleObjs[i]) lv_obj_add_flag(capsuleObjs[i], LV_OBJ_FLAG_HIDDEN); + if (ctx->capsules[i].y > ctx->areaH) { + ctx->capsules[i].active = false; + if (ctx->capsuleObjs[i]) lv_obj_add_flag(ctx->capsuleObjs[i], LV_OBJ_FLAG_HIDDEN); continue; } // Paddle collision - if (capsules[i].y + capsuleH > paddleYPos && - capsules[i].y < paddleYPos + paddleH && - capsules[i].x + capsuleW > paddleX && - capsules[i].x < paddleX + paddleW) { - activatePowerUp(capsules[i].type); - capsules[i].active = false; - if (capsuleObjs[i]) lv_obj_add_flag(capsuleObjs[i], LV_OBJ_FLAG_HIDDEN); + if (ctx->capsules[i].y + ctx->capsuleH > ctx->paddleYPos && + ctx->capsules[i].y < ctx->paddleYPos + ctx->paddleH && + ctx->capsules[i].x + ctx->capsuleW > ctx->paddleX && + ctx->capsules[i].x < ctx->paddleX + ctx->paddleW) { + activatePowerUp(ctx, ctx->capsules[i].type); + ctx->capsules[i].active = false; + if (ctx->capsuleObjs[i]) lv_obj_add_flag(ctx->capsuleObjs[i], LV_OBJ_FLAG_HIDDEN); continue; } - if (capsuleObjs[i]) lv_obj_set_pos(capsuleObjs[i], (int)capsules[i].x, (int)capsules[i].y); + if (ctx->capsuleObjs[i]) lv_obj_set_pos(ctx->capsuleObjs[i], (int)ctx->capsules[i].x, (int)ctx->capsules[i].y); } } -void Breakout::activatePowerUp(PowerUpType type) { - if (sfxEngine) sfxEngine->play(SfxId::Powerup); +static void activatePowerUp(Context* ctx, PowerUpType type) { + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Powerup); switch (type) { case PowerUpType::Extend: - if (!extendActive) { - extendActive = true; - paddleW = (int)(originalPaddleW * 1.5f); - int maxW = areaW / 2; - if (paddleW > maxW) paddleW = maxW; - if (paddle) lv_obj_set_width(paddle, paddleW); + if (!ctx->extendActive) { + ctx->extendActive = true; + ctx->paddleW = (int)(ctx->originalPaddleW * 1.5f); + int maxW = ctx->areaW / 2; + if (ctx->paddleW > maxW) ctx->paddleW = maxW; + if (ctx->paddle) lv_obj_set_width(ctx->paddle, ctx->paddleW); // Re-clamp paddle position - if (paddleX + paddleW > areaW) paddleX = (float)(areaW - paddleW); - if (paddle) lv_obj_set_x(paddle, (int)paddleX); + if (ctx->paddleX + ctx->paddleW > ctx->areaW) ctx->paddleX = (float)(ctx->areaW - ctx->paddleW); + if (ctx->paddle) lv_obj_set_x(ctx->paddle, (int)ctx->paddleX); } break; case PowerUpType::ExtraLife: - lives++; - if (sfxEngine) sfxEngine->play(SfxId::OneUp); - updateScoreDisplay(); + ctx->lives++; + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::OneUp); + updateScoreDisplay(ctx); break; case PowerUpType::Slow: - if (slowRecoveryTicks <= 0) { + if (ctx->slowRecoveryTicks <= 0) { // First slow: save speed and apply reduction - originalBallSpeed = ballSpeed; - ballSpeed *= 0.6f; + ctx->originalBallSpeed = ctx->ballSpeed; + ctx->ballSpeed *= 0.6f; // Scale all active ball velocities for (int b = 0; b < MAX_BALLS; b++) { - if (!balls[b].active) continue; - float curSpd = std::sqrt(balls[b].vx * balls[b].vx + balls[b].vy * balls[b].vy); + if (!ctx->balls[b].active) continue; + float curSpd = std::sqrt(ctx->balls[b].vx * ctx->balls[b].vx + ctx->balls[b].vy * ctx->balls[b].vy); if (curSpd > 0.01f) { - float scale = ballSpeed / curSpd; - balls[b].vx *= scale; - balls[b].vy *= scale; + float scale = ctx->ballSpeed / curSpd; + ctx->balls[b].vx *= scale; + ctx->balls[b].vy *= scale; } } } // Reset (or extend) recovery timer - slowRecoveryTicks = SLOW_RECOVERY_TICKS; + ctx->slowRecoveryTicks = SLOW_RECOVERY_TICKS; break; case PowerUpType::Catch: - catchActive = true; - catchBallIndex = -1; + ctx->catchActive = true; + ctx->catchBallIndex = -1; break; case PowerUpType::Split: - splitBalls(); + splitBalls(ctx); break; case PowerUpType::Laser: - laserActive = true; - laserCooldown = 0; + ctx->laserActive = true; + ctx->laserCooldown = 0; break; case PowerUpType::BreakOut: - openExit(); + openExit(ctx); break; } } -void Breakout::clearPowerUps() { +static void clearPowerUps(Context* ctx) { // Reset extend - if (extendActive) { - extendActive = false; - paddleW = originalPaddleW; - if (paddle) lv_obj_set_width(paddle, paddleW); + if (ctx->extendActive) { + ctx->extendActive = false; + ctx->paddleW = ctx->originalPaddleW; + if (ctx->paddle) lv_obj_set_width(ctx->paddle, ctx->paddleW); } // Reset catch - catchActive = false; - catchBallIndex = -1; - catchAutoReleaseTicks = 0; + ctx->catchActive = false; + ctx->catchBallIndex = -1; + ctx->catchAutoReleaseTicks = 0; // Reset slow - if (slowRecoveryTicks > 0) { - ballSpeed = baseBallSpeed + (level - 1) * 0.3f; - slowRecoveryTicks = 0; + if (ctx->slowRecoveryTicks > 0) { + ctx->ballSpeed = ctx->baseBallSpeed + (ctx->level - 1) * 0.3f; + ctx->slowRecoveryTicks = 0; } // Reset laser - laserActive = false; - laserCooldown = 0; + ctx->laserActive = false; + ctx->laserCooldown = 0; for (int i = 0; i < MAX_LASERS; i++) { - lasers[i].active = false; - if (lasers[i].obj) lv_obj_add_flag(lasers[i].obj, LV_OBJ_FLAG_HIDDEN); + ctx->lasers[i].active = false; + if (ctx->lasers[i].obj) lv_obj_add_flag(ctx->lasers[i].obj, LV_OBJ_FLAG_HIDDEN); } // Clear capsules for (int i = 0; i < MAX_CAPSULES; i++) { - capsules[i].active = false; - if (capsuleObjs[i]) lv_obj_add_flag(capsuleObjs[i], LV_OBJ_FLAG_HIDDEN); + ctx->capsules[i].active = false; + if (ctx->capsuleObjs[i]) lv_obj_add_flag(ctx->capsuleObjs[i], LV_OBJ_FLAG_HIDDEN); } // Close exit - closeExit(); + closeExit(ctx); } -// ── Multi-Ball ─────────────────────────────────────────────── +/* ── Multi-Ball ───────────────────────────────────────────────── */ -void Breakout::splitBalls() { +static void splitBalls(Context* ctx) { // Find first active ball to split from int sourceIdx = -1; for (int i = 0; i < MAX_BALLS; i++) { - if (balls[i].active) { sourceIdx = i; break; } + if (ctx->balls[i].active) { sourceIdx = i; break; } } if (sourceIdx < 0) return; - BallState& src = balls[sourceIdx]; + BallState& src = ctx->balls[sourceIdx]; int spawned = 0; for (int i = 0; i < MAX_BALLS && spawned < 2; i++) { - if (balls[i].active) continue; - balls[i].active = true; - balls[i].x = src.x; - balls[i].y = src.y; + if (ctx->balls[i].active) continue; + ctx->balls[i].active = true; + ctx->balls[i].x = src.x; + ctx->balls[i].y = src.y; // Diverging angles: +30 and -30 degrees from source float angle = (spawned == 0) ? 0.5f : -0.5f; float speed = std::sqrt(src.vx * src.vx + src.vy * src.vy); - if (speed < 0.01f) speed = ballSpeed; + if (speed < 0.01f) speed = ctx->ballSpeed; float srcAngle = std::atan2(src.vy, src.vx); - balls[i].vx = speed * std::cos(srcAngle + angle); - balls[i].vy = speed * std::sin(srcAngle + angle); + ctx->balls[i].vx = speed * std::cos(srcAngle + angle); + ctx->balls[i].vy = speed * std::sin(srcAngle + angle); - if (balls[i].obj) { - lv_obj_set_pos(balls[i].obj, (int)balls[i].x, (int)balls[i].y); - lv_obj_clear_flag(balls[i].obj, LV_OBJ_FLAG_HIDDEN); + if (ctx->balls[i].obj) { + lv_obj_set_pos(ctx->balls[i].obj, (int)ctx->balls[i].x, (int)ctx->balls[i].y); + lv_obj_clear_flag(ctx->balls[i].obj, LV_OBJ_FLAG_HIDDEN); } spawned++; } - activeBallCount += spawned; + ctx->activeBallCount += spawned; } -void Breakout::updateBalls() { +static void updateBalls(Context* ctx) { for (int b = 0; b < MAX_BALLS; b++) { - if (!balls[b].active) continue; + if (!ctx->balls[b].active) continue; // Caught ball follows paddle - if (catchActive && catchBallIndex == b) { - balls[b].x = paddleX + catchOffsetX; - balls[b].y = (float)(paddleYPos - ballSize - 2); - if (balls[b].obj) lv_obj_set_pos(balls[b].obj, (int)balls[b].x, (int)balls[b].y); + if (ctx->catchActive && ctx->catchBallIndex == b) { + ctx->balls[b].x = ctx->paddleX + ctx->catchOffsetX; + ctx->balls[b].y = (float)(ctx->paddleYPos - ctx->ballSize - 2); + if (ctx->balls[b].obj) lv_obj_set_pos(ctx->balls[b].obj, (int)ctx->balls[b].x, (int)ctx->balls[b].y); - catchAutoReleaseTicks++; - if (catchAutoReleaseTicks >= CATCH_AUTO_RELEASE_TICKS) { + ctx->catchAutoReleaseTicks++; + if (ctx->catchAutoReleaseTicks >= CATCH_AUTO_RELEASE_TICKS) { // Auto-release - balls[b].vx = (esp_random() % 2 ? 1.0f : -1.0f) * ballSpeed * 0.7f; - balls[b].vy = -ballSpeed; - catchBallIndex = -1; - catchAutoReleaseTicks = 0; + ctx->balls[b].vx = (esp_random() % 2 ? 1.0f : -1.0f) * ctx->ballSpeed * 0.7f; + ctx->balls[b].vy = -ctx->ballSpeed; + ctx->catchBallIndex = -1; + ctx->catchAutoReleaseTicks = 0; } continue; } // Move ball - balls[b].x += balls[b].vx; - balls[b].y += balls[b].vy; + ctx->balls[b].x += ctx->balls[b].vx; + ctx->balls[b].y += ctx->balls[b].vy; // Left wall collision - if (balls[b].x < 0) { - balls[b].x = 0; - balls[b].vx = -balls[b].vx; - if (sfxEngine) sfxEngine->play(SfxId::Click); + if (ctx->balls[b].x < 0) { + ctx->balls[b].x = 0; + ctx->balls[b].vx = -ctx->balls[b].vx; + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Click); } // Right wall collision (ball always bounces, exit is paddle-only) - if (balls[b].x + ballSize > areaW) { - balls[b].x = (float)(areaW - ballSize); - balls[b].vx = -balls[b].vx; - if (sfxEngine) sfxEngine->play(SfxId::Click); + if (ctx->balls[b].x + ctx->ballSize > ctx->areaW) { + ctx->balls[b].x = (float)(ctx->areaW - ctx->ballSize); + ctx->balls[b].vx = -ctx->balls[b].vx; + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Click); } // Top wall - if (balls[b].y < 0) { - balls[b].y = 0; - balls[b].vy = -balls[b].vy; - if (sfxEngine) sfxEngine->play(SfxId::Click); + if (ctx->balls[b].y < 0) { + ctx->balls[b].y = 0; + ctx->balls[b].vy = -ctx->balls[b].vy; + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Click); } // Bottom edge - if (balls[b].y + ballSize > areaH) { - balls[b].active = false; - if (balls[b].obj) lv_obj_add_flag(balls[b].obj, LV_OBJ_FLAG_HIDDEN); - activeBallCount--; - if (activeBallCount <= 0) { - activeBallCount = 0; - loseLife(); + if (ctx->balls[b].y + ctx->ballSize > ctx->areaH) { + ctx->balls[b].active = false; + if (ctx->balls[b].obj) lv_obj_add_flag(ctx->balls[b].obj, LV_OBJ_FLAG_HIDDEN); + ctx->activeBallCount--; + if (ctx->activeBallCount <= 0) { + ctx->activeBallCount = 0; + loseLife(ctx); return; } continue; } // Paddle collision - if (balls[b].vy > 0 && - balls[b].x + ballSize > paddleX && balls[b].x < paddleX + paddleW && - balls[b].y + ballSize > paddleYPos && balls[b].y < paddleYPos + paddleH) { + if (ctx->balls[b].vy > 0 && + ctx->balls[b].x + ctx->ballSize > ctx->paddleX && ctx->balls[b].x < ctx->paddleX + ctx->paddleW && + ctx->balls[b].y + ctx->ballSize > ctx->paddleYPos && ctx->balls[b].y < ctx->paddleYPos + ctx->paddleH) { - if (catchActive && catchBallIndex < 0) { + if (ctx->catchActive && ctx->catchBallIndex < 0) { // Catch the ball - catchBallIndex = b; - catchOffsetX = balls[b].x - paddleX; - catchAutoReleaseTicks = 0; - balls[b].vx = 0; - balls[b].vy = 0; - balls[b].y = (float)(paddleYPos - ballSize - 2); - if (sfxEngine) sfxEngine->play(SfxId::Click); + ctx->catchBallIndex = b; + ctx->catchOffsetX = ctx->balls[b].x - ctx->paddleX; + ctx->catchAutoReleaseTicks = 0; + ctx->balls[b].vx = 0; + ctx->balls[b].vy = 0; + ctx->balls[b].y = (float)(ctx->paddleYPos - ctx->ballSize - 2); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Click); } else { // Normal bounce - float hitPos = (balls[b].x + ballSize / 2.0f - paddleX) / (float)paddleW; + float hitPos = (ctx->balls[b].x + ctx->ballSize / 2.0f - ctx->paddleX) / (float)ctx->paddleW; float angle = (hitPos - 0.5f) * 2.0f; - balls[b].vx = angle * ballSpeed; - balls[b].vy = -std::fabs(balls[b].vy); - if (std::fabs(balls[b].vy) < ballSpeed * 0.3f) { - balls[b].vy = -ballSpeed * 0.3f; + ctx->balls[b].vx = angle * ctx->ballSpeed; + ctx->balls[b].vy = -std::fabs(ctx->balls[b].vy); + if (std::fabs(ctx->balls[b].vy) < ctx->ballSpeed * 0.3f) { + ctx->balls[b].vy = -ctx->ballSpeed * 0.3f; } - balls[b].y = (float)(paddleYPos - ballSize); - if (sfxEngine) sfxEngine->play(SfxId::Click); + ctx->balls[b].y = (float)(ctx->paddleYPos - ctx->ballSize); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Click); } } // Brick collisions for this ball - for (int r = 0; r < rows; r++) { - for (int c = 0; c < cols; c++) { - int idx = r * cols + c; - if (!brickAlive[idx]) continue; + for (int r = 0; r < ctx->rows; r++) { + for (int c = 0; c < ctx->cols; c++) { + int idx = r * ctx->cols + c; + if (!ctx->brickAlive[idx]) continue; - float bx = (float)(brickOffsetX + c * (brickW + brickGap)); - float by = (float)(brickOffsetY + r * (brickH + brickGap)); + float bx = (float)(ctx->brickOffsetX + c * (ctx->brickW + ctx->brickGap)); + float by = (float)(ctx->brickOffsetY + r * (ctx->brickH + ctx->brickGap)); - if (balls[b].x + ballSize > bx && balls[b].x < bx + brickW && - balls[b].y + ballSize > by && balls[b].y < by + brickH) { + if (ctx->balls[b].x + ctx->ballSize > bx && ctx->balls[b].x < bx + ctx->brickW && + ctx->balls[b].y + ctx->ballSize > by && ctx->balls[b].y < by + ctx->brickH) { - hitBrick(idx); + hitBrick(ctx, idx); // Bounce direction - float overlapLeft = balls[b].x + ballSize - bx; - float overlapRight = bx + brickW - balls[b].x; - float overlapTop = balls[b].y + ballSize - by; - float overlapBottom = by + brickH - balls[b].y; + float overlapLeft = ctx->balls[b].x + ctx->ballSize - bx; + float overlapRight = bx + ctx->brickW - ctx->balls[b].x; + float overlapTop = ctx->balls[b].y + ctx->ballSize - by; + float overlapBottom = by + ctx->brickH - ctx->balls[b].y; float minOverlapX = overlapLeft < overlapRight ? overlapLeft : overlapRight; float minOverlapY = overlapTop < overlapBottom ? overlapTop : overlapBottom; if (minOverlapX < minOverlapY) { - balls[b].vx = -balls[b].vx; + ctx->balls[b].vx = -ctx->balls[b].vx; } else { - balls[b].vy = -balls[b].vy; + ctx->balls[b].vy = -ctx->balls[b].vy; } goto nextBall; // One brick per ball per tick @@ -1171,77 +1238,77 @@ void Breakout::updateBalls() { } nextBall: - if (balls[b].obj && balls[b].active) { - lv_obj_set_pos(balls[b].obj, (int)balls[b].x, (int)balls[b].y); + if (ctx->balls[b].obj && ctx->balls[b].active) { + lv_obj_set_pos(ctx->balls[b].obj, (int)ctx->balls[b].x, (int)ctx->balls[b].y); } } } -// ── Laser System ───────────────────────────────────────────── +/* ── Laser System ─────────────────────────────────────────────── */ -void Breakout::fireLaser() { +static void fireLaser(Context* ctx) { for (int i = 0; i < MAX_LASERS; i++) { - if (!lasers[i].active) { - lasers[i].active = true; - lasers[i].x = paddleX + paddleW / 2.0f - laserW / 2.0f; - lasers[i].y = (float)(paddleYPos - laserH); - if (lasers[i].obj) { - lv_obj_set_pos(lasers[i].obj, (int)lasers[i].x, (int)lasers[i].y); - lv_obj_clear_flag(lasers[i].obj, LV_OBJ_FLAG_HIDDEN); + if (!ctx->lasers[i].active) { + ctx->lasers[i].active = true; + ctx->lasers[i].x = ctx->paddleX + ctx->paddleW / 2.0f - ctx->laserW / 2.0f; + ctx->lasers[i].y = (float)(ctx->paddleYPos - ctx->laserH); + if (ctx->lasers[i].obj) { + lv_obj_set_pos(ctx->lasers[i].obj, (int)ctx->lasers[i].x, (int)ctx->lasers[i].y); + lv_obj_clear_flag(ctx->lasers[i].obj, LV_OBJ_FLAG_HIDDEN); } - if (sfxEngine) sfxEngine->play(SfxId::Laser); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Laser); return; } } } -void Breakout::updateLasers() { - if (!laserActive) return; +static void updateLasers(Context* ctx) { + if (!ctx->laserActive) return; // Auto-fire - laserCooldown--; - if (laserCooldown <= 0) { - fireLaser(); - laserCooldown = LASER_COOLDOWN_TICKS; + ctx->laserCooldown--; + if (ctx->laserCooldown <= 0) { + fireLaser(ctx); + ctx->laserCooldown = LASER_COOLDOWN_TICKS; } for (int i = 0; i < MAX_LASERS; i++) { - if (!lasers[i].active) continue; + if (!ctx->lasers[i].active) continue; - lasers[i].y -= laserSpeed; + ctx->lasers[i].y -= ctx->laserSpeed; - if (lasers[i].y + laserH < 0) { - lasers[i].active = false; - if (lasers[i].obj) lv_obj_add_flag(lasers[i].obj, LV_OBJ_FLAG_HIDDEN); + if (ctx->lasers[i].y + ctx->laserH < 0) { + ctx->lasers[i].active = false; + if (ctx->lasers[i].obj) lv_obj_add_flag(ctx->lasers[i].obj, LV_OBJ_FLAG_HIDDEN); continue; } - if (lasers[i].obj && lasers[i].active) { - lv_obj_set_pos(lasers[i].obj, (int)lasers[i].x, (int)lasers[i].y); + if (ctx->lasers[i].obj && ctx->lasers[i].active) { + lv_obj_set_pos(ctx->lasers[i].obj, (int)ctx->lasers[i].x, (int)ctx->lasers[i].y); } } // Check all laser-brick collisions once per tick - checkLaserBrickCollisions(); + checkLaserBrickCollisions(ctx); } -void Breakout::checkLaserBrickCollisions() { +static void checkLaserBrickCollisions(Context* ctx) { for (int li = 0; li < MAX_LASERS; li++) { - if (!lasers[li].active) continue; + if (!ctx->lasers[li].active) continue; - for (int r = 0; r < rows; r++) { - for (int c = 0; c < cols; c++) { - int idx = r * cols + c; - if (!brickAlive[idx]) continue; + for (int r = 0; r < ctx->rows; r++) { + for (int c = 0; c < ctx->cols; c++) { + int idx = r * ctx->cols + c; + if (!ctx->brickAlive[idx]) continue; - float bx = (float)(brickOffsetX + c * (brickW + brickGap)); - float by = (float)(brickOffsetY + r * (brickH + brickGap)); + float bx = (float)(ctx->brickOffsetX + c * (ctx->brickW + ctx->brickGap)); + float by = (float)(ctx->brickOffsetY + r * (ctx->brickH + ctx->brickGap)); - if (lasers[li].x + laserW > bx && lasers[li].x < bx + brickW && - lasers[li].y + laserH > by && lasers[li].y < by + brickH) { - hitBrick(idx); - lasers[li].active = false; - if (lasers[li].obj) lv_obj_add_flag(lasers[li].obj, LV_OBJ_FLAG_HIDDEN); + if (ctx->lasers[li].x + ctx->laserW > bx && ctx->lasers[li].x < bx + ctx->brickW && + ctx->lasers[li].y + ctx->laserH > by && ctx->lasers[li].y < by + ctx->brickH) { + hitBrick(ctx, idx); + ctx->lasers[li].active = false; + if (ctx->lasers[li].obj) lv_obj_add_flag(ctx->lasers[li].obj, LV_OBJ_FLAG_HIDDEN); goto nextLaser; } } @@ -1250,178 +1317,178 @@ void Breakout::checkLaserBrickCollisions() { } } -// ── BreakOut Exit ──────────────────────────────────────────── +/* ── BreakOut Exit ────────────────────────────────────────────── */ -void Breakout::openExit() { - exitOpen = true; - if (exitIndicator) lv_obj_clear_flag(exitIndicator, LV_OBJ_FLAG_HIDDEN); +static void openExit(Context* ctx) { + ctx->exitOpen = true; + if (ctx->exitIndicator) lv_obj_clear_flag(ctx->exitIndicator, LV_OBJ_FLAG_HIDDEN); } -void Breakout::closeExit() { - exitOpen = false; - if (exitIndicator) lv_obj_add_flag(exitIndicator, LV_OBJ_FLAG_HIDDEN); +static void closeExit(Context* ctx) { + ctx->exitOpen = false; + if (ctx->exitIndicator) lv_obj_add_flag(ctx->exitIndicator, LV_OBJ_FLAG_HIDDEN); } -// ── Main Game Tick ─────────────────────────────────────────── +/* ── Main Game Tick ───────────────────────────────────────────── */ -void Breakout::update() { - if (state == GameState::Ready) { +static void update(Context* ctx) { + if (ctx->state == GameState::Ready) { // Ball follows paddle - balls[0].x = paddleX + paddleW / 2.0f - ballSize / 2.0f; - if (balls[0].obj) lv_obj_set_x(balls[0].obj, (int)balls[0].x); + ctx->balls[0].x = ctx->paddleX + ctx->paddleW / 2.0f - ctx->ballSize / 2.0f; + if (ctx->balls[0].obj) lv_obj_set_x(ctx->balls[0].obj, (int)ctx->balls[0].x); return; } - if (state != GameState::Playing) return; + if (ctx->state != GameState::Playing) return; // Slow ball recovery - if (slowRecoveryTicks > 0) { - slowRecoveryTicks--; - if (slowRecoveryTicks <= 0) { + if (ctx->slowRecoveryTicks > 0) { + ctx->slowRecoveryTicks--; + if (ctx->slowRecoveryTicks <= 0) { // Restore normal speed - float targetSpeed = baseBallSpeed + (level - 1) * 0.3f; - ballSpeed = targetSpeed; + float targetSpeed = ctx->baseBallSpeed + (ctx->level - 1) * 0.3f; + ctx->ballSpeed = targetSpeed; } else { // Gradually recover speed - float targetSpeed = baseBallSpeed + (level - 1) * 0.3f; - float progress = 1.0f - (float)slowRecoveryTicks / SLOW_RECOVERY_TICKS; - ballSpeed = originalBallSpeed * 0.6f + (targetSpeed - originalBallSpeed * 0.6f) * progress; + float targetSpeed = ctx->baseBallSpeed + (ctx->level - 1) * 0.3f; + float progress = 1.0f - (float)ctx->slowRecoveryTicks / SLOW_RECOVERY_TICKS; + ctx->ballSpeed = ctx->originalBallSpeed * 0.6f + (targetSpeed - ctx->originalBallSpeed * 0.6f) * progress; } } // Update all balls (movement, collisions) - updateBalls(); + updateBalls(ctx); // Check if paddle reaches BreakOut exit - if (exitOpen && paddleX + paddleW >= areaW - 8) { - score += 10000; - updateScoreDisplay(); - saveHighScore(score); - if (sfxEngine) sfxEngine->play(SfxId::Warp); - winLevel(); + if (ctx->exitOpen && ctx->paddleX + ctx->paddleW >= ctx->areaW - 8) { + ctx->score += 10000; + updateScoreDisplay(ctx); + saveHighScore(ctx->score); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Warp); + winLevel(ctx); return; } // Update capsules - updateCapsules(); + updateCapsules(ctx); // Update lasers - updateLasers(); + updateLasers(ctx); } -// ── Display Updates ────────────────────────────────────────── +/* ── Display Updates ──────────────────────────────────────────── */ -void Breakout::updateScoreDisplay() { - if (scoreLabel) { - if (level > 1) { - lv_label_set_text_fmt(scoreLabel, "L%d: %d", level, score); +static void updateScoreDisplay(Context* ctx) { + if (ctx->scoreLabel) { + if (ctx->level > 1) { + lv_label_set_text_fmt(ctx->scoreLabel, "L%d: %d", ctx->level, ctx->score); } else { - lv_label_set_text_fmt(scoreLabel, "SCORE: %d", score); + lv_label_set_text_fmt(ctx->scoreLabel, "SCORE: %d", ctx->score); } } - if (livesLabel) { - lv_label_set_text_fmt(livesLabel, "L: %d", lives); + if (ctx->livesLabel) { + lv_label_set_text_fmt(ctx->livesLabel, "L: %d", ctx->lives); } } -void Breakout::updateMessage() { - if (!messageLabel) return; +static void updateMessage(Context* ctx) { + if (!ctx->messageLabel) return; - switch (state) { + switch (ctx->state) { case GameState::Ready: { char buf[64]; const char* input_hint = "Touch"; if (device_has_active_by_type(&KEYBOARD_TYPE)) { input_hint = "Space"; } - if (level > 1) { - snprintf(buf, sizeof(buf), "Level %d\n%s to start!", level, input_hint); + if (ctx->level > 1) { + snprintf(buf, sizeof(buf), "Level %d\n%s to start!", ctx->level, input_hint); } else if (highScore > 0) { snprintf(buf, sizeof(buf), "%s to start!\nBest Score: %d", input_hint, (int)highScore); } else { snprintf(buf, sizeof(buf), "%s to start!", input_hint); } - lv_label_set_text(messageLabel, buf); - lv_obj_clear_flag(messageLabel, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(ctx->messageLabel, buf); + lv_obj_clear_flag(ctx->messageLabel, LV_OBJ_FLAG_HIDDEN); break; } case GameState::Playing: - lv_obj_add_flag(messageLabel, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(ctx->messageLabel, LV_OBJ_FLAG_HIDDEN); break; case GameState::Paused: - lv_label_set_text(messageLabel, "PAUSED"); - lv_obj_clear_flag(messageLabel, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(ctx->messageLabel, "PAUSED"); + lv_obj_clear_flag(ctx->messageLabel, LV_OBJ_FLAG_HIDDEN); break; case GameState::GameOver: { char buf[64]; - if (score > highScore && score > 0) { - snprintf(buf, sizeof(buf), "NEW HIGH SCORE!\n%d", score); + if (ctx->score > highScore && ctx->score > 0) { + snprintf(buf, sizeof(buf), "NEW HIGH SCORE!\n%d", ctx->score); } else { - snprintf(buf, sizeof(buf), "Game Over\nScore: %d\nBest Score: %d", score, (int)highScore); + snprintf(buf, sizeof(buf), "Game Over\nScore: %d\nBest Score: %d", ctx->score, (int)highScore); } - lv_label_set_text(messageLabel, buf); - lv_obj_clear_flag(messageLabel, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(ctx->messageLabel, buf); + lv_obj_clear_flag(ctx->messageLabel, LV_OBJ_FLAG_HIDDEN); break; } } - lv_obj_center(messageLabel); + lv_obj_center(ctx->messageLabel); } -void Breakout::updateSoundIcon() { - if (soundBtnIcon) { - lv_label_set_text(soundBtnIcon, soundEnabled ? LV_SYMBOL_VOLUME_MAX : LV_SYMBOL_MUTE); +static void updateSoundIcon(Context* ctx) { + if (ctx->soundBtnIcon) { + lv_label_set_text(ctx->soundBtnIcon, soundEnabled ? LV_SYMBOL_VOLUME_MAX : LV_SYMBOL_MUTE); } } -// ── Event Callbacks ────────────────────────────────────────── +/* ── Event Callbacks ──────────────────────────────────────────── */ -void Breakout::onTick(lv_timer_t* timer) { - Breakout* self = static_cast(lv_timer_get_user_data(timer)); - if (self) self->update(); +static void onTick(lv_timer_t* timer) { + auto* ctx = static_cast(lv_timer_get_user_data(timer)); + if (ctx) update(ctx); } -void Breakout::onPressed(lv_event_t* e) { - Breakout* self = static_cast(lv_event_get_user_data(e)); - if (!self || !self->paddle) return; - if (self->state == GameState::GameOver || self->state == GameState::Paused) return; +static void onPressed(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (!ctx || !ctx->paddle) return; + if (ctx->state == GameState::GameOver || ctx->state == GameState::Paused) return; lv_point_t point; lv_indev_get_point(lv_indev_active(), &point); // Move paddle to touch X (centered on finger) - self->paddleX = (float)(point.x - self->paddleW / 2); + ctx->paddleX = (float)(point.x - ctx->paddleW / 2); // Clamp to game area bounds - if (self->paddleX < 0) self->paddleX = 0; - if (self->paddleX + self->paddleW > self->areaW) - self->paddleX = (float)(self->areaW - self->paddleW); + if (ctx->paddleX < 0) ctx->paddleX = 0; + if (ctx->paddleX + ctx->paddleW > ctx->areaW) + ctx->paddleX = (float)(ctx->areaW - ctx->paddleW); - lv_obj_set_x(self->paddle, (int)self->paddleX); + lv_obj_set_x(ctx->paddle, (int)ctx->paddleX); // In ready state, ball follows paddle - if (self->state == GameState::Ready) { - self->balls[0].x = self->paddleX + self->paddleW / 2.0f - self->ballSize / 2.0f; - if (self->balls[0].obj) lv_obj_set_x(self->balls[0].obj, (int)self->balls[0].x); + if (ctx->state == GameState::Ready) { + ctx->balls[0].x = ctx->paddleX + ctx->paddleW / 2.0f - ctx->ballSize / 2.0f; + if (ctx->balls[0].obj) lv_obj_set_x(ctx->balls[0].obj, (int)ctx->balls[0].x); } } -void Breakout::onClicked(lv_event_t* e) { - Breakout* self = static_cast(lv_event_get_user_data(e)); - if (!self) return; - - if (self->state == GameState::Ready) { - self->launchBall(); - } else if (self->state == GameState::GameOver) { - self->startGame(); - } else if (self->state == GameState::Paused) { - self->togglePause(); - } else if (self->state == GameState::Playing && self->catchActive && self->catchBallIndex >= 0) { - self->launchBall(); +static void onClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (!ctx) return; + + if (ctx->state == GameState::Ready) { + launchBall(ctx); + } else if (ctx->state == GameState::GameOver) { + startGame(ctx); + } else if (ctx->state == GameState::Paused) { + togglePause(ctx); + } else if (ctx->state == GameState::Playing && ctx->catchActive && ctx->catchBallIndex >= 0) { + launchBall(ctx); } } -void Breakout::onKey(lv_event_t* e) { - Breakout* self = static_cast(lv_event_get_user_data(e)); - if (!self) return; +static void onKey(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (!ctx) return; uint32_t key = lv_event_get_key(e); @@ -1430,38 +1497,38 @@ void Breakout::onKey(lv_event_t* e) { case 'a': case 'A': case ',': - if (self->state == GameState::GameOver || self->state == GameState::Paused) break; - self->paddleX -= self->paddleSpeed; - if (self->paddleX < 0) self->paddleX = 0; - if (self->paddle) lv_obj_set_x(self->paddle, (int)self->paddleX); - if (self->state == GameState::Ready) self->resetBall(); + if (ctx->state == GameState::GameOver || ctx->state == GameState::Paused) break; + ctx->paddleX -= ctx->paddleSpeed; + if (ctx->paddleX < 0) ctx->paddleX = 0; + if (ctx->paddle) lv_obj_set_x(ctx->paddle, (int)ctx->paddleX); + if (ctx->state == GameState::Ready) resetBall(ctx); break; case LV_KEY_RIGHT: case 'd': case 'D': case '/': - if (self->state == GameState::GameOver || self->state == GameState::Paused) break; - self->paddleX += self->paddleSpeed; - if (self->paddleX + self->paddleW > self->areaW) - self->paddleX = (float)(self->areaW - self->paddleW); - if (self->paddle) lv_obj_set_x(self->paddle, (int)self->paddleX); - if (self->state == GameState::Ready) self->resetBall(); + if (ctx->state == GameState::GameOver || ctx->state == GameState::Paused) break; + ctx->paddleX += ctx->paddleSpeed; + if (ctx->paddleX + ctx->paddleW > ctx->areaW) + ctx->paddleX = (float)(ctx->areaW - ctx->paddleW); + if (ctx->paddle) lv_obj_set_x(ctx->paddle, (int)ctx->paddleX); + if (ctx->state == GameState::Ready) resetBall(ctx); break; case LV_KEY_ENTER: case ' ': - if (self->state == GameState::Ready) { - self->launchBall(); - } else if (self->state == GameState::GameOver) { - self->startGame(); - } else if (self->state == GameState::Paused) { - self->togglePause(); - } else if (self->state == GameState::Playing) { - if (self->catchActive && self->catchBallIndex >= 0) { - self->launchBall(); + if (ctx->state == GameState::Ready) { + launchBall(ctx); + } else if (ctx->state == GameState::GameOver) { + startGame(ctx); + } else if (ctx->state == GameState::Paused) { + togglePause(ctx); + } else if (ctx->state == GameState::Playing) { + if (ctx->catchActive && ctx->catchBallIndex >= 0) { + launchBall(ctx); } else { - self->togglePause(); + togglePause(ctx); } } break; @@ -1478,22 +1545,22 @@ void Breakout::onKey(lv_event_t* e) { } } -void Breakout::onPauseClicked(lv_event_t* e) { - Breakout* self = static_cast(lv_event_get_user_data(e)); - if (self) self->togglePause(); +static void onPauseClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx) togglePause(ctx); } -void Breakout::onSoundToggled(lv_event_t* e) { - Breakout* self = static_cast(lv_event_get_user_data(e)); - if (!self) return; +static void onSoundToggled(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (!ctx) return; soundEnabled = !soundEnabled; - if (self->sfxEngine) self->sfxEngine->setEnabled(soundEnabled); + if (ctx->sfxEngine) ctx->sfxEngine->setEnabled(soundEnabled); saveSoundSetting(soundEnabled); - self->updateSoundIcon(); + updateSoundIcon(ctx); } -void Breakout::onReenterKeyMode(lv_event_t* e) { +static void onReenterKeyMode(lv_event_t* e) { lv_obj_t* area = lv_event_get_current_target_obj(e); lv_group_t* group = lv_group_get_default(); if (!group) return; diff --git a/Apps/Breakout/main/Source/Breakout.h b/Apps/Breakout/main/Source/Breakout.h index f68f251..ea2c22e 100644 --- a/Apps/Breakout/main/Source/Breakout.h +++ b/Apps/Breakout/main/Source/Breakout.h @@ -4,9 +4,8 @@ */ #pragma once -#include #include -#include +#include class SfxEngine; @@ -44,10 +43,10 @@ struct Laser { lv_obj_t* obj; }; -class Breakout final : public App { +struct Context { + uint32_t appInstanceId; -private: - // UI pointers (nulled in onHide) + // UI pointers (nulled on teardown) lv_obj_t* gameArea = nullptr; lv_obj_t* paddle = nullptr; lv_obj_t* bricks[MAX_BRICKS] = {}; @@ -116,7 +115,7 @@ class Breakout final : public App { // Paddle float paddleX = 0; - // Layout dimensions (calculated in onShow) + // Layout dimensions (calculated on create) int areaW = 0, areaH = 0; int cols = 0, rows = 0; int brickW = 0, brickH = 0; @@ -127,58 +126,10 @@ class Breakout final : public App { int paddleYPos = 0; float ballSpeed = 0; float paddleSpeed = 0; +}; - // Static callbacks - static void onTick(lv_timer_t* timer); - static void onPressed(lv_event_t* e); - static void onClicked(lv_event_t* e); - static void onKey(lv_event_t* e); - static void onReenterKeyMode(lv_event_t* e); - static void onPauseClicked(lv_event_t* e); - static void onSoundToggled(lv_event_t* e); - - // Game logic - void startGame(); - void nextLevel(); - void resetBall(); - void launchBall(); - void update(); - void checkLaserBrickCollisions(); - void loseLife(); - void winLevel(); - void createBricks(); - void setupLevelPattern(); - void refreshBricks(); - void updateScoreDisplay(); - void updateMessage(); - void togglePause(); - void updateSoundIcon(); - - // Capsule system - void spawnCapsule(float x, float y); - void updateCapsules(); - void activatePowerUp(PowerUpType type); - void clearPowerUps(); - void createCapsuleObjs(); - - // Multi-ball - void updateBalls(); - void splitBalls(); - - // Laser - void updateLasers(); - void fireLaser(); - void createLaserObjs(); - - // BreakOut exit - void openExit(); - void closeExit(); - - // Brick helpers - void hitBrick(int idx); - int scoreBrick(int idx); +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void breakoutCreateWidgets(lv_obj_t* parent, void* userData); -public: - void onShow(AppHandle context, lv_obj_t* parent) override; - void onHide(AppHandle context) override; -}; +/** Releases resources acquired while the window was shown (sfx engine, LVGL group/timer). Call once the window is torn down. */ +void breakoutTeardown(Context* ctx); diff --git a/Apps/Breakout/main/Source/main.cpp b/Apps/Breakout/main/Source/main.cpp index 8944f75..5067825 100644 --- a/Apps/Breakout/main/Source/main.cpp +++ b/Apps/Breakout/main/Source/main.cpp @@ -1,10 +1,46 @@ #include "Breakout.h" -#include + +#include +#include +#include + +#include + +#include extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + // Heap-allocated: Context holds several fixed-size arrays (bricks, balls, capsules, + // per-brick state) that are too large to put on the 8192-byte app task stack (see + // app_scheduler.cpp) alongside everything else on it. + auto ctx = std::make_unique(); + ctx->appInstanceId = app_instance_id; + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, breakoutCreateWidgets, ctx.get()); + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(app_instance_id); + should_close = true; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + breakoutTeardown(ctx.get()); + return 0; } diff --git a/Apps/Calculator/CMakeLists.txt b/Apps/Calculator/CMakeLists.txt index 01bfb4b..1fc8491 100644 --- a/Apps/Calculator/CMakeLists.txt +++ b/Apps/Calculator/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(Calculator) tactility_project(Calculator) diff --git a/Apps/Calculator/main/CMakeLists.txt b/Apps/Calculator/main/CMakeLists.txt index 0309010..3d04446 100644 --- a/Apps/Calculator/main/CMakeLists.txt +++ b/Apps/Calculator/main/CMakeLists.txt @@ -4,8 +4,5 @@ file(GLOB_RECURSE SOURCE_FILES idf_component_register( SRCS ${SOURCE_FILES} - # Library headers must be included directly, - # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include REQUIRES TactilitySDK ) diff --git a/Apps/Calculator/main/Source/Calculator.cpp b/Apps/Calculator/main/Source/Calculator.cpp index 3a3e081..7187c81 100644 --- a/Apps/Calculator/main/Source/Calculator.cpp +++ b/Apps/Calculator/main/Source/Calculator.cpp @@ -1,10 +1,13 @@ #include "Calculator.h" +#include + #include +#include #include -#include +#include #include -#include +#include constexpr auto* TAG = "Calculator"; @@ -14,39 +17,7 @@ static int precedence(char op) { return 0; } -void Calculator::button_event_cb(lv_event_t* e) { - Calculator* self = static_cast(lv_event_get_user_data(e)); - lv_obj_t* buttonmatrix = lv_event_get_current_target_obj(e); - lv_event_code_t event_code = lv_event_get_code(e); - uint32_t button_id = lv_buttonmatrix_get_selected_button(buttonmatrix); - const char* button_text = lv_buttonmatrix_get_button_text(buttonmatrix, button_id); - if (event_code == LV_EVENT_VALUE_CHANGED) { - self->handleInput(button_text); - } -} - -void Calculator::handleInput(const char* txt) { - if (strcmp(txt, "C") == 0) { - resetCalculator(); - return; - } - - if (strcmp(txt, "=") == 0) { - evaluateExpression(); - return; - } - - if (strlen(formulaBuffer) + strlen(txt) < sizeof(formulaBuffer) - 1) { - if (newInput) { - memset(formulaBuffer, 0, sizeof(formulaBuffer)); - newInput = false; - } - strcat(formulaBuffer, txt); - lv_label_set_text(displayLabel, formulaBuffer); - } -} - -std::deque Calculator::infixToRPN(const std::string& infix) { +static std::deque infixToRPN(const std::string& infix) { std::stack opStack; std::deque output; std::string token; @@ -87,7 +58,7 @@ std::deque Calculator::infixToRPN(const std::string& infix) { return output; } -double Calculator::evaluateRPN(std::deque rpnQueue) { +static double evaluateRPN(std::deque rpnQueue) { std::stack values; while (!rpnQueue.empty()) { @@ -115,35 +86,72 @@ double Calculator::evaluateRPN(std::deque rpnQueue) { return values.empty() ? 0 : values.top(); } -void Calculator::evaluateExpression() { - double result = computeFormula(); - size_t formulaLen = strlen(formulaBuffer); - size_t maxAvailable = sizeof(formulaBuffer) - formulaLen - 1; +static double computeFormula(Context* ctx) { + return evaluateRPN(infixToRPN(std::string(ctx->formulaBuffer))); +} + +static void resetCalculator(Context* ctx) { + memset(ctx->formulaBuffer, 0, sizeof(ctx->formulaBuffer)); + lv_label_set_text(ctx->displayLabel, "0"); + lv_label_set_text(ctx->resultLabel, ""); + ctx->newInput = true; +} + +static void evaluateExpression(Context* ctx) { + double result = computeFormula(ctx); + + size_t formulaLen = strlen(ctx->formulaBuffer); + size_t maxAvailable = sizeof(ctx->formulaBuffer) - formulaLen - 1; if (maxAvailable > 10) { char resultBuffer[32]; snprintf(resultBuffer, sizeof(resultBuffer), " = %.8g", result); - strncat(formulaBuffer, resultBuffer, maxAvailable); - } else { snprintf(formulaBuffer, sizeof(formulaBuffer), "%.8g", result); } + strncat(ctx->formulaBuffer, resultBuffer, maxAvailable); + } else { + snprintf(ctx->formulaBuffer, sizeof(ctx->formulaBuffer), "%.8g", result); + } - lv_label_set_text(displayLabel, "0"); - lv_label_set_text(resultLabel, formulaBuffer); - newInput = true; + lv_label_set_text(ctx->displayLabel, "0"); + lv_label_set_text(ctx->resultLabel, ctx->formulaBuffer); + ctx->newInput = true; } -double Calculator::computeFormula() { - return evaluateRPN(infixToRPN(std::string(formulaBuffer))); +static void handleInput(Context* ctx, const char* txt) { + if (strcmp(txt, "C") == 0) { + resetCalculator(ctx); + return; + } + + if (strcmp(txt, "=") == 0) { + evaluateExpression(ctx); + return; + } + + if (strlen(ctx->formulaBuffer) + strlen(txt) < sizeof(ctx->formulaBuffer) - 1) { + if (ctx->newInput) { + memset(ctx->formulaBuffer, 0, sizeof(ctx->formulaBuffer)); + ctx->newInput = false; + } + strcat(ctx->formulaBuffer, txt); + lv_label_set_text(ctx->displayLabel, ctx->formulaBuffer); + } } -void Calculator::resetCalculator() { - memset(formulaBuffer, 0, sizeof(formulaBuffer)); - lv_label_set_text(displayLabel, "0"); - lv_label_set_text(resultLabel, ""); - newInput = true; +static void onButtonPressed(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + lv_obj_t* buttonmatrix = lv_event_get_current_target_obj(e); + lv_event_code_t event_code = lv_event_get_code(e); + uint32_t button_id = lv_buttonmatrix_get_selected_button(buttonmatrix); + const char* button_text = lv_buttonmatrix_get_button_text(buttonmatrix, button_id); + if (event_code == LV_EVENT_VALUE_CHANGED) { + handleInput(ctx, button_text); + } } -void Calculator::onShow(AppHandle appHandle, lv_obj_t* parent) { +void calculatorCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); @@ -165,15 +173,15 @@ void Calculator::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_set_style_border_width(wrapper, 0, 0); lv_obj_remove_flag(wrapper, LV_OBJ_FLAG_SCROLLABLE); - displayLabel = lv_label_create(wrapper); - lv_label_set_text(displayLabel, "0"); - lv_obj_set_width(displayLabel, LV_SIZE_CONTENT); - lv_obj_set_align(displayLabel, LV_ALIGN_LEFT_MID); + ctx->displayLabel = lv_label_create(wrapper); + lv_label_set_text(ctx->displayLabel, "0"); + lv_obj_set_width(ctx->displayLabel, LV_SIZE_CONTENT); + lv_obj_set_align(ctx->displayLabel, LV_ALIGN_LEFT_MID); - resultLabel = lv_label_create(wrapper); - lv_label_set_text(resultLabel, ""); - lv_obj_set_width(resultLabel, LV_SIZE_CONTENT); - lv_obj_set_align(resultLabel, LV_ALIGN_RIGHT_MID); + ctx->resultLabel = lv_label_create(wrapper); + lv_label_set_text(ctx->resultLabel, ""); + lv_obj_set_width(ctx->resultLabel, LV_SIZE_CONTENT); + lv_obj_set_align(ctx->resultLabel, LV_ALIGN_RIGHT_MID); static const char* btn_map[] = { "(", ")", "C", "/", "\n", @@ -201,5 +209,5 @@ void Calculator::onShow(AppHandle appHandle, lv_obj_t* parent) { } lv_obj_align(buttonmatrix, LV_ALIGN_BOTTOM_MID, 0, -5); - lv_obj_add_event_cb(buttonmatrix, button_event_cb, LV_EVENT_VALUE_CHANGED, this); -} \ No newline at end of file + lv_obj_add_event_cb(buttonmatrix, onButtonPressed, LV_EVENT_VALUE_CHANGED, ctx); +} diff --git a/Apps/Calculator/main/Source/Calculator.h b/Apps/Calculator/main/Source/Calculator.h index 9aea097..dfd7760 100644 --- a/Apps/Calculator/main/Source/Calculator.h +++ b/Apps/Calculator/main/Source/Calculator.h @@ -1,28 +1,16 @@ #pragma once -#include "tt_app.h" - #include -#include -#include -#include +#include -class Calculator final : public App { +struct Context { + uint32_t appInstanceId; - lv_obj_t* displayLabel; - lv_obj_t* resultLabel; + lv_obj_t* displayLabel = nullptr; + lv_obj_t* resultLabel = nullptr; char formulaBuffer[128] = {0}; // Stores the full input expression bool newInput = true; +}; - static void button_event_cb(lv_event_t* e); - void handleInput(const char* txt); - void evaluateExpression(); - double computeFormula(); - static std::deque infixToRPN(const std::string& infix); - static double evaluateRPN(std::deque rpnQueue); - void resetCalculator(); - -public: - - void onShow(AppHandle context, lv_obj_t* parent) override; -}; \ No newline at end of file +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void calculatorCreateWidgets(lv_obj_t* parent, void* userData); diff --git a/Apps/Calculator/main/Source/main.cpp b/Apps/Calculator/main/Source/main.cpp index 5acf8a5..9f0f613 100644 --- a/Apps/Calculator/main/Source/main.cpp +++ b/Apps/Calculator/main/Source/main.cpp @@ -1,10 +1,40 @@ #include "Calculator.h" -#include + +#include +#include +#include + +#include extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + Context ctx {}; + ctx.appInstanceId = app_instance_id; + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, calculatorCreateWidgets, &ctx); + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(app_instance_id); + should_close = true; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + return 0; } diff --git a/Apps/Diceware/CMakeLists.txt b/Apps/Diceware/CMakeLists.txt index d575666..5ddb44f 100644 --- a/Apps/Diceware/CMakeLists.txt +++ b/Apps/Diceware/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(Diceware) tactility_project(Diceware) diff --git a/Apps/Diceware/main/CMakeLists.txt b/Apps/Diceware/main/CMakeLists.txt index 0f54d39..94136e8 100644 --- a/Apps/Diceware/main/CMakeLists.txt +++ b/Apps/Diceware/main/CMakeLists.txt @@ -4,9 +4,6 @@ file(GLOB_RECURSE SOURCE_FILES idf_component_register( SRCS ${SOURCE_FILES} - # Library headers must be included directly, - # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include REQUIRES TactilitySDK ) diff --git a/Apps/Diceware/main/Source/Diceware.cpp b/Apps/Diceware/main/Source/Diceware.cpp index c455c00..fc8f420 100644 --- a/Apps/Diceware/main/Source/Diceware.cpp +++ b/Apps/Diceware/main/Source/Diceware.cpp @@ -1,17 +1,19 @@ #include "Diceware.h" -#include -#include +#include +#include #include #include +#include #include #include -#include - constexpr char* TAG = "Diceware"; +/** Must match manifest.properties' app.id */ +static constexpr const char* APP_ID = "one.tactility.diceware"; + static void skipNewlines(FILE* file, const int count) { char c; int count_in_file = 0; @@ -30,11 +32,9 @@ static std::string readWord(FILE* file) { return result; } -static std::string readWordAtLine(const AppHandle handle, const int lineIndex) { +static std::string readWordAtLine(const int lineIndex) { char path[256]; - size_t size = 256; - tt_app_get_assets_child_path(handle, "eff_large_wordlist.txt", path, &size); - if (size == 0) { + if (app_paths_get_assets_path(APP_ID, "eff_large_wordlist.txt", path, sizeof(path)) != ERROR_NONE) { ESP_LOGE(TAG, "Failed to get assets path"); return ""; } @@ -53,77 +53,84 @@ static std::string readWordAtLine(const AppHandle handle, const int lineIndex) { return word; } -int32_t Diceware::jobMain() { +static void onFinishJob(Context* ctx, std::string result) { + lvgl_lock(); + lv_label_set_text(ctx->resultLabel, result.c_str()); + lvgl_unlock(); +} + +static int32_t jobMain(Context* ctx) { std::string result; - for (int i = 0; i < wordCount; i++) { + for (int i = 0; i < ctx->wordCount; i++) { constexpr int line_count = 7776; const auto line_index = esp_random() % line_count; - auto word = readWordAtLine(handle, line_index); + auto word = readWordAtLine(line_index); result += word; result += " "; } - onFinishJob(result); + onFinishJob(ctx, result); return 0; } -void Diceware::cleanupJob() { - if (jobThread != nullptr) { - jobThread->join(); - jobThread = nullptr; +static void cleanupJob(Context* ctx) { + if (ctx->jobThread != nullptr) { + ctx->jobThread->join(); + ctx->jobThread = nullptr; } } -void Diceware::startJob(uint32_t jobWordCount) { - cleanupJob(); +static void startJob(Context* ctx, uint32_t jobWordCount) { + cleanupJob(ctx); - wordCount = jobWordCount; - jobThread = std::make_unique("Diceware", 4096, [this] { - return jobMain(); + ctx->wordCount = jobWordCount; + ctx->jobThread = std::make_unique("Diceware", 4096, [ctx] { + return jobMain(ctx); }); - jobThread->start(); -} - -void Diceware::onFinishJob(std::string result) { - lvgl_lock(); - lv_label_set_text(resultLabel, result.c_str()); - lvgl_unlock(); + ctx->jobThread->start(); } -void Diceware::onClickGenerate(lv_event_t* e) { - auto* application = static_cast(lv_event_get_user_data(e)); - auto* spinbox = application->spinbox; +static void onClickGenerate(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + auto* spinbox = ctx->spinbox; - lv_label_set_text(application->resultLabel, "Generating..."); + lv_label_set_text(ctx->resultLabel, "Generating..."); const auto word_count = lv_spinbox_get_value(spinbox); - application->startJob(word_count); + startJob(ctx, word_count); } -void Diceware::onSpinboxDecrement(lv_event_t* e) { +static void onSpinboxDecrement(lv_event_t* e) { auto* spinbox = static_cast(lv_event_get_user_data(e)); lv_spinbox_decrement(spinbox); } -void Diceware::onSpinboxIncrement(lv_event_t* e) { +static void onSpinboxIncrement(lv_event_t* e) { auto* spinbox = static_cast(lv_event_get_user_data(e)); lv_spinbox_increment(spinbox); } -void Diceware::onHelpClicked(lv_event_t* e) { - const char* buttons[] = { "OK" }; - tt_app_alertdialog_start("Diceware Info", "The hardware random number generator can use the Wi-Fi radio to improve randomness. There's no need to connect to a Wi-Fi network for this to work.", buttons, 1); +static void onHelpClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (!ctx) return; + + const char* argv[] = { + "Diceware Info", + "The hardware random number generator can use the Wi-Fi radio to improve randomness. There's no need to connect to a Wi-Fi network for this to work.", + "OK", + }; + app_manager_start_for_result("AlertDialog", ctx->appInstanceId, 3, argv, &ctx->pendingHelpDialogId); } -void Diceware::onShow(AppHandle appHandle, lv_obj_t* parent) { - handle = appHandle; +void dicewareCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); auto* toolbar = lvgl_toolbar_create(parent, "Diceware"); - lvgl_toolbar_add_text_button_action(toolbar, "?", onHelpClicked, nullptr); + lvgl_toolbar_add_text_button_action(toolbar, "?", onHelpClicked, ctx); auto* wrapper = lv_obj_create(parent); lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); @@ -141,45 +148,45 @@ void Diceware::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_align(generate_button, LV_ALIGN_LEFT_MID, 0, 0); auto* generate_label = lv_label_create(generate_button); lv_label_set_text(generate_label, "Generate"); - lv_obj_add_event_cb(generate_button, onClickGenerate, LV_EVENT_SHORT_CLICKED, this); + lv_obj_add_event_cb(generate_button, onClickGenerate, LV_EVENT_SHORT_CLICKED, ctx); - spinbox = lv_spinbox_create(top_row_wrapper); - lv_spinbox_set_range(spinbox, 4, 12); - lv_spinbox_set_value(spinbox, 5); - lv_spinbox_set_step(spinbox, 1); - lv_spinbox_set_digit_format(spinbox, 1, 0); - lv_obj_set_width(spinbox, 30); + ctx->spinbox = lv_spinbox_create(top_row_wrapper); + lv_spinbox_set_range(ctx->spinbox, 4, 12); + lv_spinbox_set_value(ctx->spinbox, 5); + lv_spinbox_set_step(ctx->spinbox, 1); + lv_spinbox_set_digit_format(ctx->spinbox, 1, 0); + lv_obj_set_width(ctx->spinbox, 30); auto* spinbox_dec_button = lv_button_create(top_row_wrapper); lv_obj_set_style_bg_image_src(spinbox_dec_button, LV_SYMBOL_MINUS, LV_STATE_DEFAULT); - lv_obj_add_event_cb(spinbox_dec_button, onSpinboxDecrement, LV_EVENT_SHORT_CLICKED, spinbox); + lv_obj_add_event_cb(spinbox_dec_button, onSpinboxDecrement, LV_EVENT_SHORT_CLICKED, ctx->spinbox); lv_obj_set_style_pad_all(spinbox_dec_button, 16, LV_STATE_DEFAULT); auto* spinbox_inc_button = lv_button_create(top_row_wrapper); // lv_obj_align_to(spinbox_inc_button, spinbox, LV_ALIGN_OUT_RIGHT_MID, 5, 0); lv_obj_set_style_bg_image_src(spinbox_inc_button, LV_SYMBOL_PLUS, LV_STATE_DEFAULT); - lv_obj_add_event_cb(spinbox_inc_button, onSpinboxIncrement, LV_EVENT_SHORT_CLICKED, spinbox); + lv_obj_add_event_cb(spinbox_inc_button, onSpinboxIncrement, LV_EVENT_SHORT_CLICKED, ctx->spinbox); lv_obj_set_style_pad_all(spinbox_inc_button, 16, LV_STATE_DEFAULT); // Align spinbox widgets lv_obj_align(spinbox_inc_button, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_align_to(spinbox, spinbox_inc_button, LV_ALIGN_OUT_LEFT_MID, -5, 0); - lv_obj_align_to(spinbox_dec_button, spinbox, LV_ALIGN_OUT_LEFT_MID, -5, 0); + lv_obj_align_to(ctx->spinbox, spinbox_inc_button, LV_ALIGN_OUT_LEFT_MID, -5, 0); + lv_obj_align_to(spinbox_dec_button, ctx->spinbox, LV_ALIGN_OUT_LEFT_MID, -5, 0); auto* result_wrapper = lv_obj_create(wrapper); lv_obj_set_flex_grow(result_wrapper, 1); lv_obj_set_width(result_wrapper, LV_PCT(100)); lv_obj_set_style_pad_all(result_wrapper, 0, LV_STATE_DEFAULT); - resultLabel = lv_label_create(result_wrapper); + ctx->resultLabel = lv_label_create(result_wrapper); // See https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/system/random.html - lv_label_set_text(resultLabel, "Press Generate button\nWi-Fi improves randomness.\nSee info button."); - lv_label_set_long_mode(resultLabel, LV_LABEL_LONG_MODE_WRAP); - lv_obj_set_size(resultLabel, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_align(resultLabel, LV_ALIGN_CENTER); - lv_obj_set_style_text_align(resultLabel, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT); + lv_label_set_text(ctx->resultLabel, "Press Generate button\nWi-Fi improves randomness.\nSee info button."); + lv_label_set_long_mode(ctx->resultLabel, LV_LABEL_LONG_MODE_WRAP); + lv_obj_set_size(ctx->resultLabel, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_align(ctx->resultLabel, LV_ALIGN_CENTER); + lv_obj_set_style_text_align(ctx->resultLabel, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT); } -void Diceware::onHide(AppHandle context) { - cleanupJob(); +void dicewareTeardown(Context* ctx) { + cleanupJob(ctx); } diff --git a/Apps/Diceware/main/Source/Diceware.h b/Apps/Diceware/main/Source/Diceware.h index 4a1fe6a..ad20601 100644 --- a/Apps/Diceware/main/Source/Diceware.h +++ b/Apps/Diceware/main/Source/Diceware.h @@ -1,36 +1,24 @@ #pragma once -#include "tt_app.h" - #include -#include #include -#include - #include +#include -class Diceware final : public App { +struct Context { + uint32_t appInstanceId; - AppHandle handle = nullptr; lv_obj_t* spinbox = nullptr; lv_obj_t* resultLabel = nullptr; std::unique_ptr jobThread = nullptr; uint32_t wordCount = 5; + // Instance id of the currently-shown "?" AlertDialog, or 0 if none is pending. + uint32_t pendingHelpDialogId = 0; +}; - static void onClickGenerate(lv_event_t* e); - static void onSpinboxDecrement(lv_event_t* e); - static void onSpinboxIncrement(lv_event_t* e); - static void onHelpClicked(lv_event_t* e); - - int32_t jobMain(); - - void startJob(uint32_t jobWordCount); - void onFinishJob(std::string result); - void cleanupJob(); - -public: +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void dicewareCreateWidgets(lv_obj_t* parent, void* userData); - void onShow(AppHandle context, lv_obj_t* parent) override; - void onHide(AppHandle context) override; -}; +/** Joins the background word-picking job, if any. Call once the window is torn down. */ +void dicewareTeardown(Context* ctx); diff --git a/Apps/Diceware/main/Source/main.cpp b/Apps/Diceware/main/Source/main.cpp index 06a0e8b..9d9e981 100644 --- a/Apps/Diceware/main/Source/main.cpp +++ b/Apps/Diceware/main/Source/main.cpp @@ -1,10 +1,51 @@ #include "Diceware.h" -#include + +#include +#include +#include + +#include extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + Context ctx {}; + ctx.appInstanceId = app_instance_id; + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, dicewareCreateWidgets, &ctx); + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(app_instance_id); + should_close = true; + break; + case APP_EVENT_RESULT: + if (event.result.launch_id == ctx.pendingHelpDialogId) { + ctx.pendingHelpDialogId = 0; + } + app_manager_stop(event.result.launch_id); + break; + default: + break; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + dicewareTeardown(&ctx); + return 0; } diff --git a/Apps/EpubReader/CMakeLists.txt b/Apps/EpubReader/CMakeLists.txt index 54dd223..c0da026 100644 --- a/Apps/EpubReader/CMakeLists.txt +++ b/Apps/EpubReader/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(EpubReader) tactility_project(EpubReader) diff --git a/Apps/EpubReader/main/CMakeLists.txt b/Apps/EpubReader/main/CMakeLists.txt index f37e34e..fcf9f4b 100644 --- a/Apps/EpubReader/main/CMakeLists.txt +++ b/Apps/EpubReader/main/CMakeLists.txt @@ -4,8 +4,5 @@ file(GLOB_RECURSE SOURCE_FILES idf_component_register( SRCS ${SOURCE_FILES} - # Library headers must be included directly, - # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include REQUIRES TactilitySDK esp_rom ) diff --git a/Apps/EpubReader/main/Source/EpubReader.cpp b/Apps/EpubReader/main/Source/EpubReader.cpp index 3551a34..62b8c7b 100644 --- a/Apps/EpubReader/main/Source/EpubReader.cpp +++ b/Apps/EpubReader/main/Source/EpubReader.cpp @@ -1,9 +1,9 @@ #include "EpubReader.h" #include "HtmlStrip.h" // stripHtmlToText -#include + +#include +#include #include -#include -#include #include #include #include @@ -14,18 +14,19 @@ #include static const char* TAG = "EpubReader"; -static const char* EPUB_FILE_ARGUMENT = "file"; + +/** Must match manifest.properties' app.id */ +static constexpr const char* APP_ID = "one.tactility.epubreader"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -std::string EpubReader::getAppDataRoot(AppHandle app) { +static std::string getAppDataRoot() { char path[128] = {0}; - size_t sz = sizeof(path); - tt_app_get_user_data_path(app, path, &sz); - // path = /sdcard/user/app/one.tactility.epubreader → extract "/sdcard" - // path = /data/user/app/one.tactility.epubreader → extract "/data" + app_paths_get_user_data_directory(APP_ID, path, sizeof(path)); + // path = /sdcard/tactility/app/one.tactility.epubreader → extract "/sdcard" + // path = /data/tactility/app/one.tactility.epubreader → extract "/data" std::string s(path); size_t pos = s.find('/', 1); return (pos != std::string::npos) ? s.substr(0, pos) : "/sdcard"; @@ -35,36 +36,34 @@ std::string EpubReader::getAppDataRoot(AppHandle app) { // Books folder persistence // --------------------------------------------------------------------------- -void EpubReader::loadBooksPath() { - if (!appHandle_) return; - char path[128]; size_t sz = sizeof(path); - tt_app_get_user_data_child_path(appHandle_, "books_folder.txt", path, &sz); +void loadBooksPath(Context* ctx) { + char path[128]; + if (app_paths_get_user_data_path(APP_ID, "books_folder.txt", path, sizeof(path)) != ERROR_NONE) return; FILE* f = fopen(path, "r"); if (!f) return; char buf[256] = {}; if (fgets(buf, sizeof(buf), f)) { size_t len = strlen(buf); if (len > 0 && buf[len - 1] == '\n') buf[len - 1] = '\0'; - if (buf[0] != '\0') booksPath_ = buf; + if (buf[0] != '\0') ctx->booksPath_ = buf; } fclose(f); } -void EpubReader::saveBooksPath() { - if (!appHandle_) return; - char path[128]; size_t sz = sizeof(path); - tt_app_get_user_data_child_path(appHandle_, "books_folder.txt", path, &sz); +void saveBooksPath(Context* ctx) { + char path[128]; + if (app_paths_get_user_data_path(APP_ID, "books_folder.txt", path, sizeof(path)) != ERROR_NONE) return; FILE* f = fopen(path, "w"); if (!f) return; - fprintf(f, "%s\n", booksPath_.c_str()); + fprintf(f, "%s\n", ctx->booksPath_.c_str()); fclose(f); } // --------------------------------------------------------------------------- -// File type helpers (static members - used by UI and Async TUs via the class) +// File type helpers (shared across translation units) // --------------------------------------------------------------------------- -bool EpubReader::isSupportedFile(const std::string& name) { +bool isSupportedFile(const std::string& name) { auto pos = name.rfind('.'); if (pos == std::string::npos) return false; std::string ext = name.substr(pos); @@ -72,7 +71,7 @@ bool EpubReader::isSupportedFile(const std::string& name) { return ext == ".epub" || ext == ".txt"; } -bool EpubReader::isTextFile(const std::string& path) { +bool isTextFile(const std::string& path) { auto pos = path.rfind('.'); if (pos == std::string::npos) return false; std::string ext = path.substr(pos); @@ -84,24 +83,23 @@ bool EpubReader::isTextFile(const std::string& path) { // Persistence - plain text file in the app user-data directory // --------------------------------------------------------------------------- -void EpubReader::saveProgress() { - if (currentFilePath_.empty() || !appHandle_) return; - char path[128]; size_t sz = sizeof(path); - tt_app_get_user_data_child_path(appHandle_, "progress.txt", path, &sz); +void saveProgress(Context* ctx) { + if (ctx->currentFilePath_.empty()) return; + char path[128]; + if (app_paths_get_user_data_path(APP_ID, "progress.txt", path, sizeof(path)) != ERROR_NONE) return; FILE* f = fopen(path, "w"); if (!f) return; // Text mode: pageOffset_ is a scroll-Y pixel position; epub: spine chapter index. // The mode tag makes the file self-describing so the value's semantics are unambiguous. - int savedIndex = textMode_ ? (int)pageOffset_ : currentSpineIndex_; - fprintf(f, "%s\n%d\n%s\n", currentFilePath_.c_str(), savedIndex, - textMode_ ? "text" : "epub"); + int savedIndex = ctx->textMode_ ? (int)ctx->pageOffset_ : ctx->currentSpineIndex_; + fprintf(f, "%s\n%d\n%s\n", ctx->currentFilePath_.c_str(), savedIndex, + ctx->textMode_ ? "text" : "epub"); fclose(f); } -bool EpubReader::loadProgress(std::string& outPath, int& outChapter, bool& outIsText) { - if (!appHandle_) return false; - char path[128]; size_t sz = sizeof(path); - tt_app_get_user_data_child_path(appHandle_, "progress.txt", path, &sz); +bool loadProgress(std::string& outPath, int& outChapter, bool& outIsText) { + char path[128]; + if (app_paths_get_user_data_path(APP_ID, "progress.txt", path, sizeof(path)) != ERROR_NONE) return false; FILE* f = fopen(path, "r"); if (!f) return false; char filePath[256] = {}; @@ -129,24 +127,24 @@ bool EpubReader::loadProgress(std::string& outPath, int& outChapter, bool& outIs // Render all of pageContent_ as per-paragraph labels, then restore saved scroll position. // Prev/Next navigate by scrolling one viewport height within the chapter; at chapter // boundaries they load the adjacent chapter (like text mode, but across chapters). -void EpubReader::renderPage() { - if (!contentWidget_) return; - lv_obj_clean(contentWidget_); - if (pageContent_.empty()) { - lv_obj_t* lbl = lv_label_create(contentWidget_); +void renderPage(Context* ctx) { + if (!ctx->contentWidget_) return; + lv_obj_clean(ctx->contentWidget_); + if (ctx->pageContent_.empty()) { + lv_obj_t* lbl = lv_label_create(ctx->contentWidget_); lv_label_set_text(lbl, "(Empty chapter)"); - lv_obj_scroll_to_y(lv_obj_get_parent(contentWidget_), 0, LV_ANIM_OFF); + lv_obj_scroll_to_y(lv_obj_get_parent(ctx->contentWidget_), 0, LV_ANIM_OFF); return; } - renderSlice(pageContent_); - lv_coord_t scrollY = (pageOffset_ > (size_t)LV_COORD_MAX) ? LV_COORD_MAX : (lv_coord_t)pageOffset_; - lv_obj_scroll_to_y(lv_obj_get_parent(contentWidget_), scrollY, LV_ANIM_OFF); + renderSlice(ctx, ctx->pageContent_); + lv_coord_t scrollY = (ctx->pageOffset_ > (size_t)LV_COORD_MAX) ? LV_COORD_MAX : (lv_coord_t)ctx->pageOffset_; + lv_obj_scroll_to_y(lv_obj_get_parent(ctx->contentWidget_), scrollY, LV_ANIM_OFF); } -void EpubReader::loadChapter(int index, int direction) { - if (!epub_ || !contentWidget_) return; +void loadChapter(Context* ctx, int index, int direction) { + if (!ctx->epub_ || !ctx->contentWidget_) return; - const auto& spine = epub_->getSpine(); + const auto& spine = ctx->epub_->getSpine(); // Auto-skip chapters whose HTML strips to nothing (image-only, boilerplate, etc.) // currentSpineIndex_ is NOT updated until we confirm the chapter has content - @@ -155,46 +153,48 @@ void EpubReader::loadChapter(int index, int direction) { while (true) { if (index < 0 || index >= (int)spine.size()) return; - std::string html = epub_->readFile(spine[index].href, MAX_CHAPTER_HTML); - stripHtmlToText(html, pageContent_); + std::string html = ctx->epub_->readFile(spine[index].href, MAX_CHAPTER_HTML); + stripHtmlToText(html, ctx->pageContent_); html = {}; // release raw HTML before rendering - if (!pageContent_.empty()) break; + if (!ctx->pageContent_.empty()) break; // Chapter stripped to nothing - advance in the navigation direction if (direction == 0) { - currentSpineIndex_ = index; - pageOffset_ = 0; + ctx->currentSpineIndex_ = index; + ctx->pageOffset_ = 0; LOG_W(TAG, "Chapter %d stripped to nothing and no direction to skip - blank chapter", index); - lv_obj_clean(contentWidget_); - lv_obj_t* lbl = lv_label_create(contentWidget_); + lv_obj_clean(ctx->contentWidget_); + lv_obj_t* lbl = lv_label_create(ctx->contentWidget_); lv_label_set_text(lbl, "(Chapter content unavailable)"); return; } index += direction; } - currentSpineIndex_ = index; - pageOffset_ = 0; - renderPage(); + ctx->currentSpineIndex_ = index; + ctx->pageOffset_ = 0; + renderPage(ctx); // When arriving from a later chapter (going backward), jump to the end of this chapter // after LVGL has laid out the labels (deferred so content height is known). - if (direction < 0) lv_async_call(asyncScrollToEnd, this); - saveProgress(); + if (direction < 0) lv_async_call(asyncScrollToEnd, ctx); + saveProgress(ctx); } -void EpubReader::openTocDialog() { - if (!epub_) return; - const auto& toc = epub_->getToc(); +void openTocDialog(Context* ctx) { + if (!ctx->epub_) return; + const auto& toc = ctx->epub_->getToc(); if (toc.empty()) return; - std::vector titles; - titles.reserve(toc.size()); + std::vector argv; + argv.reserve(toc.size() + 1); + argv.push_back("Table of Contents"); for (const auto& item : toc) { - titles.push_back(item.title.c_str()); + argv.push_back(item.title.c_str()); } - tocDialogId_ = tt_app_selectiondialog_start( - "Table of Contents", (int)titles.size(), titles.data() + app_manager_start_for_result( + "SelectionDialog", ctx->appInstanceId, + (int)argv.size(), argv.data(), &ctx->tocDialogId_ ); } @@ -202,148 +202,105 @@ void EpubReader::openTocDialog() { // App lifecycle // --------------------------------------------------------------------------- -void EpubReader::onShow(AppHandle app, lv_obj_t* parent) { - appHandle_ = app; +void epubReaderCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); // Hard requirement: without PSRAM this app cannot parse EPUBs or hold fonts. // Show a one-shot alert then stop; psramAlertId_ prevents re-launching the dialog - // if onShow is called again before tt_app_stop() takes effect. + // if this is called again (e.g. after a dialog roundtrip) before the app has closed. if (heap_caps_get_total_size(MALLOC_CAP_SPIRAM) == 0) { - if (psramAlertId_ == 0) { - static const char* kButtons[] = { "OK" }; - psramAlertId_ = tt_app_alertdialog_start( + if (ctx->psramAlertId_ == 0) { + const char* argv[] = { "PSRAM Required", "Epub Reader requires a device with PSRAM and cannot run on this hardware.", - kButtons, 1 + "OK", + }; + app_manager_start_for_result( + "AlertDialog", ctx->appInstanceId, 3, argv, &ctx->psramAlertId_ ); } return; } - loadFonts(); + loadFonts(ctx); - if (dataRoot_.empty()) { - dataRoot_ = getAppDataRoot(app); + if (ctx->dataRoot_.empty()) { + ctx->dataRoot_ = getAppDataRoot(); // Ensure the app data directory exists (needed for progress.txt and books_folder.txt) - char dir[128]; size_t sz = sizeof(dir); - tt_app_get_user_data_path(app, dir, &sz); + char dir[128]; + app_paths_get_user_data_directory(APP_ID, dir, sizeof(dir)); for (char* p = dir + 1; *p; ++p) { if (*p == '/') { *p = '\0'; mkdir(dir, 0755); *p = '/'; } } mkdir(dir, 0755); // Load saved books folder; start browser there if set, otherwise dataRoot_ - loadBooksPath(); - browsePath_ = booksPath_.empty() ? dataRoot_ : booksPath_; + loadBooksPath(ctx); + ctx->browsePath_ = ctx->booksPath_.empty() ? ctx->dataRoot_ : ctx->booksPath_; } lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); - toolbar_ = lvgl_toolbar_create(parent, "Epub Reader"); - - wrapperWidget_ = lv_obj_create(parent); - lv_obj_set_width(wrapperWidget_, LV_PCT(100)); - lv_obj_set_flex_grow(wrapperWidget_, 1); - lv_obj_set_flex_flow(wrapperWidget_, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(wrapperWidget_, 0, 0); - lv_obj_set_style_border_width(wrapperWidget_, 0, 0); - lv_obj_set_style_bg_opa(wrapperWidget_, LV_OPA_TRANSP, 0); - lv_obj_remove_flag(wrapperWidget_, LV_OBJ_FLAG_SCROLLABLE); - - // Apply chapter jump from TOC dialog (stored in onResult) - if (pendingChapterIndex_ >= 0) { - currentSpineIndex_ = pendingChapterIndex_; - pendingChapterIndex_ = -1; - } + ctx->toolbar_ = lvgl_toolbar_create(parent, "Epub Reader"); + + ctx->wrapperWidget_ = lv_obj_create(parent); + lv_obj_set_width(ctx->wrapperWidget_, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->wrapperWidget_, 1); + lv_obj_set_flex_flow(ctx->wrapperWidget_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(ctx->wrapperWidget_, 0, 0); + lv_obj_set_style_border_width(ctx->wrapperWidget_, 0, 0); + lv_obj_set_style_bg_opa(ctx->wrapperWidget_, LV_OPA_TRANSP, 0); + lv_obj_remove_flag(ctx->wrapperWidget_, LV_OBJ_FLAG_SCROLLABLE); // Attempt to open a book: launch parameter → saved progress (first show only). // Both paths use lv_async_call so EpubService::open runs at fresh stack - // depth (not nested inside the deep GuiService→onShow call chain). + // depth (not nested inside the deep window_manager→createWidgets call chain). bool asyncOpen = false; - if (!epub_) { - BundleHandle bundle = tt_app_get_parameters(app); - if (bundle) { - char filepath[256] = {0}; - if (tt_bundle_opt_string(bundle, EPUB_FILE_ARGUMENT, filepath, (uint32_t)sizeof(filepath))) { - LOG_I(TAG, "Opening from parameter: %s", filepath); - pendingFilePath_ = filepath; - lv_async_call(asyncOpenEpub, this); - asyncOpen = true; - } + if (!ctx->epub_) { + if (!ctx->launchFilePath_.empty()) { + LOG_I(TAG, "Opening from parameter: %s", ctx->launchFilePath_.c_str()); + ctx->pendingFilePath_ = ctx->launchFilePath_; + lv_async_call(asyncOpenEpub, ctx); + asyncOpen = true; } } bool asyncRestore = false; - if (!epub_ && !asyncOpen) { + if (!ctx->epub_ && !asyncOpen) { std::string savedPath; int savedChapter = 0; bool savedIsText = false; if (loadProgress(savedPath, savedChapter, savedIsText)) { // Schedule open via lv_async_call so it runs at the same call-stack // depth as asyncOpenEpub - calling EpubService::open directly here - // (from GuiService→onShow) adds extra frames that overflow the task stack. - pendingFilePath_ = savedPath; - currentSpineIndex_ = savedChapter; - lv_async_call(asyncRestoreEpub, this); + // adds extra frames that overflow the task stack. + ctx->pendingFilePath_ = savedPath; + ctx->currentSpineIndex_ = savedChapter; + lv_async_call(asyncRestoreEpub, ctx); asyncRestore = true; LOG_I(TAG, "Scheduling restore: %s ch%d", savedPath.c_str(), savedChapter); } } - if (epub_ && epub_->isValid()) { - setReaderToolbarButtons(); - buildReaderUI(wrapperWidget_); + if (ctx->epub_ && ctx->epub_->isValid()) { + setReaderToolbarButtons(ctx); + buildReaderUI(ctx, ctx->wrapperWidget_); } else if (!asyncRestore && !asyncOpen) { - epub_ = nullptr; - setBrowserToolbarButtons(); - buildBrowserUI(wrapperWidget_); + ctx->epub_ = nullptr; + setBrowserToolbarButtons(ctx); + buildBrowserUI(ctx, ctx->wrapperWidget_); } else { // asyncOpenEpub / asyncRestoreEpub will replace the wrapper contents when // they fire; show an empty placeholder for now (no browser flash on ePaper) } } -void EpubReader::onHide(AppHandle /*app*/) { - ++openToken_; // invalidate any in-flight background open task - // Skip font unload during a dialog roundtrip (tocDialogId_ is non-zero while - // the TOC selection dialog is open). loadFonts() guards against double-loading, - // so fonts will be reused as-is when onShow fires after the dialog closes. - // When truly exiting, no dialog is open and fonts are freed as normal. - if (tocDialogId_ == 0 && psramAlertId_ == 0) { - unloadFonts(); - } - contentWidget_ = nullptr; - wrapperWidget_ = nullptr; - toolbar_ = nullptr; - appHandle_ = nullptr; - pageContent_ = {}; // release chapter/text memory - pageOffset_ = 0; - textMode_ = false; -} - -void EpubReader::onResult(AppHandle /*app*/, void* /*data*/, AppLaunchId launchId, - AppResult /*result*/, BundleHandle resultData) { - // Never touch LVGL objects here - store state for onShow - if (launchId == psramAlertId_ && psramAlertId_ != 0) { - tt_app_stop(); - return; - } - if (launchId == tocDialogId_ && tocDialogId_ != 0) { - tocDialogId_ = 0; - if (resultData) { - int32_t selection = tt_app_selectiondialog_get_result_index(resultData); - if (selection >= 0 && epub_) { - const auto& toc = epub_->getToc(); - const auto& spine = epub_->getSpine(); - if (selection < (int32_t)toc.size()) { - pendingChapterIndex_ = -1; // Reset before searching - for (size_t i = 0; i < spine.size(); ++i) { - if (spine[i].href == toc[selection].href) { - pendingChapterIndex_ = (int)i; - break; - } - } - } - } - } - } +void epubReaderTeardown(Context* ctx) { + ++ctx->openToken_; // invalidate any in-flight background open task + unloadFonts(); + ctx->contentWidget_ = nullptr; + ctx->wrapperWidget_ = nullptr; + ctx->toolbar_ = nullptr; + ctx->pageContent_ = {}; // release chapter/text memory + ctx->pageOffset_ = 0; + ctx->textMode_ = false; } diff --git a/Apps/EpubReader/main/Source/EpubReader.h b/Apps/EpubReader/main/Source/EpubReader.h index b966b36..d0da344 100644 --- a/Apps/EpubReader/main/Source/EpubReader.h +++ b/Apps/EpubReader/main/Source/EpubReader.h @@ -1,10 +1,9 @@ #pragma once -#include -#include -#include #include +#include #include +#include #include #include @@ -17,21 +16,18 @@ LV_FONT_DECLARE(material_symbols_shared_32) // Font selection utility (defined in EpubReaderUI.cpp, shared across translation units). const lv_font_t* selectContentFont(bool italic = false, bool bold = false); -class EpubReader final : public App { - - // App handle (stored for use in async rebuilds where AppHandle isn't available) - AppHandle appHandle_ = nullptr; +static constexpr size_t MAX_CHAPTER_HTML = 131072; // max HTML bytes read per chapter (128 KB) - static constexpr size_t MAX_CHAPTER_HTML = 131072; // max HTML bytes read per chapter (128 KB) +struct Context { + uint32_t appInstanceId; // EPUB / text reader state std::shared_ptr epub_; bool textMode_ = false; // true when showing a plain .txt file (no epub_) std::string currentFilePath_; int currentSpineIndex_ = 0; - AppLaunchId tocDialogId_ = 0; - AppLaunchId psramAlertId_ = 0; // Non-zero once the no-PSRAM alert has been launched - int pendingChapterIndex_ = -1; // Set by onResult, consumed in onShow + uint32_t tocDialogId_ = 0; + uint32_t psramAlertId_ = 0; // Non-zero once the no-PSRAM alert has been launched // Per-chapter stripped plain text + current display offset std::string pageContent_; @@ -41,85 +37,93 @@ class EpubReader final : public App { std::string dataRoot_; std::string browsePath_; std::string pendingFilePath_; // Set before async open + // Path passed on the command line at launch (argv[0]), or empty. Re-checked (and left set, + // matching the original bundle-based behavior of re-checking every show) each time + // createWidgets() runs until an epub_ is successfully opened. + std::string launchFilePath_; std::vector> browserEntries_; // {name, isDir} // Books folder std::string booksPath_; // saved books folder path (empty = not set) int shelfPage_ = 0; // current page in shelf view (persists across open/close) - // Background open token - incremented on each new open + in onHide to invalidate - // any in-flight background task so its result is discarded if it arrives late. + // Background open token - incremented in epubReaderTeardown() to invalidate any in-flight + // background task so its result is discarded if it arrives after the app has closed. std::atomic openToken_ = 0; - // UI pointers (nulled in onHide) + // UI pointers (nulled in epubReaderTeardown()) lv_obj_t* toolbar_ = nullptr; lv_obj_t* wrapperWidget_ = nullptr; // In text mode: an lv_label. // In EPUB mode: a transparent flex-column container holding per-paragraph labels. lv_obj_t* contentWidget_ = nullptr; - - // Font lifecycle - loads the 4 Noto Serif variants for the active display size tier - // from binary assets via lv_binfont_create; unloaded in onHide (skipped during dialog roundtrips). - void loadFonts(); - void unloadFonts(); - - // UI builders - void buildReaderUI(lv_obj_t* parent); - // Parse an ESC-encoded page slice and populate contentWidget_ with labels. - void renderSlice(const std::string& slice); - void buildBrowserUI(lv_obj_t* parent); - void buildShelfUI(lv_obj_t* parent); - void setReaderToolbarButtons(); - void setBrowserToolbarButtons(); - - // Chapter / text logic - void loadChapter(int index, int direction = 1); - void renderPage(); - void saveProgress(); - bool loadProgress(std::string& outPath, int& outChapter, bool& outIsText); - void openTocDialog(); - - // Helpers - static std::string getAppDataRoot(AppHandle app); - void loadBooksPath(); - void saveBooksPath(); - static bool isSupportedFile(const std::string& name); - static bool isTextFile(const std::string& path); - void scanBooksDir(const std::string& path, const std::string& prefix); // shelf flat-scan helper - - // Async rebuilds - safe to call from within LVGL event callbacks - static void asyncOpenEpub(void* data); - static void asyncRestoreEpub(void* data); // like asyncOpenEpub but keeps currentSpineIndex_ - static void asyncNavigateBrowser(void* data); - static void asyncSwitchToBrowser(void* data); - - // Background open - runs EpubService::open off the LVGL task to avoid stack overflow. - // asyncOpenComplete is posted via lv_async_call when done. - static void spawnOpenTask(EpubReader* self, bool restore); // shared setup for both open paths - static void backgroundOpenTask(void* data); // xTaskCreate target - static void asyncOpenComplete(void* data); // lv_async_call target, back on LVGL task - static void asyncScrollToEnd(void* data); // lv_async_call: scroll to bottom after layout - - // Navigation - shared by toolbar callbacks and tap zones - void doPrev(); - void doNext(); - - // LVGL event callbacks - static void onPrevPressed(lv_event_t* e); - static void onNextPressed(lv_event_t* e); - static void onReaderTap(lv_event_t* e); - static void onTocPressed(lv_event_t* e); - static void onBrowsePressed(lv_event_t* e); - static void onBrowserBack(lv_event_t* e); - static void onBrowserItem(lv_event_t* e); - static void onSetBooksFolder(lv_event_t* e); // "Folder" toolbar button - static void onShelfFirst(lv_event_t* e); - static void onShelfPrev(lv_event_t* e); - static void onShelfNext(lv_event_t* e); - static void onShelfLast(lv_event_t* e); - -public: - void onShow(AppHandle context, lv_obj_t* parent) override; - void onHide(AppHandle context) override; - void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override; }; + +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void epubReaderCreateWidgets(lv_obj_t* parent, void* userData); + +/** Releases fonts and joins/invalidates any in-flight background job. Call once the window is torn down. */ +void epubReaderTeardown(Context* ctx); + +// --------------------------------------------------------------------------- +// Shared across translation units (EpubReader.cpp / EpubReaderUI.cpp / EpubReaderAsync.cpp) +// --------------------------------------------------------------------------- + +// Font lifecycle - loads the 4 Noto Serif variants for the active display size tier +// from binary assets via lv_binfont_create; unloaded in epubReaderTeardown(). +void loadFonts(Context* ctx); +void unloadFonts(); + +// UI builders +void buildReaderUI(Context* ctx, lv_obj_t* parent); +// Parse an ESC-encoded page slice and populate contentWidget_ with labels. +void renderSlice(Context* ctx, const std::string& slice); +void buildBrowserUI(Context* ctx, lv_obj_t* parent); +void buildShelfUI(Context* ctx, lv_obj_t* parent); +void setReaderToolbarButtons(Context* ctx); +void setBrowserToolbarButtons(Context* ctx); + +// Chapter / text logic +void loadChapter(Context* ctx, int index, int direction = 1); +void renderPage(Context* ctx); +void saveProgress(Context* ctx); +bool loadProgress(std::string& outPath, int& outChapter, bool& outIsText); +void openTocDialog(Context* ctx); + +// Helpers +void loadBooksPath(Context* ctx); +void saveBooksPath(Context* ctx); +bool isSupportedFile(const std::string& name); +bool isTextFile(const std::string& path); +void scanBooksDir(Context* ctx, const std::string& path, const std::string& prefix); // shelf flat-scan helper + +// Async rebuilds - safe to call from within LVGL event callbacks +void asyncOpenEpub(void* data); +void asyncRestoreEpub(void* data); // like asyncOpenEpub but keeps currentSpineIndex_ +void asyncNavigateBrowser(void* data); +void asyncSwitchToBrowser(void* data); + +// Background open - runs EpubService::open off the LVGL task to avoid stack overflow. +// asyncOpenComplete is posted via lv_async_call when done. +void spawnOpenTask(Context* ctx, bool restore); // shared setup for both open paths +void backgroundOpenTask(void* data); // xTaskCreate target +void asyncOpenComplete(void* data); // lv_async_call target, back on LVGL task +void asyncScrollToEnd(void* data); // lv_async_call: scroll to bottom after layout + +// Navigation - shared by toolbar callbacks and tap zones +void doPrev(Context* ctx); +void doNext(Context* ctx); + +// LVGL event callbacks +void onPrevPressed(lv_event_t* e); +void onNextPressed(lv_event_t* e); +void onReaderTap(lv_event_t* e); +void onTocPressed(lv_event_t* e); +void onBrowsePressed(lv_event_t* e); +void onBrowserBack(lv_event_t* e); +void onBrowserItem(lv_event_t* e); +void onSetBooksFolder(lv_event_t* e); // "Folder" toolbar button +void onShelfFirst(lv_event_t* e); +void onShelfPrev(lv_event_t* e); +void onShelfNext(lv_event_t* e); +void onShelfLast(lv_event_t* e); diff --git a/Apps/EpubReader/main/Source/EpubReaderAsync.cpp b/Apps/EpubReader/main/Source/EpubReaderAsync.cpp index d3fddfc..fe4d2ab 100644 --- a/Apps/EpubReader/main/Source/EpubReaderAsync.cpp +++ b/Apps/EpubReader/main/Source/EpubReaderAsync.cpp @@ -2,7 +2,6 @@ #include #include #include -#include #include #include #include @@ -17,11 +16,11 @@ static const char* TAG = "EpubReader"; // freed in asyncOpenComplete (or in spawnOpenTask on task-create failure). // --------------------------------------------------------------------------- struct OpenArgs { - EpubReader* self; + Context* ctx; std::string filePath; // path to .epub or .txt bool restore; // true = keep currentSpineIndex_ (restore mode) int spineIndex; // savedChapter / savedOffset for restore - uint32_t token; // matches self->openToken_ at dispatch time + uint32_t token; // matches ctx->openToken_ at dispatch time // Results filled by backgroundOpenTask: std::shared_ptr epub; // null on failure std::string textContent; // pre-read content for .txt files @@ -33,74 +32,74 @@ struct OpenArgs { // Helper: allocate an OpenArgs, clean the wrapper, show a placeholder, and spawn // the background task. Used by both asyncOpenEpub and asyncRestoreEpub. -void EpubReader::spawnOpenTask(EpubReader* self, bool restore) { +void spawnOpenTask(Context* ctx, bool restore) { auto* args = new OpenArgs{}; - args->self = self; - args->filePath = self->pendingFilePath_; + args->ctx = ctx; + args->filePath = ctx->pendingFilePath_; args->restore = restore; - args->spineIndex = self->currentSpineIndex_; - args->token = self->openToken_; + args->spineIndex = ctx->currentSpineIndex_; + args->token = ctx->openToken_; // Show a brief placeholder so old content doesn't linger during the open - lv_obj_clean(self->wrapperWidget_); - lvgl_toolbar_clear_actions(self->toolbar_); - lv_obj_t* lbl = lv_label_create(self->wrapperWidget_); + lv_obj_clean(ctx->wrapperWidget_); + lvgl_toolbar_clear_actions(ctx->toolbar_); + lv_obj_t* lbl = lv_label_create(ctx->wrapperWidget_); lv_obj_set_style_pad_all(lbl, 8, 0); lv_label_set_text(lbl, restore ? "Loading..." : "Opening..."); - if (xTaskCreateWithCaps(EpubReader::backgroundOpenTask, "epubOpen", 32768 /* 32 KB */, args, 3, nullptr, MALLOC_CAP_SPIRAM) + if (xTaskCreateWithCaps(backgroundOpenTask, "epubOpen", 32768 /* 32 KB */, args, 3, nullptr, MALLOC_CAP_SPIRAM) != pdPASS) { LOG_E(TAG, "Failed to create open task - out of memory"); delete args; - self->epub_ = nullptr; - self->setBrowserToolbarButtons(); - lv_obj_clean(self->wrapperWidget_); - self->buildBrowserUI(self->wrapperWidget_); + ctx->epub_ = nullptr; + setBrowserToolbarButtons(ctx); + lv_obj_clean(ctx->wrapperWidget_); + buildBrowserUI(ctx, ctx->wrapperWidget_); } } // Like asyncOpenEpub but keeps currentSpineIndex_ - used when restoring a saved session. -void EpubReader::asyncRestoreEpub(void* data) { - auto* self = static_cast(data); - if (!self->wrapperWidget_ || !self->toolbar_) return; - self->textMode_ = false; - ++self->openToken_; - spawnOpenTask(self, /*restore=*/true); +void asyncRestoreEpub(void* data) { + auto* ctx = static_cast(data); + if (!ctx->wrapperWidget_ || !ctx->toolbar_) return; + ctx->textMode_ = false; + ++ctx->openToken_; + spawnOpenTask(ctx, /*restore=*/true); } -void EpubReader::asyncNavigateBrowser(void* data) { - auto* self = static_cast(data); - if (!self->wrapperWidget_ || !self->toolbar_) return; - self->setBrowserToolbarButtons(); - lv_obj_clean(self->wrapperWidget_); - self->buildBrowserUI(self->wrapperWidget_); +void asyncNavigateBrowser(void* data) { + auto* ctx = static_cast(data); + if (!ctx->wrapperWidget_ || !ctx->toolbar_) return; + setBrowserToolbarButtons(ctx); + lv_obj_clean(ctx->wrapperWidget_); + buildBrowserUI(ctx, ctx->wrapperWidget_); } -void EpubReader::asyncOpenEpub(void* data) { - auto* self = static_cast(data); - if (!self->wrapperWidget_ || !self->toolbar_) return; +void asyncOpenEpub(void* data) { + auto* ctx = static_cast(data); + if (!ctx->wrapperWidget_ || !ctx->toolbar_) return; - self->currentSpineIndex_ = 0; - self->textMode_ = false; - ++self->openToken_; + ctx->currentSpineIndex_ = 0; + ctx->textMode_ = false; + ++ctx->openToken_; - spawnOpenTask(self, /*restore=*/false); + spawnOpenTask(ctx, /*restore=*/false); } -void EpubReader::asyncSwitchToBrowser(void* data) { - auto* self = static_cast(data); - if (!self->wrapperWidget_ || !self->toolbar_) return; +void asyncSwitchToBrowser(void* data) { + auto* ctx = static_cast(data); + if (!ctx->wrapperWidget_ || !ctx->toolbar_) return; - self->epub_ = nullptr; - self->textMode_ = false; - self->currentSpineIndex_ = 0; - self->contentWidget_ = nullptr; + ctx->epub_ = nullptr; + ctx->textMode_ = false; + ctx->currentSpineIndex_ = 0; + ctx->contentWidget_ = nullptr; // Return to books folder (if set) so the user lands on their library - if (!self->booksPath_.empty()) self->browsePath_ = self->booksPath_; - self->setBrowserToolbarButtons(); - lv_obj_clean(self->wrapperWidget_); - self->buildBrowserUI(self->wrapperWidget_); + if (!ctx->booksPath_.empty()) ctx->browsePath_ = ctx->booksPath_; + setBrowserToolbarButtons(ctx); + lv_obj_clean(ctx->wrapperWidget_); + buildBrowserUI(ctx, ctx->wrapperWidget_); } // --------------------------------------------------------------------------- @@ -110,7 +109,7 @@ void EpubReader::asyncSwitchToBrowser(void* data) { // Runs on a FreeRTOS task with its own 32 KB stack. // Does all SD card I/O (epub parse or text file read) completely off the LVGL // task to prevent stack overflow and serialise SDMMC access. -void EpubReader::backgroundOpenTask(void* data) { +void backgroundOpenTask(void* data) { auto* a = static_cast(data); // Acquire the filesystem lock before any SD card I/O - prevents concurrent @@ -150,61 +149,61 @@ void EpubReader::backgroundOpenTask(void* data) { // Called back on the LVGL task (via lv_async_call from backgroundOpenTask). // Checks the open token, then either builds the reader UI or falls back to browser. -void EpubReader::asyncOpenComplete(void* data) { - auto* a = static_cast(data); - auto* self = a->self; +void asyncOpenComplete(void* data) { + auto* a = static_cast(data); + auto* ctx = a->ctx; - // Discard stale results if the app was hidden or a newer open was started - if (!self->wrapperWidget_ || !self->toolbar_ || a->token != self->openToken_) { + // Discard stale results if the app was closed or a newer open was started + if (!ctx->wrapperWidget_ || !ctx->toolbar_ || a->token != ctx->openToken_) { delete a; return; } if (isTextFile(a->filePath)) { - self->epub_ = nullptr; - self->textMode_ = false; - self->currentSpineIndex_ = a->restore ? a->spineIndex : 0; + ctx->epub_ = nullptr; + ctx->textMode_ = false; + ctx->currentSpineIndex_ = a->restore ? a->spineIndex : 0; if (!a->textContent.empty()) { // Content was pre-read in backgroundOpenTask (under the FS lock) - // no SD I/O needed here on the LVGL task. - self->pageContent_ = std::move(a->textContent); - self->textMode_ = true; - self->currentFilePath_ = a->filePath; - self->pageOffset_ = (self->currentSpineIndex_ > 0) - ? (size_t)self->currentSpineIndex_ : 0u; - self->currentSpineIndex_ = 0; - LOG_I(TAG, "Text file loaded: %zu bytes", self->pageContent_.size()); + ctx->pageContent_ = std::move(a->textContent); + ctx->textMode_ = true; + ctx->currentFilePath_ = a->filePath; + ctx->pageOffset_ = (ctx->currentSpineIndex_ > 0) + ? (size_t)ctx->currentSpineIndex_ : 0u; + ctx->currentSpineIndex_ = 0; + LOG_I(TAG, "Text file loaded: %zu bytes", ctx->pageContent_.size()); } else { // Text content empty (lock timeout or read error) - show error in browser LOG_E(TAG, "Text content empty; cannot display: %s", a->filePath.c_str()); - lv_obj_clean(self->wrapperWidget_); - lv_obj_t* errLbl = lv_label_create(self->wrapperWidget_); + lv_obj_clean(ctx->wrapperWidget_); + lv_obj_t* errLbl = lv_label_create(ctx->wrapperWidget_); lv_obj_set_style_pad_all(errLbl, 8, 0); lv_label_set_text(errLbl, "Failed to open file.\nPlease try again."); - self->setBrowserToolbarButtons(); + setBrowserToolbarButtons(ctx); delete a; return; } - self->setReaderToolbarButtons(); - lv_obj_clean(self->wrapperWidget_); - self->buildReaderUI(self->wrapperWidget_); + setReaderToolbarButtons(ctx); + lv_obj_clean(ctx->wrapperWidget_); + buildReaderUI(ctx, ctx->wrapperWidget_); delete a; return; } if (a->epub && a->epub->isValid()) { - self->epub_ = a->epub; - self->currentFilePath_ = a->filePath; - self->setReaderToolbarButtons(); - lv_obj_clean(self->wrapperWidget_); - self->buildReaderUI(self->wrapperWidget_); + ctx->epub_ = a->epub; + ctx->currentFilePath_ = a->filePath; + setReaderToolbarButtons(ctx); + lv_obj_clean(ctx->wrapperWidget_); + buildReaderUI(ctx, ctx->wrapperWidget_); } else { LOG_E(TAG, "Failed to open: %s", a->filePath.c_str()); - self->epub_ = nullptr; - self->currentSpineIndex_ = 0; - self->setBrowserToolbarButtons(); - lv_obj_clean(self->wrapperWidget_); - self->buildBrowserUI(self->wrapperWidget_); + ctx->epub_ = nullptr; + ctx->currentSpineIndex_ = 0; + setBrowserToolbarButtons(ctx); + lv_obj_clean(ctx->wrapperWidget_); + buildBrowserUI(ctx, ctx->wrapperWidget_); } delete a; } @@ -216,15 +215,15 @@ void EpubReader::asyncOpenComplete(void* data) { // Fired via lv_async_call after renderPage() when loading a chapter backward (direction < 0). // By the time this runs LVGL has completed layout, so content height is known and we can // scroll to the very end - placing the user at the bottom of the chapter they backed into. -void EpubReader::asyncScrollToEnd(void* data) { - auto* self = static_cast(data); - if (!self->contentWidget_ || !self->wrapperWidget_) return; - lv_obj_t* scroll = lv_obj_get_parent(self->contentWidget_); +void asyncScrollToEnd(void* data) { + auto* ctx = static_cast(data); + if (!ctx->contentWidget_ || !ctx->wrapperWidget_) return; + lv_obj_t* scroll = lv_obj_get_parent(ctx->contentWidget_); if (!scroll) return; lv_obj_scroll_to_y(scroll, LV_COORD_MAX, LV_ANIM_OFF); lv_coord_t sy = lv_obj_get_scroll_y(scroll); - self->pageOffset_ = (sy > 0) ? (size_t)sy : 0; - self->saveProgress(); + ctx->pageOffset_ = (sy > 0) ? (size_t)sy : 0; + saveProgress(ctx); } // Snap a scroll step down to the nearest whole-line multiple so page turns @@ -241,124 +240,122 @@ static lv_coord_t snapStep(lv_coord_t viewH) { // paragraph label Y positions are also multiples of lineH (zero label padding + // pad_row=lineH on contentWidget_) - pages always start on a clean line boundary. // At chapter boundaries (EPUB only) the adjacent chapter is loaded. -void EpubReader::doPrev() { - if (!contentWidget_) return; - lv_obj_t* scroll = lv_obj_get_parent(contentWidget_); +void doPrev(Context* ctx) { + if (!ctx->contentWidget_) return; + lv_obj_t* scroll = lv_obj_get_parent(ctx->contentWidget_); if (!scroll) return; lv_coord_t curY = lv_obj_get_scroll_y(scroll); lv_coord_t step = snapStep(lv_obj_get_height(scroll)); lv_obj_scroll_to_y(scroll, curY > step ? curY - step : 0, LV_ANIM_OFF); lv_coord_t newY = lv_obj_get_scroll_y(scroll); - if (newY == curY && !textMode_) { + if (newY == curY && !ctx->textMode_) { // Scroll didn't move - already at the top; cross into the previous chapter. - if (currentSpineIndex_ > 0) loadChapter(currentSpineIndex_ - 1, -1); + if (ctx->currentSpineIndex_ > 0) loadChapter(ctx, ctx->currentSpineIndex_ - 1, -1); } else { - pageOffset_ = (newY > 0) ? (size_t)newY : 0; - saveProgress(); + ctx->pageOffset_ = (newY > 0) ? (size_t)newY : 0; + saveProgress(ctx); } } -void EpubReader::doNext() { - if (!contentWidget_) return; - lv_obj_t* scroll = lv_obj_get_parent(contentWidget_); +void doNext(Context* ctx) { + if (!ctx->contentWidget_) return; + lv_obj_t* scroll = lv_obj_get_parent(ctx->contentWidget_); if (!scroll) return; lv_coord_t curY = lv_obj_get_scroll_y(scroll); lv_coord_t step = snapStep(lv_obj_get_height(scroll)); lv_obj_scroll_to_y(scroll, curY + step, LV_ANIM_OFF); lv_coord_t newY = lv_obj_get_scroll_y(scroll); - if (newY == curY && !textMode_) { + if (newY == curY && !ctx->textMode_) { // Scroll position didn't move - content fits in the viewport or we've reached // the end. lv_obj_get_scroll_bottom() returns negative for short chapters so // checking == 0 is unreliable; this approach works for all chapter lengths. - loadChapter(currentSpineIndex_ + 1, +1); + loadChapter(ctx, ctx->currentSpineIndex_ + 1, +1); } else { - pageOffset_ = (newY > 0) ? (size_t)newY : 0; - saveProgress(); + ctx->pageOffset_ = (newY > 0) ? (size_t)newY : 0; + saveProgress(ctx); } } -void EpubReader::onPrevPressed(lv_event_t* e) { - static_cast(lv_event_get_user_data(e))->doPrev(); +void onPrevPressed(lv_event_t* e) { + doPrev(static_cast(lv_event_get_user_data(e))); } -void EpubReader::onNextPressed(lv_event_t* e) { - static_cast(lv_event_get_user_data(e))->doNext(); +void onNextPressed(lv_event_t* e) { + doNext(static_cast(lv_event_get_user_data(e))); } -void EpubReader::onReaderTap(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); +void onReaderTap(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); lv_indev_t* indev = lv_indev_active(); if (!indev) return; lv_point_t pt; lv_indev_get_point(indev, &pt); lv_coord_t w = lv_display_get_horizontal_resolution(nullptr); - if (pt.x < w / 2) self->doPrev(); - else self->doNext(); + if (pt.x < w / 2) doPrev(ctx); + else doNext(ctx); } -void EpubReader::onTocPressed(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - self->openTocDialog(); +void onTocPressed(lv_event_t* e) { + openTocDialog(static_cast(lv_event_get_user_data(e))); } -void EpubReader::onBrowsePressed(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - lv_async_call(asyncSwitchToBrowser, self); +void onBrowsePressed(lv_event_t* e) { + lv_async_call(asyncSwitchToBrowser, lv_event_get_user_data(e)); } -void EpubReader::onBrowserBack(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - size_t pos = self->browsePath_.rfind('/'); - if (pos != std::string::npos && self->browsePath_ != self->dataRoot_) { - self->browsePath_ = self->browsePath_.substr(0, pos); +void onBrowserBack(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + size_t pos = ctx->browsePath_.rfind('/'); + if (pos != std::string::npos && ctx->browsePath_ != ctx->dataRoot_) { + ctx->browsePath_ = ctx->browsePath_.substr(0, pos); } - lv_async_call(asyncNavigateBrowser, self); + lv_async_call(asyncNavigateBrowser, ctx); } -void EpubReader::onBrowserItem(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); +void onBrowserItem(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); uintptr_t idx = (uintptr_t)lv_obj_get_user_data(lv_event_get_target_obj(e)); - if (idx >= self->browserEntries_.size()) return; + if (idx >= ctx->browserEntries_.size()) return; - const auto& [name, isDir] = self->browserEntries_[idx]; + const auto& [name, isDir] = ctx->browserEntries_[idx]; if (isDir) { - self->browsePath_ += "/" + name; - lv_async_call(asyncNavigateBrowser, self); + ctx->browsePath_ += "/" + name; + lv_async_call(asyncNavigateBrowser, ctx); } else { - self->pendingFilePath_ = self->browsePath_ + "/" + name; - lv_async_call(asyncOpenEpub, self); + ctx->pendingFilePath_ = ctx->browsePath_ + "/" + name; + lv_async_call(asyncOpenEpub, ctx); } } // "Use Folder" toolbar button - save the current browsePath_ as the books folder. -void EpubReader::onSetBooksFolder(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - self->booksPath_ = self->browsePath_; - self->saveBooksPath(); - lv_async_call(asyncNavigateBrowser, self); +void onSetBooksFolder(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + ctx->booksPath_ = ctx->browsePath_; + saveBooksPath(ctx); + lv_async_call(asyncNavigateBrowser, ctx); } // Shelf page navigation callbacks -void EpubReader::onShelfFirst(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - self->shelfPage_ = 0; - lv_async_call(asyncNavigateBrowser, self); +void onShelfFirst(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + ctx->shelfPage_ = 0; + lv_async_call(asyncNavigateBrowser, ctx); } -void EpubReader::onShelfPrev(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (self->shelfPage_ > 0) --self->shelfPage_; - lv_async_call(asyncNavigateBrowser, self); +void onShelfPrev(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx->shelfPage_ > 0) --ctx->shelfPage_; + lv_async_call(asyncNavigateBrowser, ctx); } -void EpubReader::onShelfNext(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - ++self->shelfPage_; // clamped in buildShelfUI - lv_async_call(asyncNavigateBrowser, self); +void onShelfNext(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + ++ctx->shelfPage_; // clamped in buildShelfUI + lv_async_call(asyncNavigateBrowser, ctx); } -void EpubReader::onShelfLast(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - self->shelfPage_ = INT_MAX; // clamped to totalPages-1 in buildShelfUI - lv_async_call(asyncNavigateBrowser, self); +void onShelfLast(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + ctx->shelfPage_ = INT_MAX; // clamped to totalPages-1 in buildShelfUI + lv_async_call(asyncNavigateBrowser, ctx); } diff --git a/Apps/EpubReader/main/Source/EpubReaderUI.cpp b/Apps/EpubReader/main/Source/EpubReaderUI.cpp index 485b8d9..27de7ce 100644 --- a/Apps/EpubReader/main/Source/EpubReaderUI.cpp +++ b/Apps/EpubReader/main/Source/EpubReaderUI.cpp @@ -1,4 +1,5 @@ #include "EpubReader.h" +#include #include #include #include @@ -11,9 +12,11 @@ static const char* TAG = "EpubReader"; +/** Must match manifest.properties' app.id */ +static constexpr const char* APP_ID = "one.tactility.epubreader"; + // Runtime-loaded Noto Serif fonts - 4 variants for the active display size tier. -// Loaded by EpubReader::loadFonts() on onShow, freed by unloadFonts() on onHide -// (skipped when onHide fires for a dialog roundtrip - tocDialogId_ will be non-zero). +// Loaded by loadFonts() on createWidgets, freed by unloadFonts() in epubReaderTeardown(). // lv_binfont_create/destroy are exported by the firmware's lvgl-module symbols. // NOTE: These are file-scope statics intentionally. Tactility runs one instance of // each app at a time, so there is no multi-instance aliasing or use-after-free risk. @@ -36,21 +39,21 @@ static void setListBtnLongMode(lv_obj_t* btn, lv_label_long_mode_t mode) { // Toolbar helper // --------------------------------------------------------------------------- -void EpubReader::setReaderToolbarButtons() { - lvgl_toolbar_clear_actions(toolbar_); - lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_PREV, onPrevPressed, this); - if (!textMode_) { - lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_LIST, onTocPressed, this); +void setReaderToolbarButtons(Context* ctx) { + lvgl_toolbar_clear_actions(ctx->toolbar_); + lvgl_toolbar_add_text_button_action(ctx->toolbar_, LV_SYMBOL_PREV, onPrevPressed, ctx); + if (!ctx->textMode_) { + lvgl_toolbar_add_text_button_action(ctx->toolbar_, LV_SYMBOL_LIST, onTocPressed, ctx); } - lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_NEXT, onNextPressed, this); - lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_DIRECTORY, onBrowsePressed, this); + lvgl_toolbar_add_text_button_action(ctx->toolbar_, LV_SYMBOL_NEXT, onNextPressed, ctx); + lvgl_toolbar_add_text_button_action(ctx->toolbar_, LV_SYMBOL_DIRECTORY, onBrowsePressed, ctx); } -void EpubReader::setBrowserToolbarButtons() { - lvgl_toolbar_clear_actions(toolbar_); +void setBrowserToolbarButtons(Context* ctx) { + lvgl_toolbar_clear_actions(ctx->toolbar_); // Show "Use Folder" button when the current browse path isn't already the saved books folder - if (browsePath_ != booksPath_) { - lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_DIRECTORY, onSetBooksFolder, this); + if (ctx->browsePath_ != ctx->booksPath_) { + lvgl_toolbar_add_text_button_action(ctx->toolbar_, LV_SYMBOL_DIRECTORY, onSetBooksFolder, ctx); } } @@ -77,19 +80,18 @@ const lv_font_t* selectContentFont(bool italic, bool bold) { // Font lifecycle // --------------------------------------------------------------------------- -void EpubReader::loadFonts() { +void loadFonts(Context* ctx) { if (s_fontRegular) return; // already loaded // Without PSRAM the heap is too constrained to hold even a single binary font // alongside the epub parser and LVGL allocations - skip loading entirely. - // onShow() will have already shown an alert dialog for this case. + // epubReaderCreateWidgets() will have already shown an alert dialog for this case. if (heap_caps_get_total_size(MALLOC_CAP_SPIRAM) == 0) return; // Verify the assets directory exists before attempting individual file loads. // This produces one clear diagnostic instead of one error per font file. - char assetsDir[256]; size_t dirSz = sizeof(assetsDir); - tt_app_get_assets_path(appHandle_, assetsDir, &dirSz); - if (dirSz == 0) { + char assetsDir[256]; + if (app_paths_get_assets_directory(APP_ID, assetsDir, sizeof(assetsDir)) != ERROR_NONE) { LOG_E(TAG, "loadFonts: could not resolve assets path"); return; } @@ -117,9 +119,8 @@ void EpubReader::loadFonts() { for (auto& v : variants) { char filename[64]; snprintf(filename, sizeof(filename), "font_%spt_%s.bin", sz, v.suffix); - char assetPath[256]; size_t pathSz = sizeof(assetPath); - tt_app_get_assets_child_path(appHandle_, filename, assetPath, &pathSz); - if (pathSz == 0) { + char assetPath[256]; + if (app_paths_get_assets_path(APP_ID, filename, assetPath, sizeof(assetPath)) != ERROR_NONE) { LOG_E(TAG, "loadFonts: no asset path for %s", filename); continue; } @@ -132,14 +133,14 @@ void EpubReader::loadFonts() { } -void EpubReader::unloadFonts() { +void unloadFonts() { lv_font_t** ptrs[] = { &s_fontRegular, &s_fontItalic, &s_fontBold, &s_fontBoldItalic }; for (auto* p : ptrs) { if (*p) { lv_binfont_destroy(*p); *p = nullptr; } } } -void EpubReader::buildReaderUI(lv_obj_t* parent) { +void buildReaderUI(Context* ctx, lv_obj_t* parent) { lv_obj_t* scroll = lv_obj_create(parent); lv_obj_set_width(scroll, LV_PCT(100)); lv_obj_set_flex_grow(scroll, 1); @@ -149,34 +150,34 @@ void EpubReader::buildReaderUI(lv_obj_t* parent) { // Tap left half = prev, right half = next. // LV_EVENT_CLICKED only fires on press+release with minimal movement, // so it won't conflict with vertical scrolling in text mode. - lv_obj_add_event_cb(scroll, onReaderTap, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(scroll, onReaderTap, LV_EVENT_CLICKED, ctx); - if (textMode_) { + if (ctx->textMode_) { // Plain text file: single label, Prev/Next scroll by screen height. - contentWidget_ = lv_label_create(scroll); - lv_obj_set_width(contentWidget_, LV_PCT(100)); - lv_label_set_long_mode(contentWidget_, LV_LABEL_LONG_MODE_WRAP); - lv_obj_set_style_text_font(contentWidget_, selectContentFont(false, false), 0); - lv_label_set_text(contentWidget_, pageContent_.c_str()); - lv_obj_scroll_to_y(scroll, (lv_coord_t)pageOffset_, LV_ANIM_OFF); - saveProgress(); - } else if (epub_) { + ctx->contentWidget_ = lv_label_create(scroll); + lv_obj_set_width(ctx->contentWidget_, LV_PCT(100)); + lv_label_set_long_mode(ctx->contentWidget_, LV_LABEL_LONG_MODE_WRAP); + lv_obj_set_style_text_font(ctx->contentWidget_, selectContentFont(false, false), 0); + lv_label_set_text(ctx->contentWidget_, ctx->pageContent_.c_str()); + lv_obj_scroll_to_y(scroll, (lv_coord_t)ctx->pageOffset_, LV_ANIM_OFF); + saveProgress(ctx); + } else if (ctx->epub_) { // EPUB: transparent flex-column container; renderPage() fills it with // per-paragraph labels (one per paragraph, with individual alignment). - contentWidget_ = lv_obj_create(scroll); - lv_obj_set_width(contentWidget_, LV_PCT(100)); - lv_obj_set_height(contentWidget_, LV_SIZE_CONTENT); - lv_obj_set_flex_flow(contentWidget_, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(contentWidget_, 0, 0); + ctx->contentWidget_ = lv_obj_create(scroll); + lv_obj_set_width(ctx->contentWidget_, LV_PCT(100)); + lv_obj_set_height(ctx->contentWidget_, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(ctx->contentWidget_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(ctx->contentWidget_, 0, 0); // One line-height of gap between paragraphs keeps all paragraph tops on // exact lineH-multiple boundaries - required for snapStep to work cleanly. - lv_obj_set_style_pad_row(contentWidget_, + lv_obj_set_style_pad_row(ctx->contentWidget_, (lv_coord_t)lv_font_get_line_height(selectContentFont(false, false)), 0); - lv_obj_set_style_border_width(contentWidget_, 0, 0); - lv_obj_set_style_bg_opa(contentWidget_, LV_OPA_TRANSP, 0); - lv_obj_remove_flag(contentWidget_, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_remove_flag(contentWidget_, LV_OBJ_FLAG_CLICKABLE); - loadChapter(currentSpineIndex_, +1); + lv_obj_set_style_border_width(ctx->contentWidget_, 0, 0); + lv_obj_set_style_bg_opa(ctx->contentWidget_, LV_OPA_TRANSP, 0); + lv_obj_remove_flag(ctx->contentWidget_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(ctx->contentWidget_, LV_OBJ_FLAG_CLICKABLE); + loadChapter(ctx, ctx->currentSpineIndex_, +1); } } @@ -235,7 +236,7 @@ static void renderSpanParagraph(lv_obj_t* parent, const char* seg, size_t len, } // --------------------------------------------------------------------------- -// renderSlice - parse ESC-encoded text into per-paragraph LVGL widgets +// renderSlice - parse ESC-encoded page slice into per-paragraph LVGL widgets // --------------------------------------------------------------------------- // ESC token protocol (see HtmlStrip.h): // Every paragraph starts with ESC + 'L'|'C'|'R' (alignment). @@ -245,8 +246,8 @@ static void renderSpanParagraph(lv_obj_t* parent, const char* seg, size_t len, // // Paragraphs without inline tokens → lv_label (lighter, fewer allocations). // Paragraphs with inline tokens → lv_spangroup (per-span font selection). -void EpubReader::renderSlice(const std::string& slice) { - if (!contentWidget_) return; +void renderSlice(Context* ctx, const std::string& slice) { + if (!ctx->contentWidget_) return; const lv_font_t* font = selectContentFont(false, false); size_t pos = 0; @@ -280,10 +281,10 @@ void EpubReader::renderSlice(const std::string& slice) { if (hasContent) { if (hasInlineTokens(seg, segLen)) { // Mixed-style paragraph: spangroup renders inline bold/italic runs - renderSpanParagraph(contentWidget_, seg, segLen, align); + renderSpanParagraph(ctx->contentWidget_, seg, segLen, align); } else { // Plain paragraph: lv_label (simpler, lower memory overhead) - lv_obj_t* lbl = lv_label_create(contentWidget_); + lv_obj_t* lbl = lv_label_create(ctx->contentWidget_); lv_obj_set_width(lbl, LV_PCT(100)); lv_obj_set_style_pad_all(lbl, 0, 0); // override theme padding; keeps heights = N*lineH lv_label_set_long_mode(lbl, LV_LABEL_LONG_MODE_WRAP); @@ -297,10 +298,10 @@ void EpubReader::renderSlice(const std::string& slice) { } } -void EpubReader::buildBrowserUI(lv_obj_t* parent) { +void buildBrowserUI(Context* ctx, lv_obj_t* parent) { // When at the configured books folder, show the shelf instead of the file list - if (!booksPath_.empty() && browsePath_ == booksPath_) { - buildShelfUI(parent); + if (!ctx->booksPath_.empty() && ctx->browsePath_ == ctx->booksPath_) { + buildShelfUI(ctx, parent); return; } @@ -315,17 +316,17 @@ void EpubReader::buildBrowserUI(lv_obj_t* parent) { lv_obj_set_style_bg_opa(pathBar, LV_OPA_TRANSP, 0); lv_obj_remove_flag(pathBar, LV_OBJ_FLAG_SCROLLABLE); - if (browsePath_ != dataRoot_) { + if (ctx->browsePath_ != ctx->dataRoot_) { lv_obj_t* backBtn = lv_button_create(pathBar); lv_obj_set_size(backBtn, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_style_pad_all(backBtn, 4, 0); lv_label_set_text(lv_label_create(backBtn), LV_SYMBOL_LEFT " Back"); - lv_obj_add_event_cb(backBtn, onBrowserBack, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(backBtn, onBrowserBack, LV_EVENT_CLICKED, ctx); } lv_obj_t* pathLabel = lv_label_create(pathBar); lv_obj_set_flex_grow(pathLabel, 1); - lv_label_set_text(pathLabel, browsePath_.c_str()); + lv_label_set_text(pathLabel, ctx->browsePath_.c_str()); lv_label_set_long_mode(pathLabel, LV_LABEL_LONG_MODE_DOTS);//LV_LABEL_LONG_MODE_SCROLL_CIRCULAR // File list @@ -335,8 +336,8 @@ void EpubReader::buildBrowserUI(lv_obj_t* parent) { lv_obj_set_style_border_width(list, 0, 0); // Scan directory - browserEntries_.clear(); - DIR* dir = opendir(browsePath_.c_str()); + ctx->browserEntries_.clear(); + DIR* dir = opendir(ctx->browsePath_.c_str()); if (dir) { struct dirent* entry; while ((entry = readdir(dir)) != nullptr) { @@ -345,36 +346,36 @@ void EpubReader::buildBrowserUI(lv_obj_t* parent) { bool isDir = (entry->d_type == DT_DIR); if (entry->d_type == DT_UNKNOWN) { struct stat st; - std::string fullPath = browsePath_ + "/" + name; + std::string fullPath = ctx->browsePath_ + "/" + name; if (stat(fullPath.c_str(), &st) == 0) { isDir = S_ISDIR(st.st_mode); } } if (isDir || isSupportedFile(name)) { - browserEntries_.push_back({name, isDir}); + ctx->browserEntries_.push_back({name, isDir}); } } closedir(dir); } else { - LOG_W(TAG, "Cannot open dir: %s", browsePath_.c_str()); + LOG_W(TAG, "Cannot open dir: %s", ctx->browsePath_.c_str()); } // Sort: directories first, then alphabetically within each group - std::sort(browserEntries_.begin(), browserEntries_.end(), + std::sort(ctx->browserEntries_.begin(), ctx->browserEntries_.end(), [](const auto& a, const auto& b) { if (a.second != b.second) return a.second > b.second; return a.first < b.first; }); - if (browserEntries_.empty()) { + if (ctx->browserEntries_.empty()) { lv_list_add_text(list, "No supported files found."); } else { - for (size_t i = 0; i < browserEntries_.size(); ++i) { - const auto& [name, isDir] = browserEntries_[i]; + for (size_t i = 0; i < ctx->browserEntries_.size(); ++i) { + const auto& [name, isDir] = ctx->browserEntries_[i]; const char* icon = isDir ? LV_SYMBOL_DIRECTORY : LV_SYMBOL_FILE; lv_obj_t* btn = lv_list_add_button(list, icon, name.c_str()); lv_obj_set_user_data(btn, (void*)(uintptr_t)i); - lv_obj_add_event_cb(btn, onBrowserItem, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(btn, onBrowserItem, LV_EVENT_CLICKED, ctx); setListBtnLongMode(btn, LV_LABEL_LONG_MODE_DOTS);//LV_LABEL_LONG_MODE_SCROLL_CIRCULAR } } @@ -382,7 +383,7 @@ void EpubReader::buildBrowserUI(lv_obj_t* parent) { // Recursively scans path for epub/txt files, appending entries to browserEntries_. // Called with prefix="" for booksPath_; recurses one level into subdirectories. -void EpubReader::scanBooksDir(const std::string& path, const std::string& prefix) { +void scanBooksDir(Context* ctx, const std::string& path, const std::string& prefix) { DIR* d = opendir(path.c_str()); if (!d) return; struct dirent* ent; @@ -396,14 +397,14 @@ void EpubReader::scanBooksDir(const std::string& path, const std::string& prefix isDir = S_ISDIR(st.st_mode); } if (isDir && prefix.empty()) { - scanBooksDir(path + "/" + name, name + "/"); + scanBooksDir(ctx, path + "/" + name, name + "/"); } else if (!isDir) { auto p = name.rfind('.'); if (p == std::string::npos) continue; std::string e = name.substr(p); std::transform(e.begin(), e.end(), e.begin(), ::tolower); if (e == ".epub" || e == ".txt") - browserEntries_.push_back({prefix + name, false}); + ctx->browserEntries_.push_back({prefix + name, false}); } } closedir(d); @@ -411,7 +412,7 @@ void EpubReader::scanBooksDir(const std::string& path, const std::string& prefix // Books shelf view - shown when browsePath_ == booksPath_. // Scans booksPath_ and one level of subdirectories into a single flat sorted list. -void EpubReader::buildShelfUI(lv_obj_t* parent) { +void buildShelfUI(Context* ctx, lv_obj_t* parent) { lv_coord_t dispW = lv_display_get_horizontal_resolution(nullptr); lv_coord_t dispH = lv_display_get_vertical_resolution(nullptr); bool isLarge = (dispW >= 480 || dispH >= 480); @@ -423,7 +424,7 @@ void EpubReader::buildShelfUI(lv_obj_t* parent) { int navPadVer = isLarge ? 6 : 3; // Optional path bar with Back so the user can navigate away from the shelf - if (booksPath_ != dataRoot_) { + if (ctx->booksPath_ != ctx->dataRoot_) { lv_obj_t* pathBar = lv_obj_create(parent); lv_obj_set_size(pathBar, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_flex_flow(pathBar, LV_FLEX_FLOW_ROW); @@ -438,22 +439,22 @@ void EpubReader::buildShelfUI(lv_obj_t* parent) { lv_obj_set_size(backBtn, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_style_pad_all(backBtn, padBar, 0); lv_label_set_text(lv_label_create(backBtn), LV_SYMBOL_LEFT " Browse"); - lv_obj_add_event_cb(backBtn, onBrowserBack, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(backBtn, onBrowserBack, LV_EVENT_CLICKED, ctx); lv_obj_t* pathLbl = lv_label_create(pathBar); lv_obj_set_flex_grow(pathLbl, 1); lv_label_set_long_mode(pathLbl, LV_LABEL_LONG_MODE_DOTS);//LV_LABEL_LONG_MODE_SCROLL_CIRCULAR - lv_label_set_text(pathLbl, booksPath_.c_str()); + lv_label_set_text(pathLbl, ctx->booksPath_.c_str()); } // Scan booksPath_ for books, and one level of subdirectories into a flat list. // Subfolder entries are stored as "subfolder/filename" so onBrowserItem builds // the correct full path via browsePath_ + "/" + name. - browserEntries_.clear(); - scanBooksDir(booksPath_, ""); + ctx->browserEntries_.clear(); + scanBooksDir(ctx, ctx->booksPath_, ""); // Sort alphabetically by filename (ignoring subfolder prefix) - std::sort(browserEntries_.begin(), browserEntries_.end(), + std::sort(ctx->browserEntries_.begin(), ctx->browserEntries_.end(), [](const auto& a, const auto& b) { // rfind returns npos if no '/' found; npos+1 wraps to 0, yielding the full string auto nameA = a.first.substr(a.first.rfind('/') + 1); @@ -465,10 +466,10 @@ void EpubReader::buildShelfUI(lv_obj_t* parent) { }); // Clamp shelfPage_ to valid range - int totalItems = (int)browserEntries_.size(); + int totalItems = (int)ctx->browserEntries_.size(); int totalPages = std::max(1, (totalItems + pageSize - 1) / pageSize); - shelfPage_ = std::max(0, std::min(shelfPage_, totalPages - 1)); - int startIdx = shelfPage_ * pageSize; + ctx->shelfPage_ = std::max(0, std::min(ctx->shelfPage_, totalPages - 1)); + int startIdx = ctx->shelfPage_ * pageSize; int endIdx = std::min(startIdx + pageSize, totalItems); // Non-scrollable list; items share height equally via flex_grow=1 @@ -478,11 +479,11 @@ void EpubReader::buildShelfUI(lv_obj_t* parent) { lv_obj_set_style_border_width(list, 0, 0); lv_obj_remove_flag(list, LV_OBJ_FLAG_SCROLLABLE); - if (browserEntries_.empty()) { + if (ctx->browserEntries_.empty()) { lv_list_add_text(list, "No books found in books folder."); } else { for (int i = startIdx; i < endIdx; ++i) { - const std::string& entry = browserEntries_[(size_t)i].first; + const std::string& entry = ctx->browserEntries_[(size_t)i].first; // Display name: strip subfolder prefix and extension std::string displayName = entry.substr(entry.rfind('/') + 1); auto dot = displayName.rfind('.'); @@ -496,7 +497,7 @@ void EpubReader::buildShelfUI(lv_obj_t* parent) { lv_obj_t* btn = lv_list_add_button(list, icon, displayName.c_str()); lv_obj_set_flex_grow(btn, 1); // equal height share across page items lv_obj_set_user_data(btn, (void*)(uintptr_t)i); - lv_obj_add_event_cb(btn, onBrowserItem, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(btn, onBrowserItem, LV_EVENT_CLICKED, ctx); setListBtnLongMode(btn, LV_LABEL_LONG_MODE_DOTS);//LV_LABEL_LONG_MODE_SCROLL_CIRCULAR lv_obj_t* iconLbl = lv_obj_get_child(btn, 0); if (iconLbl) lv_obj_set_style_text_font(iconLbl, selectIconFont(), 0); @@ -514,8 +515,8 @@ void EpubReader::buildShelfUI(lv_obj_t* parent) { lv_obj_set_style_bg_opa(navBar, LV_OPA_TRANSP, 0); lv_obj_remove_flag(navBar, LV_OBJ_FLAG_SCROLLABLE); - bool canPrev = (shelfPage_ > 0); - bool canNext = (shelfPage_ < totalPages - 1); + bool canPrev = (ctx->shelfPage_ > 0); + bool canNext = (ctx->shelfPage_ < totalPages - 1); auto makeNavBtn = [&](const char* label, lv_event_cb_t cb, bool enabled) { lv_obj_t* btn = lv_button_create(navBar); @@ -523,7 +524,7 @@ void EpubReader::buildShelfUI(lv_obj_t* parent) { lv_obj_set_style_pad_hor(btn, navPadHor, 0); lv_obj_set_style_pad_ver(btn, navPadVer, 0); lv_label_set_text(lv_label_create(btn), label); - if (enabled) lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, this); + if (enabled) lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, ctx); else lv_obj_add_state(btn, LV_STATE_DISABLED); }; @@ -531,7 +532,7 @@ void EpubReader::buildShelfUI(lv_obj_t* parent) { makeNavBtn(LV_SYMBOL_LEFT, onShelfPrev, canPrev); char pageBuf[24]; - snprintf(pageBuf, sizeof(pageBuf), "%d / %d", shelfPage_ + 1, totalPages); + snprintf(pageBuf, sizeof(pageBuf), "%d / %d", ctx->shelfPage_ + 1, totalPages); lv_obj_t* pageLbl = lv_label_create(navBar); lv_obj_set_style_pad_hor(pageLbl, 10, 0); lv_label_set_text(pageLbl, pageBuf); diff --git a/Apps/EpubReader/main/Source/main.cpp b/Apps/EpubReader/main/Source/main.cpp index cede946..86e2840 100644 --- a/Apps/EpubReader/main/Source/main.cpp +++ b/Apps/EpubReader/main/Source/main.cpp @@ -1,10 +1,81 @@ #include "EpubReader.h" -#include + +#include +#include +#include + +#include +#include extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + Context ctx {}; + ctx.appInstanceId = app_instance_id; + if (argc > 0) { + ctx.launchFilePath_ = argv[0]; + } + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, epubReaderCreateWidgets, &ctx); + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(app_instance_id); + should_close = true; + break; + + case APP_EVENT_RESULT: { + uint32_t launch_id = event.result.launch_id; + + if (launch_id == ctx.psramAlertId_) { + // PSRAM alert closed - this app cannot run without PSRAM, close self. + ctx.psramAlertId_ = 0; + app_manager_stop(launch_id); + app_manager_finish(app_instance_id); + should_close = true; + } else if (launch_id == ctx.tocDialogId_) { + ctx.tocDialogId_ = 0; + int32_t selection = event.result.result; + if (selection >= 0 && ctx.epub_) { + const auto& toc = ctx.epub_->getToc(); + const auto& spine = ctx.epub_->getSpine(); + if (selection < (int32_t)toc.size()) { + for (size_t i = 0; i < spine.size(); ++i) { + if (spine[i].href == toc[(size_t)selection].href) { + lvgl_lock(); + loadChapter(&ctx, (int)i, 0); + lvgl_unlock(); + break; + } + } + } + } + app_manager_stop(launch_id); + } + break; + } + + default: + break; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + epubReaderTeardown(&ctx); + return 0; } diff --git a/Apps/EspNowBridge/CMakeLists.txt b/Apps/EspNowBridge/CMakeLists.txt index 119c9cd..1e89b68 100644 --- a/Apps/EspNowBridge/CMakeLists.txt +++ b/Apps/EspNowBridge/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(EspNowBridge) tactility_project(EspNowBridge) diff --git a/Apps/EspNowBridge/main/CMakeLists.txt b/Apps/EspNowBridge/main/CMakeLists.txt index 121a360..025768b 100644 --- a/Apps/EspNowBridge/main/CMakeLists.txt +++ b/Apps/EspNowBridge/main/CMakeLists.txt @@ -4,8 +4,5 @@ file(GLOB_RECURSE SOURCE_FILES idf_component_register( SRCS ${SOURCE_FILES} - # Library headers must be included directly, - # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include REQUIRES TactilitySDK bootloader_support esp_app_format ) diff --git a/Apps/EspNowBridge/main/Source/EspNowBridge.cpp b/Apps/EspNowBridge/main/Source/EspNowBridge.cpp index 3f979e7..9433a1b 100644 --- a/Apps/EspNowBridge/main/Source/EspNowBridge.cpp +++ b/Apps/EspNowBridge/main/Source/EspNowBridge.cpp @@ -1,13 +1,12 @@ #include "EspNowBridge.h" +#include #include #include #include #include -#include #include -#include #include #include @@ -32,6 +31,9 @@ static constexpr size_t CHUNK_SIZE = 1500; static constexpr uint32_t TRANSPORT_WAIT_TIMEOUT_MS = 5000; static constexpr uint32_t UPDATE_TASK_STACK_SIZE = 8192; +/** Must match manifest.properties' app.id */ +static constexpr const char* APP_ID = "one.tactility.espnowbridge"; + AutoScanPauseGuard::AutoScanPauseGuard() { wifi_auto_scan_set_paused(true); } AutoScanPauseGuard::~AutoScanPauseGuard() { wifi_auto_scan_set_paused(false); } @@ -194,49 +196,28 @@ static bool activateSupported(uint32_t major, uint32_t minor) { return (major > 2) || (major == 2 && minor > 5); } -std::atomic EspNowBridge::liveInstance_{nullptr}; - -void EspNowBridge::onCreate(AppHandle app) { - appHandle_ = app; - taskDoneSemaphore_ = xSemaphoreCreateBinary(); - liveInstance_ = this; -} - -void EspNowBridge::onDestroy(AppHandle /*app*/) { - // Clear liveInstance_ first so any task still running bails out at its next liveInstance_ - // check instead of continuing to touch this instance's members. - liveInstance_ = nullptr; - - // Wait for any outstanding background task (OTA update, transport-wait) to actually finish - - // the app framework frees this instance shortly after onDestroy() returns, so a task that - // outlives it would dereference freed memory. - while (outstandingTasks_.load() > 0) { - if (taskDoneSemaphore_ != nullptr) { - xSemaphoreTake(taskDoneSemaphore_, pdMS_TO_TICKS(1000)); - } - } - - if (taskDoneSemaphore_ != nullptr) { - vSemaphoreDelete(taskDoneSemaphore_); - taskDoneSemaphore_ = nullptr; - } -} +// Only one EspNowBridge instance is ever live at a time (app-module owns a single instance per +// running app), so a single static "is this instance still current" pointer, guarded by an +// atomic, lets the OTA worker task and dispatchToUi()'s lv_async_call closures check +// liveInstance == ctx before touching any member, instead of needing a shared-ownership lifetime +// guard. +static std::atomic liveInstance{nullptr}; -void EspNowBridge::refreshCurrentVersion() { +static void refreshCurrentVersion(Context* ctx) { char versionStr[32]; - if (getCurrentVersionString(firmwareOps_, firmwareCtx_, versionStr, sizeof(versionStr))) { - lv_label_set_text_fmt(currentVersionLabel_, "Co-processor firmware: %s", versionStr); + if (getCurrentVersionString(ctx->firmwareOps, ctx->firmwareCtx, versionStr, sizeof(versionStr))) { + lv_label_set_text_fmt(ctx->currentVersionLabel, "Co-processor firmware: %s", versionStr); } else { - lv_label_set_text(currentVersionLabel_, "Co-processor firmware: unknown (link not up)"); + lv_label_set_text(ctx->currentVersionLabel, "Co-processor firmware: unknown (link not up)"); } } -bool EspNowBridge::isWifiRadioOn() { - if (wifiDevice_ == nullptr) { +static bool isWifiRadioOn(Context* ctx) { + if (ctx->wifiDevice == nullptr) { return false; } WifiRadioState radioState = WIFI_RADIO_STATE_OFF; - if (wifi_get_radio_state(wifiDevice_, &radioState) != ERROR_NONE) { + if (wifi_get_radio_state(ctx->wifiDevice, &radioState) != ERROR_NONE) { return false; } // ON with any station state (disconnected/pending/connected) is fine - the ESP-NOW bridge @@ -244,45 +225,48 @@ bool EspNowBridge::isWifiRadioOn() { return radioState == WIFI_RADIO_STATE_ON; } -void EspNowBridge::refreshWifiPrompt() { - if (isWifiRadioOn()) { - lv_obj_add_flag(enableWifiButton_, LV_OBJ_FLAG_HIDDEN); - setUpdateButtonsDisabled(false); +static void setUpdateButtonsDisabled(Context* ctx, bool disabled) { + if (disabled) { + lv_obj_add_state(ctx->updateButton, LV_STATE_DISABLED); + lv_obj_add_state(ctx->updateBundledButton, LV_STATE_DISABLED); } else { - lv_obj_clear_flag(enableWifiButton_, LV_OBJ_FLAG_HIDDEN); - setUpdateButtonsDisabled(true); + lv_obj_clear_state(ctx->updateButton, LV_STATE_DISABLED); + lv_obj_clear_state(ctx->updateBundledButton, LV_STATE_DISABLED); } } -void EspNowBridge::setUpdateButtonsDisabled(bool disabled) { - if (disabled) { - lv_obj_add_state(updateButton_, LV_STATE_DISABLED); - lv_obj_add_state(updateBundledButton_, LV_STATE_DISABLED); +static void refreshWifiPrompt(Context* ctx) { + if (isWifiRadioOn(ctx)) { + lv_obj_add_flag(ctx->enableWifiButton, LV_OBJ_FLAG_HIDDEN); + setUpdateButtonsDisabled(ctx, false); } else { - lv_obj_clear_state(updateButton_, LV_STATE_DISABLED); - lv_obj_clear_state(updateBundledButton_, LV_STATE_DISABLED); + lv_obj_clear_flag(ctx->enableWifiButton, LV_OBJ_FLAG_HIDDEN); + setUpdateButtonsDisabled(ctx, true); } } -void EspNowBridge::setStatus(const std::string& text) { - lv_label_set_text(statusLabel_, text.c_str()); +static void setStatus(Context* ctx, const std::string& text) { + lv_label_set_text(ctx->statusLabel, text.c_str()); } -void EspNowBridge::setProgress(int percent) { - lv_bar_set_value(progressBar_, percent, LV_ANIM_OFF); +static void setProgress(Context* ctx, int percent) { + lv_bar_set_value(ctx->progressBar, percent, LV_ANIM_OFF); } namespace { struct UiDispatchPayload { - EspNowBridge* instance; - void (*work)(EspNowBridge&, void*); + Context* instance; + void (*work)(Context&, void*); void* context; void (*freeContext)(void*); }; } -void EspNowBridge::dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*)) { - auto* payload = new UiDispatchPayload{this, work, context, freeContext}; +/** Marshal a UI-touching closure onto the LVGL task. Only ever invoked if liveInstance is still + * @a ctx (checked at dispatch time and again right before running, on the LVGL task) and + * ctx->isShown is true (this app's widget tree exists). */ +static void dispatchToUi(Context* ctx, void (*work)(Context&, void*), void* context, void (*freeContext)(void*)) { + auto* payload = new UiDispatchPayload{ctx, work, context, freeContext}; // lv_async_call() itself is an LVGL operation and must be lock-guarded when called from a // non-LVGL task (see lvgl_lock()'s doc comment) - the OTA worker task calls dispatchToUi() // repeatedly during the transfer, and without this lock most of those calls were silently @@ -303,7 +287,7 @@ void EspNowBridge::dispatchToUi(void (*work)(EspNowBridge&, void*), void* contex lv_result_t result = lv_async_call([](void* userData) { auto* payload = static_cast(userData); - if (EspNowBridge::liveInstance_.load() == payload->instance && payload->instance->isShown_.load()) { + if (liveInstance.load() == payload->instance && payload->instance->isShown.load()) { payload->work(*payload->instance, payload->context); } if (payload->freeContext != nullptr) { @@ -323,46 +307,46 @@ void EspNowBridge::dispatchToUi(void (*work)(EspNowBridge&, void*), void* contex namespace { -void workSetStatus(EspNowBridge& app, void* context) { - app.setStatus(*static_cast(context)); +void workSetStatus(Context& app, void* context) { + setStatus(&app, *static_cast(context)); } void freeString(void* context) { delete static_cast(context); } -void workSetProgress(EspNowBridge& app, void* context) { - app.setProgress(*static_cast(context)); +void workSetProgress(Context& app, void* context) { + setProgress(&app, *static_cast(context)); } void freeInt(void* context) { delete static_cast(context); } } // namespace -void EspNowBridge::performUpdate(const std::string& filePath) { - dispatchToUi([](EspNowBridge& app, void*) { - app.setUpdateButtonsDisabled(true); - app.setProgress(0); - app.setStatus("Waiting for co-processor link..."); +static void performUpdate(Context* ctx, const std::string& filePath) { + dispatchToUi(ctx, [](Context& app, void*) { + setUpdateButtonsDisabled(&app, true); + setProgress(&app, 0); + setStatus(&app, "Waiting for co-processor link..."); }, nullptr, nullptr); - if (firmwareOps_ == nullptr) { - dispatchToUi([](EspNowBridge& app, void*) { - app.setStatus("This WiFi device has no updatable co-processor"); - app.setUpdateButtonsDisabled(false); + if (ctx->firmwareOps == nullptr) { + dispatchToUi(ctx, [](Context& app, void*) { + setStatus(&app, "This WiFi device has no updatable co-processor"); + setUpdateButtonsDisabled(&app, false); }, nullptr, nullptr); return; } - if (!firmwareOps_->wait_ready(firmwareCtx_, TRANSPORT_WAIT_TIMEOUT_MS)) { - dispatchToUi([](EspNowBridge& app, void*) { - app.setStatus("Co-processor link not available - update cancelled"); - app.setUpdateButtonsDisabled(false); + if (!ctx->firmwareOps->wait_ready(ctx->firmwareCtx, TRANSPORT_WAIT_TIMEOUT_MS)) { + dispatchToUi(ctx, [](Context& app, void*) { + setStatus(&app, "Co-processor link not available - update cancelled"); + setUpdateButtonsDisabled(&app, false); }, nullptr, nullptr); return; } FILE* file = fopen(filePath.c_str(), "rb"); if (file == nullptr) { - dispatchToUi([](EspNowBridge& app, void*) { - app.setStatus("Failed to open selected file"); - app.setUpdateButtonsDisabled(false); + dispatchToUi(ctx, [](Context& app, void*) { + setStatus(&app, "Failed to open selected file"); + setUpdateButtonsDisabled(&app, false); }, nullptr, nullptr); return; } @@ -371,9 +355,9 @@ void EspNowBridge::performUpdate(const std::string& filePath) { long fileSizeSigned = ftell(file); if (fileSizeSigned <= 0) { fclose(file); - dispatchToUi([](EspNowBridge& app, void*) { - app.setStatus("Failed to determine file size"); - app.setUpdateButtonsDisabled(false); + dispatchToUi(ctx, [](Context& app, void*) { + setStatus(&app, "Failed to determine file size"); + setUpdateButtonsDisabled(&app, false); }, nullptr, nullptr); return; } @@ -387,9 +371,9 @@ void EspNowBridge::performUpdate(const std::string& filePath) { bool isMergedBin = findAppPartitionInMergedBin(file, appOffset, partitionSize); if (isMergedBin && appOffset >= fileSize) { fclose(file); - dispatchToUi([](EspNowBridge& app, void*) { - app.setStatus("Merged bin's app partition is outside the file - selected file looks truncated"); - app.setUpdateButtonsDisabled(false); + dispatchToUi(ctx, [](Context& app, void*) { + setStatus(&app, "Merged bin's app partition is outside the file - selected file looks truncated"); + setUpdateButtonsDisabled(&app, false); }, nullptr, nullptr); return; } @@ -398,9 +382,9 @@ void EspNowBridge::performUpdate(const std::string& filePath) { std::string parseError; if (!parseImageHeader(file, appOffset, newVersion, sizeof(newVersion), &parseError)) { fclose(file); - dispatchToUi(workSetStatus, new std::string(parseError), freeString); - dispatchToUi([](EspNowBridge& app, void*) { - app.setUpdateButtonsDisabled(false); + dispatchToUi(ctx, workSetStatus, new std::string(parseError), freeString); + dispatchToUi(ctx, [](Context& app, void*) { + setUpdateButtonsDisabled(&app, false); }, nullptr, nullptr); return; } @@ -414,34 +398,34 @@ void EspNowBridge::performUpdate(const std::string& filePath) { { char buf[64]; snprintf(buf, sizeof(buf), "Pushing firmware %s...", versionStr.c_str()); - dispatchToUi(workSetStatus, new std::string(buf), freeString); + dispatchToUi(ctx, workSetStatus, new std::string(buf), freeString); } - // Held on the app instance (not a local variable) so it outlives this function - see - // heldAutoScanPauseGuard_'s declaration for why. Released when the host actually restarts - // (moot, since esp_restart() doesn't return) or if the update fails early below. - heldAutoScanPauseGuard_.emplace(); + // Held on the Context (not a local variable) so it outlives this function - see + // Context::heldAutoScanPauseGuard's declaration for why. Released when the host actually + // restarts (moot, since esp_restart() doesn't return) or if the update fails early below. + ctx->heldAutoScanPauseGuard.emplace(); FirmwareUpdateRequest updateRequest = {}; updateRequest.image_size = firmwareSize; FirmwareUpdateHandle* handle = nullptr; - if (firmwareOps_->begin(firmwareCtx_, &updateRequest, &handle) != ERROR_NONE) { + if (ctx->firmwareOps->begin(ctx->firmwareCtx, &updateRequest, &handle) != ERROR_NONE) { fclose(file); - heldAutoScanPauseGuard_.reset(); - dispatchToUi([](EspNowBridge& app, void*) { - app.setStatus("Failed to start OTA on co-processor"); - app.setUpdateButtonsDisabled(false); + ctx->heldAutoScanPauseGuard.reset(); + dispatchToUi(ctx, [](Context& app, void*) { + setStatus(&app, "Failed to start OTA on co-processor"); + setUpdateButtonsDisabled(&app, false); }, nullptr, nullptr); return; } if (fseek(file, static_cast(appOffset), SEEK_SET) != 0) { fclose(file); - firmwareOps_->abort(handle); - heldAutoScanPauseGuard_.reset(); - dispatchToUi([](EspNowBridge& app, void*) { - app.setStatus("Failed to seek to firmware start"); - app.setUpdateButtonsDisabled(false); + ctx->firmwareOps->abort(handle); + ctx->heldAutoScanPauseGuard.reset(); + dispatchToUi(ctx, [](Context& app, void*) { + setStatus(&app, "Failed to seek to firmware start"); + setUpdateButtonsDisabled(&app, false); }, nullptr, nullptr); return; } @@ -460,8 +444,8 @@ void EspNowBridge::performUpdate(const std::string& filePath) { break; } - if (firmwareOps_->write(handle, chunk, actuallyRead) != ERROR_NONE) { - LOG_E(TAG, "firmwareOps_->write() failed at offset %zu", sent); + if (ctx->firmwareOps->write(handle, chunk, actuallyRead) != ERROR_NONE) { + LOG_E(TAG, "firmwareOps->write() failed at offset %zu", sent); writeFailed = true; break; } @@ -479,7 +463,7 @@ void EspNowBridge::performUpdate(const std::string& filePath) { // under sustained OTA write load. int percent = (int)((sent * 100) / firmwareSize); if (percent != lastReportedPercent) { - dispatchToUi(workSetProgress, new int(percent), freeInt); + dispatchToUi(ctx, workSetProgress, new int(percent), freeInt); lastReportedPercent = percent; } } @@ -487,20 +471,20 @@ void EspNowBridge::performUpdate(const std::string& filePath) { fclose(file); if (writeFailed) { - firmwareOps_->abort(handle); - heldAutoScanPauseGuard_.reset(); - dispatchToUi([](EspNowBridge& app, void*) { - app.setStatus("Update failed while transferring firmware"); - app.setUpdateButtonsDisabled(false); + ctx->firmwareOps->abort(handle); + ctx->heldAutoScanPauseGuard.reset(); + dispatchToUi(ctx, [](Context& app, void*) { + setStatus(&app, "Update failed while transferring firmware"); + setUpdateButtonsDisabled(&app, false); }, nullptr, nullptr); return; } - if (firmwareOps_->finish(handle) != ERROR_NONE) { - heldAutoScanPauseGuard_.reset(); - dispatchToUi([](EspNowBridge& app, void*) { - app.setStatus("Failed to finalize OTA on co-processor"); - app.setUpdateButtonsDisabled(false); + if (ctx->firmwareOps->finish(handle) != ERROR_NONE) { + ctx->heldAutoScanPauseGuard.reset(); + dispatchToUi(ctx, [](Context& app, void*) { + setStatus(&app, "Failed to finalize OTA on co-processor"); + setUpdateButtonsDisabled(&app, false); }, nullptr, nullptr); return; } @@ -508,21 +492,21 @@ void EspNowBridge::performUpdate(const std::string& filePath) { // Check the *currently running* (pre-update) slave version - the new image isn't running // yet - and skip straight to the required host restart for older slaves. FirmwareInfo runningInfo = {}; - bool canActivate = firmwareOps_->get_info(firmwareCtx_, &runningInfo) == ERROR_NONE + bool canActivate = ctx->firmwareOps->get_info(ctx->firmwareCtx, &runningInfo) == ERROR_NONE && activateSupported(runningInfo.fw_major, runningInfo.fw_minor); if (canActivate) { - if (firmwareOps_->activate(firmwareCtx_) != ERROR_NONE) { - heldAutoScanPauseGuard_.reset(); - dispatchToUi([](EspNowBridge& app, void*) { - app.setStatus("Failed to activate new firmware - co-processor still running old firmware"); - app.setUpdateButtonsDisabled(false); + if (ctx->firmwareOps->activate(ctx->firmwareCtx) != ERROR_NONE) { + ctx->heldAutoScanPauseGuard.reset(); + dispatchToUi(ctx, [](Context& app, void*) { + setStatus(&app, "Failed to activate new firmware - co-processor still running old firmware"); + setUpdateButtonsDisabled(&app, false); }, nullptr, nullptr); return; } } - // heldAutoScanPauseGuard_ is deliberately left held (never explicitly released) - the host + // heldAutoScanPauseGuard is deliberately left held (never explicitly released) - the host // restarts itself immediately below, and there's no safe window to resume normal WiFi // activity before that. { @@ -532,7 +516,7 @@ void EspNowBridge::performUpdate(const std::string& filePath) { } else { snprintf(buf, sizeof(buf), "Firmware %s pushed - restarting to apply...", versionStr.c_str()); } - dispatchToUi(workSetStatus, new std::string(buf), freeString); + dispatchToUi(ctx, workSetStatus, new std::string(buf), freeString); } // Give the status message above a moment to actually be seen before the restart cuts the @@ -541,33 +525,33 @@ void EspNowBridge::performUpdate(const std::string& filePath) { esp_restart(); } -void EspNowBridge::updateTaskEntry(void* arg) { - auto* self = static_cast(arg); - self->performUpdate(self->pendingUpdateFilePath_); - self->updateTask_ = nullptr; - if (self->outstandingTasks_.fetch_sub(1) == 1 && self->taskDoneSemaphore_ != nullptr) { - xSemaphoreGive(self->taskDoneSemaphore_); +static void updateTaskEntry(void* arg) { + auto* ctx = static_cast(arg); + performUpdate(ctx, ctx->pendingUpdateFilePath); + ctx->updateTask = nullptr; + if (ctx->outstandingTasks.fetch_sub(1) == 1 && ctx->taskDoneSemaphore != nullptr) { + xSemaphoreGive(ctx->taskDoneSemaphore); } vTaskDelete(nullptr); } -void EspNowBridge::startUpdateTask(const std::string& filePath) { - if (updateTask_ != nullptr) { +static void startUpdateTask(Context* ctx, const std::string& filePath) { + if (ctx->updateTask != nullptr) { return; } - pendingUpdateFilePath_ = filePath; - outstandingTasks_.fetch_add(1); - if (xTaskCreate(updateTaskEntry, "espnow_bridge_ota", UPDATE_TASK_STACK_SIZE / sizeof(StackType_t), this, tskIDLE_PRIORITY + 1, &updateTask_) != pdPASS) { - outstandingTasks_.fetch_sub(1); + ctx->pendingUpdateFilePath = filePath; + ctx->outstandingTasks.fetch_add(1); + if (xTaskCreate(updateTaskEntry, "espnow_bridge_ota", UPDATE_TASK_STACK_SIZE / sizeof(StackType_t), ctx, tskIDLE_PRIORITY + 1, &ctx->updateTask) != pdPASS) { + ctx->outstandingTasks.fetch_sub(1); } } -void EspNowBridge::onUpdateButtonClicked(lv_event_t* /*event*/) { - auto* self = liveInstance_.load(); - if (self == nullptr || !self->isWifiRadioOn()) { +static void onUpdateButtonClicked(lv_event_t* /*event*/) { + auto* ctx = liveInstance.load(); + if (ctx == nullptr || !isWifiRadioOn(ctx)) { return; } - self->pickFileLaunchId_ = tt_app_fileselection_start_for_existing_file(); + ctx->pickFileLaunchId = tt_app_fileselection_start_for_existing_file(ctx->appInstanceId); } // Name of the slave bridge firmware bundled in this app's assets/ folder @@ -576,81 +560,94 @@ void EspNowBridge::onUpdateButtonClicked(lv_event_t* /*event*/) { // available too, for factory-image downgrades or custom builds. static constexpr auto* BUNDLED_FIRMWARE_ASSET_NAME = "espnow_bridge_slave_c6.bin"; -void EspNowBridge::onUpdateBundledButtonClicked(lv_event_t* /*event*/) { - auto* self = liveInstance_.load(); - if (self == nullptr || !self->isWifiRadioOn()) { +static void onUpdateBundledButtonClicked(lv_event_t* /*event*/) { + auto* ctx = liveInstance.load(); + if (ctx == nullptr || !isWifiRadioOn(ctx)) { return; } char assetPath[256] = {}; - size_t assetPathSize = sizeof(assetPath); - tt_app_get_assets_child_path(self->appHandle_, BUNDLED_FIRMWARE_ASSET_NAME, assetPath, &assetPathSize); - if (assetPath[0] == '\0') { + if (app_paths_get_assets_path(APP_ID, BUNDLED_FIRMWARE_ASSET_NAME, assetPath, sizeof(assetPath)) != ERROR_NONE) { LOG_E(TAG, "Failed to resolve bundled firmware asset path"); return; } - self->startUpdateTask(assetPath); + startUpdateTask(ctx, assetPath); +} + +static void waitForTransportTaskEntry(void* arg) { + auto* ctx = static_cast(arg); + constexpr uint32_t WAIT_TIMEOUT_MS = 10000; + // liveInstance must be checked before touching any member of ctx - if espNowBridgeTeardown() + // already ran, `ctx` may be freed, and dereferencing ctx->firmwareOps first would be a + // use-after-free even just to read the pointer. + if (liveInstance.load() == ctx && ctx->firmwareOps != nullptr + && ctx->firmwareOps->wait_ready(ctx->firmwareCtx, WAIT_TIMEOUT_MS) + && liveInstance.load() == ctx) { + dispatchToUi(ctx, [](Context& app, void*) { + refreshCurrentVersion(&app); + }, nullptr, nullptr); + } + if (ctx->outstandingTasks.fetch_sub(1) == 1 && ctx->taskDoneSemaphore != nullptr) { + xSemaphoreGive(ctx->taskDoneSemaphore); + } + vTaskDelete(nullptr); } -void EspNowBridge::onEnableWifiButtonClicked(lv_event_t* /*event*/) { - auto* self = liveInstance_.load(); - if (self == nullptr || self->wifiDevice_ == nullptr) { +static void onWifiEvent(Device* /*device*/, void* callbackContext, WifiEvent /*event*/) { + auto* ctx = static_cast(callbackContext); + if (liveInstance.load() != ctx) { return; } - device_start(self->wifiDevice_); + dispatchToUi(ctx, [](Context& app, void*) { + refreshWifiPrompt(&app); + refreshCurrentVersion(&app); + }, nullptr, nullptr); +} + +static void onEnableWifiButtonClicked(lv_event_t* /*event*/) { + auto* ctx = liveInstance.load(); + if (ctx == nullptr || ctx->wifiDevice == nullptr) { + return; + } + device_start(ctx->wifiDevice); // start_device() allocates a fresh driver context (Platforms/platform-esp32's // esp32_wifi.cpp), which wipes any event callback registered before the device was started - // re-register now that it's actually running. Also refresh once directly rather than relying // solely on the next WifiEvent, so the "WiFi on" prompt updates immediately even though the // co-processor firmware version below isn't available yet. - wifi_add_event_callback(self->wifiDevice_, self, onWifiEvent); - self->refreshWifiPrompt(); - self->refreshCurrentVersion(); + wifi_add_event_callback(ctx->wifiDevice, ctx, onWifiEvent); + refreshWifiPrompt(ctx); + refreshCurrentVersion(ctx); // The co-processor RPC transport isn't up the instant device_start() returns - it comes up - // asynchronously (~1-2s later) - so firmwareOps_->get_info() above reliably fails right after + // asynchronously (~1-2s later) - so firmwareOps->get_info() above reliably fails right after // enabling WiFi. Nothing else reliably re-triggers a version refresh once the transport - // actually comes up (WifiEvent only covers radio/station state, not transport readiness), so - // wait for it explicitly on a background task and refresh once it's ready. - if (self->firmwareOps_ != nullptr) { - self->outstandingTasks_.fetch_add(1); - if (xTaskCreate(waitForTransportTaskEntry, "espnow_bridge_wait", 4096 / sizeof(StackType_t), self, tskIDLE_PRIORITY + 1, nullptr) != pdPASS) { - self->outstandingTasks_.fetch_sub(1); + // actually comes up (the WiFi event callback only covers radio/station state, not transport + // readiness), so wait for it explicitly on a background task and refresh once it's ready. + if (ctx->firmwareOps != nullptr) { + ctx->outstandingTasks.fetch_add(1); + if (xTaskCreate(waitForTransportTaskEntry, "espnow_bridge_wait", 4096 / sizeof(StackType_t), ctx, tskIDLE_PRIORITY + 1, nullptr) != pdPASS) { + ctx->outstandingTasks.fetch_sub(1); } } } -void EspNowBridge::waitForTransportTaskEntry(void* arg) { - auto* self = static_cast(arg); - constexpr uint32_t WAIT_TIMEOUT_MS = 10000; - // liveInstance_ must be checked before touching any member of self - if onDestroy() already - // ran, `self` may be freed, and dereferencing self->firmwareOps_ first would be a - // use-after-free even just to read the pointer. - if (liveInstance_.load() == self && self->firmwareOps_ != nullptr - && self->firmwareOps_->wait_ready(self->firmwareCtx_, WAIT_TIMEOUT_MS) - && liveInstance_.load() == self) { - self->dispatchToUi([](EspNowBridge& app, void*) { - app.refreshCurrentVersion(); - }, nullptr, nullptr); - } - if (self->outstandingTasks_.fetch_sub(1) == 1 && self->taskDoneSemaphore_ != nullptr) { - xSemaphoreGive(self->taskDoneSemaphore_); - } - vTaskDelete(nullptr); +void espNowBridgeInit(Context* ctx) { + ctx->taskDoneSemaphore = xSemaphoreCreateBinary(); + liveInstance = ctx; } -void EspNowBridge::onWifiEvent(Device* /*device*/, void* callbackContext, WifiEvent /*event*/) { - auto* self = static_cast(callbackContext); - if (liveInstance_.load() != self) { - return; - } - self->dispatchToUi([](EspNowBridge& app, void*) { - app.refreshWifiPrompt(); - app.refreshCurrentVersion(); - }, nullptr, nullptr); -} +void espNowBridgeCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); -void EspNowBridge::onShow(AppHandle app, lv_obj_t* parent) { - isShown_ = true; + // Tear down whatever the previous build wired up. The first call has nothing to tear down + // (wifiDevice is null); a rebuild - after e.g. the file picker (a modal child) closes and + // this window resurfaces - does, since there's no separate "window buried" callback in this + // app framework to have done it already (unlike the old one's onHide()). + ctx->isShown = false; + if (ctx->wifiDevice != nullptr) { + wifi_remove_event_callback(ctx->wifiDevice, onWifiEvent); + ctx->wifiDevice = nullptr; + } lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); @@ -665,76 +662,86 @@ void EspNowBridge::onShow(AppHandle app, lv_obj_t* parent) { lv_obj_set_width(wrapper, LV_PCT(100)); lv_obj_set_flex_grow(wrapper, 1); - currentVersionLabel_ = lv_label_create(wrapper); - lv_obj_set_style_pad_bottom(currentVersionLabel_, 12, LV_STATE_DEFAULT); + ctx->currentVersionLabel = lv_label_create(wrapper); + lv_obj_set_style_pad_bottom(ctx->currentVersionLabel, 12, LV_STATE_DEFAULT); - enableWifiButton_ = lv_button_create(wrapper); - lv_obj_add_event_cb(enableWifiButton_, onEnableWifiButtonClicked, LV_EVENT_CLICKED, nullptr); - auto* enableWifiButtonLabel = lv_label_create(enableWifiButton_); + ctx->enableWifiButton = lv_button_create(wrapper); + lv_obj_add_event_cb(ctx->enableWifiButton, onEnableWifiButtonClicked, LV_EVENT_CLICKED, nullptr); + auto* enableWifiButtonLabel = lv_label_create(ctx->enableWifiButton); lv_label_set_text(enableWifiButtonLabel, "Enable WiFi (required for co-processor link)"); - lv_obj_set_style_pad_bottom(enableWifiButton_, 12, LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(ctx->enableWifiButton, 12, LV_STATE_DEFAULT); - updateBundledButton_ = lv_button_create(wrapper); - lv_obj_add_event_cb(updateBundledButton_, onUpdateBundledButtonClicked, LV_EVENT_CLICKED, nullptr); - auto* updateBundledButtonLabel = lv_label_create(updateBundledButton_); + ctx->updateBundledButton = lv_button_create(wrapper); + lv_obj_add_event_cb(ctx->updateBundledButton, onUpdateBundledButtonClicked, LV_EVENT_CLICKED, nullptr); + auto* updateBundledButtonLabel = lv_label_create(ctx->updateBundledButton); lv_label_set_text(updateBundledButtonLabel, "Update to bundled firmware"); - lv_obj_set_style_pad_bottom(updateBundledButton_, 12, LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(ctx->updateBundledButton, 12, LV_STATE_DEFAULT); - updateButton_ = lv_button_create(wrapper); - lv_obj_add_event_cb(updateButton_, onUpdateButtonClicked, LV_EVENT_CLICKED, nullptr); - auto* updateButtonLabel = lv_label_create(updateButton_); + ctx->updateButton = lv_button_create(wrapper); + lv_obj_add_event_cb(ctx->updateButton, onUpdateButtonClicked, LV_EVENT_CLICKED, nullptr); + auto* updateButtonLabel = lv_label_create(ctx->updateButton); lv_label_set_text(updateButtonLabel, "Update from SD card..."); - lv_obj_set_style_pad_bottom(updateButton_, 12, LV_STATE_DEFAULT); - - progressBar_ = lv_bar_create(wrapper); - lv_obj_set_size(progressBar_, LV_PCT(100), LV_PCT(6)); - lv_bar_set_range(progressBar_, 0, 100); - lv_bar_set_value(progressBar_, 0, LV_ANIM_OFF); - - statusLabel_ = lv_label_create(wrapper); - lv_label_set_text(statusLabel_, "Ready"); - - wifiDevice_ = wifi_find_first_registered_device(); - if (wifiDevice_ != nullptr) { - wifi_add_event_callback(wifiDevice_, this, onWifiEvent); - if (wifi_get_firmware_ops(wifiDevice_, &firmwareOps_, &firmwareCtx_) != ERROR_NONE) { - firmwareOps_ = nullptr; - firmwareCtx_ = nullptr; + lv_obj_set_style_pad_bottom(ctx->updateButton, 12, LV_STATE_DEFAULT); + + ctx->progressBar = lv_bar_create(wrapper); + lv_obj_set_size(ctx->progressBar, LV_PCT(100), LV_PCT(6)); + lv_bar_set_range(ctx->progressBar, 0, 100); + lv_bar_set_value(ctx->progressBar, 0, LV_ANIM_OFF); + + ctx->statusLabel = lv_label_create(wrapper); + lv_label_set_text(ctx->statusLabel, "Ready"); + + ctx->wifiDevice = wifi_find_first_registered_device(); + if (ctx->wifiDevice != nullptr) { + wifi_add_event_callback(ctx->wifiDevice, ctx, onWifiEvent); + if (wifi_get_firmware_ops(ctx->wifiDevice, &ctx->firmwareOps, &ctx->firmwareCtx) != ERROR_NONE) { + ctx->firmwareOps = nullptr; + ctx->firmwareCtx = nullptr; } } - refreshCurrentVersion(); - refreshWifiPrompt(); + refreshCurrentVersion(ctx); + refreshWifiPrompt(ctx); - // If an SD-card file was picked before this onShow() ran (FileSelection tears down and - // rebuilds this app's whole widget tree), perform the update now that widgets are valid - // again. The bundled-firmware button doesn't go through this path - it calls - // startUpdateTask() directly since there's no separate app launch/result round trip involved. - if (!pendingUpdateFilePath_.empty()) { - std::string path = std::move(pendingUpdateFilePath_); - pendingUpdateFilePath_.clear(); - startUpdateTask(path); - } + ctx->isShown = true; + + // If an SD-card file was picked before this ran, perform the update now that widgets are + // valid again. In practice this rarely fires - see espNowBridgeApplyPendingUpdate()'s doc + // comment - but it's a harmless no-op otherwise and stays as a defensive fallback. + espNowBridgeApplyPendingUpdate(ctx); } -void EspNowBridge::onHide(AppHandle /*app*/) { - isShown_ = false; - if (wifiDevice_ != nullptr) { - wifi_remove_event_callback(wifiDevice_, onWifiEvent); - wifiDevice_ = nullptr; +void espNowBridgeApplyPendingUpdate(Context* ctx) { + if (ctx->pendingUpdateFilePath.empty()) { + return; } + std::string path = std::move(ctx->pendingUpdateFilePath); + ctx->pendingUpdateFilePath.clear(); + startUpdateTask(ctx, path); } -void EspNowBridge::onResult(AppHandle /*app*/, void* /*data*/, AppLaunchId launchId, AppResult result, BundleHandle resultData) { - if (launchId != pickFileLaunchId_) { - return; +void espNowBridgeTeardown(Context* ctx) { + ctx->isShown = false; + if (ctx->wifiDevice != nullptr) { + wifi_remove_event_callback(ctx->wifiDevice, onWifiEvent); + ctx->wifiDevice = nullptr; } - pickFileLaunchId_ = 0; - if (result == APP_RESULT_OK && resultData != nullptr) { - char pathBuf[256] = {}; - if (tt_app_fileselection_get_result_path(resultData, pathBuf, sizeof(pathBuf))) { - pendingUpdateFilePath_ = pathBuf; + // Clear liveInstance first so any task still running bails out at its next liveInstance + // check instead of continuing to touch this instance's members. + liveInstance = nullptr; + + // Wait for any outstanding background task (OTA update, transport-wait) to actually finish - + // main() frees this Context shortly after this function returns, so a task that outlives it + // would dereference freed memory. + while (ctx->outstandingTasks.load() > 0) { + if (ctx->taskDoneSemaphore != nullptr) { + xSemaphoreTake(ctx->taskDoneSemaphore, pdMS_TO_TICKS(1000)); } } + + if (ctx->taskDoneSemaphore != nullptr) { + vSemaphoreDelete(ctx->taskDoneSemaphore); + ctx->taskDoneSemaphore = nullptr; + } } diff --git a/Apps/EspNowBridge/main/Source/EspNowBridge.h b/Apps/EspNowBridge/main/Source/EspNowBridge.h index fda4e05..f3fecb3 100644 --- a/Apps/EspNowBridge/main/Source/EspNowBridge.h +++ b/Apps/EspNowBridge/main/Source/EspNowBridge.h @@ -1,7 +1,5 @@ #pragma once -#include - #include #include #include @@ -24,85 +22,62 @@ class AutoScanPauseGuard { AutoScanPauseGuard& operator=(const AutoScanPauseGuard&) = delete; }; -class EspNowBridge final : public App { -public: - EspNowBridge() = default; - EspNowBridge(const EspNowBridge&) = delete; - EspNowBridge& operator=(const EspNowBridge&) = delete; - - void onCreate(AppHandle app) override; - void onDestroy(AppHandle app) override; - void onShow(AppHandle app, lv_obj_t* parent) override; - void onHide(AppHandle app) override; - void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override; - - // Public so the free-function dispatchToUi() work callbacks in EspNowBridge.cpp (which run - // outside any member-function's lexical scope, unlike the inline lambdas in performUpdate()) - // can call them. - void setStatus(const std::string& text); - void setProgress(int percent); - -private: - AppHandle appHandle_ = nullptr; - AppLaunchId pickFileLaunchId_ = 0; - std::string pendingUpdateFilePath_; - Device* wifiDevice_ = nullptr; - - // Resolved once in onShow() via wifi_get_firmware_ops() - null on a WiFi device with no - // updatable co-processor (e.g. a native, non-hosted chip). All OTA/version-query calls go - // through this generic interface, not any esp_hosted-specific API directly. - const FirmwareOps* firmwareOps_ = nullptr; - void* firmwareCtx_ = nullptr; - - // Set once in onShow(), false once onHide() tears the widget tree down - checked (via - // dispatchToUi(), below) before touching any lv_obj_t*, since the OTA worker task and the - // WiFi-event callback can both outlive a hide/app-switch. - std::atomic isShown_{false}; - - // Only one EspNowBridge instance is ever live at a time (app loader owns a single instance - // per running app), so a single static "is this instance still current" pointer, guarded by - // an atomic, substitutes for the internal app's shared_ptr-based lifetime guard - the OTA - // worker task and dispatchToUi()'s lv_async_call closures check liveInstance_ == this before - // touching any member, instead of holding a shared_ptr to keep `this` alive. - static std::atomic liveInstance_; - - TaskHandle_t updateTask_ = nullptr; +struct Context { + uint32_t appInstanceId; + + uint32_t pickFileLaunchId = 0; + std::string pendingUpdateFilePath; + Device* wifiDevice = nullptr; + + // Resolved once per createWidgets() call via wifi_get_firmware_ops() - null on a WiFi + // device with no updatable co-processor (e.g. a native, non-hosted chip). All OTA/ + // version-query calls go through this generic interface, not any esp_hosted-specific + // API directly. + const FirmwareOps* firmwareOps = nullptr; + void* firmwareCtx = nullptr; + + // Set once widgets exist (end of espNowBridgeCreateWidgets()), false again the moment + // they don't (start of a rebuild, or final teardown) - checked (via dispatchToUi(), below) + // before touching any lv_obj_t*, since the OTA worker task and the WiFi-event callback can + // both outlive the window being buried by a modal child (e.g. the file picker) or the app + // closing entirely. + std::atomic isShown{false}; + + TaskHandle_t updateTask = nullptr; // Number of background tasks (updateTaskEntry, waitForTransportTaskEntry) currently running - // against this instance's members. onDestroy() must wait for this to hit 0 before returning - - // the app framework frees this instance shortly after onDestroy() returns (see Loader.cpp), - // so any task still touching `this` past that point is a use-after-free. - std::atomic outstandingTasks_{0}; - SemaphoreHandle_t taskDoneSemaphore_ = nullptr; + // against this instance's members. espNowBridgeTeardown() must wait for this to hit 0 before + // returning - main() frees this Context shortly after, so any task still touching it past + // that point is a use-after-free. + std::atomic outstandingTasks{0}; + SemaphoreHandle_t taskDoneSemaphore = nullptr; // Outlives performUpdate() deliberately, so auto-scan stays paused across the async gap // between performUpdate() returning and the automatic restart - see performUpdate(). - std::optional heldAutoScanPauseGuard_; - - lv_obj_t* currentVersionLabel_ = nullptr; - lv_obj_t* statusLabel_ = nullptr; - lv_obj_t* progressBar_ = nullptr; - lv_obj_t* updateButton_ = nullptr; - lv_obj_t* updateBundledButton_ = nullptr; - lv_obj_t* enableWifiButton_ = nullptr; - - void refreshCurrentVersion(); - bool isWifiRadioOn(); - void refreshWifiPrompt(); - /** Enables/disables both update-trigger buttons together - only one performUpdate() can run - * at a time (see updateTask_), regardless of which button started it. */ - void setUpdateButtonsDisabled(bool disabled); - /** Marshal a UI-touching closure onto the LVGL task. Only ever invoked if liveInstance_ is - * still this instance (checked at dispatch time and again right before running, on the LVGL - * task) and isShown_ is true (this app's widget tree exists). */ - void dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*)); - void performUpdate(const std::string& filePath); - void startUpdateTask(const std::string& filePath); - - static void updateTaskEntry(void* arg); - static void onUpdateButtonClicked(lv_event_t* event); - static void onUpdateBundledButtonClicked(lv_event_t* event); - static void onEnableWifiButtonClicked(lv_event_t* event); - static void onWifiEvent(Device* device, void* callbackContext, WifiEvent event); - static void waitForTransportTaskEntry(void* arg); + std::optional heldAutoScanPauseGuard; + + lv_obj_t* currentVersionLabel = nullptr; + lv_obj_t* statusLabel = nullptr; + lv_obj_t* progressBar = nullptr; + lv_obj_t* updateButton = nullptr; + lv_obj_t* updateBundledButton = nullptr; + lv_obj_t* enableWifiButton = nullptr; }; + +/** Sets up state that must exist for the whole app instance lifetime, regardless of how many + * times the window is (re)built. Call once, right after constructing the Context. */ +void espNowBridgeInit(Context* ctx); + +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void espNowBridgeCreateWidgets(lv_obj_t* parent, void* userData); + +/** Starts the update task if ctx->pendingUpdateFilePath is set (consuming it). Called both from + * within espNowBridgeCreateWidgets() and directly by main()'s event loop right after a picked + * path arrives - the file-selection child's window teardown (and so this window's rebuild) + * happens before its APP_EVENT_RESULT is delivered here, so by the time the result arrives + * espNowBridgeCreateWidgets() has typically already run and found the path not yet set. */ +void espNowBridgeApplyPendingUpdate(Context* ctx); + +/** Waits for any outstanding background task to finish, then releases resources. Call once, + * after the window has been torn down and right before the Context itself is freed. */ +void espNowBridgeTeardown(Context* ctx); diff --git a/Apps/EspNowBridge/main/Source/main.cpp b/Apps/EspNowBridge/main/Source/main.cpp index 1e415d9..dc937d8 100644 --- a/Apps/EspNowBridge/main/Source/main.cpp +++ b/Apps/EspNowBridge/main/Source/main.cpp @@ -1,10 +1,68 @@ #include "EspNowBridge.h" -#include + +#include +#include +#include + +#include + +#include + +#include extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + // Heap-allocated: several background tasks (OTA update, transport-wait, the WiFi event + // callback) hold a raw Context* across the whole app instance lifetime, well past any single + // stack frame here. + auto ctx = std::make_unique(); + ctx->appInstanceId = app_instance_id; + espNowBridgeInit(ctx.get()); + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, espNowBridgeCreateWidgets, ctx.get()); + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(app_instance_id); + should_close = true; + break; + + case APP_EVENT_RESULT: + if (event.result.launch_id == ctx->pickFileLaunchId) { + ctx->pickFileLaunchId = 0; + if (event.result.result == 0) { // 0 = Ok (see FileSelection.h) + char pathBuf[256] = {}; + if (tt_app_fileselection_get_result_path(pathBuf, sizeof(pathBuf))) { + ctx->pendingUpdateFilePath = pathBuf; + espNowBridgeApplyPendingUpdate(ctx.get()); + } + } + } + app_manager_stop(event.result.launch_id); + break; + + default: + break; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + espNowBridgeTeardown(ctx.get()); + return 0; } diff --git a/Apps/GPIO/CMakeLists.txt b/Apps/GPIO/CMakeLists.txt index e14f42c..af58077 100644 --- a/Apps/GPIO/CMakeLists.txt +++ b/Apps/GPIO/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(GPIO) tactility_project(GPIO) diff --git a/Apps/GPIO/main/CMakeLists.txt b/Apps/GPIO/main/CMakeLists.txt index 7d924bb..c950070 100644 --- a/Apps/GPIO/main/CMakeLists.txt +++ b/Apps/GPIO/main/CMakeLists.txt @@ -4,9 +4,6 @@ file(GLOB_RECURSE SOURCE_FILES idf_component_register( SRCS ${SOURCE_FILES} - # Library headers must be included directly, - # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include REQUIRES TactilitySDK driver ) diff --git a/Apps/GPIO/main/Source/Gpio.cpp b/Apps/GPIO/main/Source/Gpio.cpp index d5ee764..463bd0c 100644 --- a/Apps/GPIO/main/Source/Gpio.cpp +++ b/Apps/GPIO/main/Source/Gpio.cpp @@ -1,28 +1,26 @@ #include "Gpio.h" -#include - #include - #include +#include #include #include -constexpr char* TAG = "GPIO"; +constexpr auto* TAG = "GPIO"; -void Gpio::updatePinStates() { +static void updatePinStates(Context* ctx) { // Update pin states - for (int i = 0; i < pinStates.size(); ++i) { - pinStates[i] = gpio_get_level((gpio_num_t)i); + for (size_t i = 0; i < ctx->pinStates.size(); ++i) { + ctx->pinStates[i] = gpio_get_level((gpio_num_t)i); } } -void Gpio::updatePinWidgets() { +static void updatePinWidgets(Context* ctx) { lvgl_lock(); - for (int j = 0; j < pinStates.size(); ++j) { - int level = pinStates[j]; - lv_obj_t* label = pinWidgets[j]; + for (size_t j = 0; j < ctx->pinStates.size(); ++j) { + int level = ctx->pinStates[j]; + lv_obj_t* label = ctx->pinWidgets[j]; void* label_user_data = lv_obj_get_user_data(label); // The user data stores the state, so we can avoid unnecessary updates if (reinterpret_cast(level) != label_user_data) { @@ -37,7 +35,7 @@ void Gpio::updatePinWidgets() { lvgl_unlock(); } -lv_obj_t* Gpio::createGpioRowWrapper(lv_obj_t* parent) { +static lv_obj_t* createGpioRowWrapper(lv_obj_t* parent) { lv_obj_t* wrapper = lv_obj_create(parent); lv_obj_set_style_pad_all(wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); @@ -47,21 +45,18 @@ lv_obj_t* Gpio::createGpioRowWrapper(lv_obj_t* parent) { // region Task -void Gpio::onTimer() { - mutex.lock(); - updatePinStates(); - updatePinWidgets(); - mutex.unlock(); -} - -void Gpio::startTask() { - mutex.lock(); - timer.start(); - mutex.unlock(); -} - -void Gpio::stopTask() { - timer.stop(); +static void gpioOnTimer(Context* ctx) { + // Widgets only exist while this window is topmost - skip otherwise (buried by another app + // started non-modally, e.g. via app_manager_start(); window_manager deletes a buried + // window's widgets, so touching pinWidgets here would use-after-free them). Same fix as + // Development.cpp's periodic status timer. + if (window_manager_get_state(ctx->window) != WINDOW_STATE_GRANTED) { + return; + } + ctx->mutex.lock(); + updatePinStates(ctx); + updatePinWidgets(ctx); + ctx->mutex.unlock(); } // endregion Task @@ -74,7 +69,9 @@ static int getSquareSpacing(UiDensity density) { } } -void Gpio::onShow(AppHandle app, lv_obj_t* parent) { +void gpioCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); @@ -109,10 +106,10 @@ void Gpio::onShow(AppHandle app, lv_obj_t* parent) { auto* row_wrapper = createGpioRowWrapper(centering_wrapper); lv_obj_align(row_wrapper, LV_ALIGN_TOP_MID, 0, 0); - mutex.lock(); + ctx->mutex.lock(); - pinStates.resize(GPIO_PIN_COUNT); - pinWidgets.resize(GPIO_PIN_COUNT); + ctx->pinStates.resize(GPIO_PIN_COUNT); + ctx->pinWidgets.resize(GPIO_PIN_COUNT); for (int i = 0; i < GPIO_PIN_COUNT; ++i) { constexpr uint8_t offset_from_left_label = 4; @@ -128,8 +125,8 @@ void Gpio::onShow(AppHandle app, lv_obj_t* parent) { lv_obj_set_pos(status_label, (column+1) * x_spacing + offset_from_left_label, 0); lv_label_set_text_fmt(status_label, "%s", LV_SYMBOL_STOP); lv_obj_set_style_text_color(status_label, lv_color_make(20, 20, 20), LV_STATE_DEFAULT); - pinWidgets[i] = status_label; - pinStates[i] = false; + ctx->pinWidgets[i] = status_label; + ctx->pinStates[i] = false; column++; @@ -148,15 +145,21 @@ void Gpio::onShow(AppHandle app, lv_obj_t* parent) { } } - mutex.unlock(); + ctx->mutex.unlock(); +} - startTask(); +void gpioInit(Context* ctx) { + ctx->timer = std::make_unique(tt::Timer::Type::Periodic, pdMS_TO_TICKS(100), [ctx] { + gpioOnTimer(ctx); + }); } -void Gpio::onHide(AppHandle app) { - mutex.lock(); - stopTask(); - pinWidgets.clear(); - pinStates.clear(); - mutex.unlock(); +void gpioTeardown(Context* ctx) { + ctx->mutex.lock(); + if (ctx->timer) { + ctx->timer->stop(); + } + ctx->pinWidgets.clear(); + ctx->pinStates.clear(); + ctx->mutex.unlock(); } diff --git a/Apps/GPIO/main/Source/Gpio.h b/Apps/GPIO/main/Source/Gpio.h index e0858d6..68307fe 100644 --- a/Apps/GPIO/main/Source/Gpio.h +++ b/Apps/GPIO/main/Source/Gpio.h @@ -1,35 +1,34 @@ #pragma once -#include - -#include - #include #include #include +#include #include -class Gpio final : public App { +struct Context { + uint32_t appInstanceId; + // Set by main() right after window_manager_create() returns - the timer callback needs it + // to check whether this window is still topmost before touching any widget (see + // gpioOnTimer()'s comment). + uint32_t window = 0; std::vector pinWidgets; std::vector pinStates; - tt::Timer timer = tt::Timer(tt::Timer::Type::Periodic, pdMS_TO_TICKS(100), [this]{ - onTimer(); - }); + // Constructed once in gpioInit(); needs ctx's address for its callback closure, so it can't + // be a plain default member initializer (Context doesn't exist yet at that point in main()). + std::unique_ptr timer; tt::RecursiveMutex mutex; +}; - static lv_obj_t* createGpioRowWrapper(lv_obj_t* parent); - void onTimer(); - -public: - - void onShow(AppHandle context, lv_obj_t* parent) override; - void onHide(AppHandle context) override; +/** Sets up state that must exist for the whole app instance lifetime. Call once, right after + * constructing the Context and before window_manager_create(). */ +void gpioInit(Context* ctx); - void startTask(); - void stopTask(); +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void gpioCreateWidgets(lv_obj_t* parent, void* userData); - void updatePinStates(); - void updatePinWidgets(); -}; +/** Stops the periodic refresh timer and releases widget-tracking state. Call once, after the + * window has been torn down. */ +void gpioTeardown(Context* ctx); diff --git a/Apps/GPIO/main/Source/main.cpp b/Apps/GPIO/main/Source/main.cpp index d7749d4..4fe78a1 100644 --- a/Apps/GPIO/main/Source/main.cpp +++ b/Apps/GPIO/main/Source/main.cpp @@ -1,9 +1,44 @@ #include "Gpio.h" +#include +#include +#include + +#include + extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + Context ctx; + ctx.appInstanceId = app_instance_id; + gpioInit(&ctx); + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, gpioCreateWidgets, &ctx); + ctx.window = window; + ctx.timer->start(); + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(app_instance_id); + should_close = true; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + gpioTeardown(&ctx); + return 0; } diff --git a/Apps/GraphicsDemo/CMakeLists.txt b/Apps/GraphicsDemo/CMakeLists.txt index c9cfd74..b57599e 100644 --- a/Apps/GraphicsDemo/CMakeLists.txt +++ b/Apps/GraphicsDemo/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(GraphicsDemo) tactility_project(GraphicsDemo) diff --git a/Apps/GraphicsDemo/main/CMakeLists.txt b/Apps/GraphicsDemo/main/CMakeLists.txt index e8bdcd6..759aed7 100644 --- a/Apps/GraphicsDemo/main/CMakeLists.txt +++ b/Apps/GraphicsDemo/main/CMakeLists.txt @@ -2,8 +2,6 @@ file(GLOB_RECURSE SOURCE_FILES Source/*.c*) idf_component_register( SRC_DIRS "Source" - # Library headers must be included directly, - # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS "Include" "../../../Libraries/TactilityCpp/Include" + INCLUDE_DIRS "Include" REQUIRES TactilitySDK ) diff --git a/Apps/GraphicsDemo/main/Include/drivers/DisplayDriver.h b/Apps/GraphicsDemo/main/Include/drivers/DisplayDriver.h index 7ce0961..5e3916a 100644 --- a/Apps/GraphicsDemo/main/Include/drivers/DisplayDriver.h +++ b/Apps/GraphicsDemo/main/Include/drivers/DisplayDriver.h @@ -21,7 +21,7 @@ class DisplayDriver { device_put(device); } - bool lock(TickType_t timeout = tt::kernel::MAX_TICKS) const { + bool lock(TickType_t timeout = tt::kernel::FREERTOS_MAX_TICKS) const { return device_try_lock(device, timeout); } diff --git a/Apps/GraphicsDemo/main/Source/Main.cpp b/Apps/GraphicsDemo/main/Source/Main.cpp index 256f4fb..52a2e52 100644 --- a/Apps/GraphicsDemo/main/Source/Main.cpp +++ b/Apps/GraphicsDemo/main/Source/Main.cpp @@ -4,8 +4,9 @@ #include -#include -#include +#include +#include +#include #include #include @@ -16,22 +17,55 @@ constexpr auto TAG = "Main"; -static void onCreate(AppHandle appHandle, void* data) { +// Shows a blocking error dialog and waits for it to close (so the user actually gets to read it) +// before the caller finishes this app - this app never creates a window of its own, so there's +// nothing else keeping it around for the dialog to be seen against. +static void showErrorAndWait(AppInstanceId appInstanceId, const char* message) { + const char* argv[] = { "Error", message, "OK" }; + uint32_t dialogInstanceId = 0; + app_manager_start_for_result("AlertDialog", appInstanceId, 3, argv, &dialogInstanceId); + if (dialogInstanceId == 0) { + return; + } + + struct AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + while (true) { + struct AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_RESULT && event.result.launch_id == dialogInstanceId) { + app_manager_stop(dialogInstanceId); + break; + } + } + + app_event_unsubscribe(&sub); +} + +extern "C" { + +int main(int argc, char* argv[]) { + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + struct Device* display_device; if (device_get_first_active_by_type(&DISPLAY_TYPE, &display_device) != ERROR_NONE) { ESP_LOGE(TAG, "No display device found"); - tt_app_stop(); - tt_app_alertdialog_start("Error", "No display device was found.", nullptr, 0); - return; + showErrorAndWait(app_instance_id, "No display device was found."); + app_manager_finish(app_instance_id); + return 0; } struct Device* touch_device; if (device_get_first_active_by_type(&POINTER_TYPE, &touch_device) != ERROR_NONE) { ESP_LOGE(TAG, "No touch device found"); device_put(display_device); - tt_app_stop(); - tt_app_alertdialog_start("Error", "No touch device was found.", nullptr, 0); - return; + showErrorAndWait(app_instance_id, "No touch device was found."); + app_manager_finish(app_instance_id); + return 0; } // Stop LVGL first (because it's currently using the drivers we want to use) @@ -55,25 +89,15 @@ static void onCreate(AppHandle appHandle, void* data) { ESP_LOGI(TAG, "Cleanup touch driver"); delete touch; - ESP_LOGI(TAG, "Stopping application"); - tt_app_stop(); -} - -static void onDestroy(AppHandle appHandle, void* data) { // Restart LVGL to resume rendering of regular apps if (!module_is_started(&lvgl_module)) { ESP_LOGI(TAG, "Restarting LVGL"); module_start(&lvgl_module); } -} -extern "C" { + ESP_LOGI(TAG, "Stopping application"); + app_manager_finish(app_instance_id); -int main(int argc, char* argv[]) { - tt_app_register((AppRegistration) { - .onCreate = onCreate, - .onDestroy = onDestroy - }); return 0; } diff --git a/Apps/HelloWorld/CMakeLists.txt b/Apps/HelloWorld/CMakeLists.txt index 95d0cb2..fa0b687 100644 --- a/Apps/HelloWorld/CMakeLists.txt +++ b/Apps/HelloWorld/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(HelloWorld) tactility_project(HelloWorld) diff --git a/Apps/HelloWorld/main/Source/main.c b/Apps/HelloWorld/main/Source/main.c index ade6222..cc08795 100644 --- a/Apps/HelloWorld/main/Source/main.c +++ b/Apps/HelloWorld/main/Source/main.c @@ -1,11 +1,15 @@ -#include +#include +#include +#include + +#include + +#include #include -/** - * Note: LVGL and Tactility methods need to be exposed manually from TactilityC/Source/tt_init.cpp - * Only C is supported for now (C++ symbols fail to link) - */ -static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { +#include + +static void create_widgets(lv_obj_t* parent, void* userData) { lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Hello World"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); @@ -15,8 +19,27 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { } int main(int argc, char* argv[]) { - tt_app_register((AppRegistration) { - .onShow = onShowApp - }); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + struct AppEventSubscription sub = { .app_instance_id = app_instance_id }; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, create_widgets, NULL); + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(app_instance_id); + should_close = true; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + return 0; } diff --git a/Apps/M5UnitTest/CMakeLists.txt b/Apps/M5UnitTest/CMakeLists.txt index 03c7bee..0b4273d 100644 --- a/Apps/M5UnitTest/CMakeLists.txt +++ b/Apps/M5UnitTest/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(M5UnitTest) tactility_project(M5UnitTest) diff --git a/Apps/M5UnitTest/main/CMakeLists.txt b/Apps/M5UnitTest/main/CMakeLists.txt index 0e8e5c6..dc43276 100644 --- a/Apps/M5UnitTest/main/CMakeLists.txt +++ b/Apps/M5UnitTest/main/CMakeLists.txt @@ -5,6 +5,6 @@ idf_component_register( SRCS ${SOURCE_FILES} ${UNIT_MODULE_FILES} # Library headers must be included directly, # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include ../../../Libraries/M5UnitModules/Include + INCLUDE_DIRS ../../../Libraries/M5UnitModules/Include REQUIRES TactilitySDK ) diff --git a/Apps/M5UnitTest/main/Source/M5UnitTest.cpp b/Apps/M5UnitTest/main/Source/M5UnitTest.cpp index 1165756..49a01e7 100644 --- a/Apps/M5UnitTest/main/Source/M5UnitTest.cpp +++ b/Apps/M5UnitTest/main/Source/M5UnitTest.cpp @@ -1,120 +1,120 @@ #include "M5UnitTest.h" #include "TestListView.h" -#include "TestViewBase.h" #include "TestUnit8Encoder.h" #include "TestUnitByteButton.h" #include "TestUnitJoystick2.h" #include "TestUnitScroll.h" #include "TestUnitPaHub.h" #include "TestUnitLcd.h" +#include "TestUnitLcdGfx.h" #include "TestUnitDualButton.h" #include "TestUnitCardKB2.h" #include "TestUnitMidi.h" #include "TestUnitRfid2.h" -#include "TestUnitLcdGfx.h" -#include #include constexpr auto* TAG = "M5UnitTest"; -M5UnitTest* M5UnitTest::s_instance = nullptr; - -// --------------------------------------------------------------------------- -// Lifecycle -// --------------------------------------------------------------------------- +namespace { -void M5UnitTest::onShow(AppHandle handle, lv_obj_t* parent) { - s_instance = this; - appHandle_ = handle; +struct UnitEntry { + void* (*create)(lv_obj_t* parent, Context* app); + void (*stop)(void* self); +}; - lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - - createWrapper(parent); +template +void* createUnit(lv_obj_t* parent, Context* app) { + auto* self = new T(); + Start(self, parent, app); + return self; +} - if (!listView_) { - listView_ = new TestListView(); - listView_->onStart(wrapper_, handle, this); - } +template +void stopUnit(void* p) { + auto* self = static_cast(p); + Stop(self); + delete self; } -void M5UnitTest::onHide(AppHandle handle) { - if (activeTestView_) { - activeTestView_->onStop(); - delete activeTestView_; - activeTestView_ = nullptr; +constexpr UnitEntry UNIT_ENTRIES[11] = { + { createUnit, stopUnit }, + { createUnit, stopUnit }, + { createUnit, stopUnit }, + { createUnit, stopUnit }, + { createUnit, stopUnit }, + { createUnit, stopUnit }, + { createUnit, stopUnit }, + { createUnit, stopUnit }, + { createUnit, stopUnit }, + { createUnit, stopUnit }, + { createUnit, stopUnit }, +}; +constexpr int UNIT_COUNT = 11; + +void stopActiveTest(Context* ctx) { + if (ctx->activeTest && ctx->activeTestStop) { + ctx->activeTestStop(ctx->activeTest); } - if (listView_) listView_->onStop(); - delete listView_; - listView_ = nullptr; - wrapper_ = nullptr; - appHandle_ = nullptr; - s_instance = nullptr; + ctx->activeTest = nullptr; + ctx->activeTestStop = nullptr; + ctx->activeTestIndex = -1; } -// --------------------------------------------------------------------------- -// View switching -// --------------------------------------------------------------------------- +} // namespace + +void m5UnitTestCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); -template -static TestViewBase* makeTestView(lv_obj_t* wrapper, AppHandle handle, M5UnitTest* app) { - auto* v = new T(); - v->onStart(wrapper, handle, app); - return v; + lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + + ctx->wrapper = lv_obj_create(parent); + lv_obj_set_width(ctx->wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->wrapper, 1); + lv_obj_set_layout(ctx->wrapper, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(ctx->wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(ctx->wrapper, 0, 0); + lv_obj_set_style_bg_opa(ctx->wrapper, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(ctx->wrapper, 0, 0); + + // Resurfacing after being buried (e.g. another app was started on top): rebuild whichever + // test was active. The C++ state object itself already existed if so, but its widgets were + // just destroyed along with the rest of the window's tree, so we drop it and start fresh + // (matches this framework's create_widgets contract used for every other app). + if (ctx->activeTestIndex >= 0) { + int index = ctx->activeTestIndex; + stopActiveTest(ctx); + m5UnitTestShowTest(ctx, index); + } else { + testListViewCreate(ctx->wrapper, ctx); + } } -void M5UnitTest::createWrapper(lv_obj_t* parent) { - wrapper_ = lv_obj_create(parent); - lv_obj_set_width(wrapper_, LV_PCT(100)); - lv_obj_set_flex_grow(wrapper_, 1); - lv_obj_set_layout(wrapper_, LV_LAYOUT_FLEX); - lv_obj_set_flex_flow(wrapper_, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(wrapper_, 0, 0); - lv_obj_set_style_bg_opa(wrapper_, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(wrapper_, 0, 0); +void m5UnitTestTeardown(Context* ctx) { + stopActiveTest(ctx); + ctx->wrapper = nullptr; } -void M5UnitTest::showTest(int unitIndex) { - // Tear down any active test view and the list view, then clean the wrapper - if (activeTestView_) { - activeTestView_->onStop(); - delete activeTestView_; - activeTestView_ = nullptr; - } - if (listView_) { - listView_->onStop(); - delete listView_; - listView_ = nullptr; +void m5UnitTestShowTest(Context* ctx, int unitIndex) { + stopActiveTest(ctx); + + if (unitIndex < 0 || unitIndex >= UNIT_COUNT) { + m5UnitTestShowList(ctx); + return; } - lv_obj_clean(wrapper_); + lv_obj_clean(ctx->wrapper); ESP_LOGI(TAG, "Opening test for unit %d", unitIndex); - switch (unitIndex) { - case 0: activeTestView_ = makeTestView (wrapper_, appHandle_, this); break; - case 1: activeTestView_ = makeTestView(wrapper_, appHandle_, this); break; - case 2: activeTestView_ = makeTestView (wrapper_, appHandle_, this); break; - case 3: activeTestView_ = makeTestView (wrapper_, appHandle_, this); break; - case 4: activeTestView_ = makeTestView (wrapper_, appHandle_, this); break; - case 5: activeTestView_ = makeTestView (wrapper_, appHandle_, this); break; - case 6: activeTestView_ = makeTestView (wrapper_, appHandle_, this); break; - case 7: activeTestView_ = makeTestView(wrapper_, appHandle_, this); break; - case 8: activeTestView_ = makeTestView (wrapper_, appHandle_, this); break; - case 9: activeTestView_ = makeTestView (wrapper_, appHandle_, this); break; - case 10: activeTestView_ = makeTestView (wrapper_, appHandle_, this); break; - default: showList(); return; - } + const UnitEntry& entry = UNIT_ENTRIES[unitIndex]; + ctx->activeTest = entry.create(ctx->wrapper, ctx); + ctx->activeTestStop = entry.stop; + ctx->activeTestIndex = unitIndex; } -void M5UnitTest::showList() { - if (activeTestView_) { - activeTestView_->onStop(); - delete activeTestView_; - activeTestView_ = nullptr; - } - lv_obj_clean(wrapper_); - - listView_ = new TestListView(); - listView_->onStart(wrapper_, appHandle_, this); +void m5UnitTestShowList(Context* ctx) { + stopActiveTest(ctx); + lv_obj_clean(ctx->wrapper); + testListViewCreate(ctx->wrapper, ctx); } diff --git a/Apps/M5UnitTest/main/Source/M5UnitTest.h b/Apps/M5UnitTest/main/Source/M5UnitTest.h index 2c4992a..c0aac6b 100644 --- a/Apps/M5UnitTest/main/Source/M5UnitTest.h +++ b/Apps/M5UnitTest/main/Source/M5UnitTest.h @@ -1,34 +1,32 @@ #pragma once -#include +#include +#include #include -#include -// Forward declarations for test views -class TestListView; -class TestViewBase; - -class M5UnitTest final : public App { -public: - void onShow(AppHandle handle, lv_obj_t* parent) override; - void onHide(AppHandle handle) override; - - // Called by TestListView when user selects a unit to test - void showTest(int unitIndex); - // Called by test views when user presses back - void showList(); - // Called by the Back button path after the view has already been deleted - void clearActiveTestView() { activeTestView_ = nullptr; } +struct Context { + AppInstanceId appInstanceId = 0; + // Set by main() right after window_manager_create() returns - test-unit timer callbacks + // need it to check whether this window is still topmost before touching any widget. + WindowId window = 0; + lv_obj_t* wrapper = nullptr; // full-screen container, cleaned between views + + // Currently active test-unit view (heap-allocated by its create() wrapper), or nullptr + // when the list is shown. activeTestStop() knows how to stop+delete activeTest. + void* activeTest = nullptr; + void (*activeTestStop)(void* self) = nullptr; + int activeTestIndex = -1; +}; - AppHandle getAppHandle() const { return appHandle_; } +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void m5UnitTestCreateWidgets(lv_obj_t* parent, void* userData); -private: - AppHandle appHandle_ = nullptr; - lv_obj_t* wrapper_ = nullptr; // full-screen container, cleaned between views - TestListView* listView_ = nullptr; - TestViewBase* activeTestView_ = nullptr; +/** Stops whatever is currently shown (a test view, if any) and releases the wrapper. Call once, + * after the window has been torn down. */ +void m5UnitTestTeardown(Context* ctx); - static M5UnitTest* s_instance; +/** Called by the list view when the user selects a unit to test. */ +void m5UnitTestShowTest(Context* ctx, int unitIndex); - void createWrapper(lv_obj_t* parent); -}; +/** Called by test views (via the shared Back button) to return to the list. */ +void m5UnitTestShowList(Context* ctx); diff --git a/Apps/M5UnitTest/main/Source/TestListView.cpp b/Apps/M5UnitTest/main/Source/TestListView.cpp index 8ba5ddc..70ba2af 100644 --- a/Apps/M5UnitTest/main/Source/TestListView.cpp +++ b/Apps/M5UnitTest/main/Source/TestListView.cpp @@ -2,27 +2,67 @@ #include "M5UnitTest.h" #include "UiScale.h" #include +#include #include +#include -void TestListView::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { - app_ = app; +namespace { +constexpr std::array UNIT_NAMES = {{ + "8Encoder", + "ByteButton", + "Joystick2", + "Scroll", + "PaHub", + "Color LCD", + "LCD Gfx Test", + "Dual-Button", + "CardKB2", + "MIDI / Synth", + "RFID 2", +}}; +// Interface icons from shared Material icon font +constexpr std::array UNIT_ICONS = {{ + LVGL_ICON_SHARED_SETTINGS, // 8Encoder - I2C + LVGL_ICON_SHARED_SETTINGS, // ByteButton - I2C + LVGL_ICON_SHARED_GAMEPAD, // Joystick2 - I2C + LVGL_ICON_SHARED_SETTINGS, // Scroll - I2C + LVGL_ICON_SHARED_HUB, // PaHub - I2C + LVGL_ICON_SHARED_DEVICES, // Color LCD - I2C + LVGL_ICON_SHARED_AREA_CHART, // LCD Gfx Test - I2C + LVGL_ICON_SHARED_ELECTRIC_BOLT, // Dual-Button - GPIO + LVGL_ICON_SHARED_KEYBOARD_ALT, // CardKB2 - I2C + LVGL_ICON_SHARED_MUSIC_NOTE, // MIDI / Synth - UART + LVGL_ICON_SHARED_WIFI, // RFID 2 - I2C +}}; +constexpr int UNIT_COUNT = UNIT_NAMES.size(); + +void onBtnClicked(lv_event_t* e) { + auto* app = static_cast(lv_event_get_user_data(e)); + lv_obj_t* btn = lv_event_get_target_obj(e); + int idx = (int)(intptr_t)lv_obj_get_user_data(btn); + if (app) m5UnitTestShowTest(app, idx); +} + +} // namespace + +void testListViewCreate(lv_obj_t* parent, Context* app) { lvgl_toolbar_create(parent, "M5 Unit Test"); - list_ = lv_list_create(parent); - lv_obj_set_width(list_, LV_PCT(100)); - lv_obj_set_flex_grow(list_, 1); - lv_obj_set_style_pad_all(list_, uiPad(), 0); - lv_obj_set_style_pad_row(list_, uiRowGap(), 0); - lv_obj_set_style_border_width(list_, 0, 0); - lv_obj_set_style_bg_opa(list_, LV_OPA_TRANSP, 0); + lv_obj_t* list = lv_list_create(parent); + lv_obj_set_width(list, LV_PCT(100)); + lv_obj_set_flex_grow(list, 1); + lv_obj_set_style_pad_all(list, uiPad(), 0); + lv_obj_set_style_pad_row(list, uiRowGap(), 0); + lv_obj_set_style_border_width(list, 0, 0); + lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, 0); const lv_font_t* font = lvgl_get_text_font(uiFont()); for (int i = 0; i < UNIT_COUNT; i++) { - lv_obj_t* btn = lv_list_add_button(list_, UNIT_ICONS[i], UNIT_NAMES[i]); + lv_obj_t* btn = lv_list_add_button(list, UNIT_ICONS[i], UNIT_NAMES[i]); lv_obj_set_user_data(btn, (void*)(intptr_t)i); - lv_obj_add_event_cb(btn, onBtnClicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(btn, onBtnClicked, LV_EVENT_CLICKED, app); // lv_list_add_button creates: child 0 = icon label, child 1 = text label lv_obj_t* textLbl = lv_obj_get_child(btn, 1); if (textLbl) lv_obj_set_style_text_font(textLbl, font, 0); @@ -30,15 +70,3 @@ void TestListView::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) if (iconLbl) lv_obj_set_style_text_font(iconLbl, lvgl_get_shared_icon_font(), 0); } } - -void TestListView::onStop() { - list_ = nullptr; - app_ = nullptr; -} - -void TestListView::onBtnClicked(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - lv_obj_t* btn = static_cast(lv_event_get_target(e)); - int idx = (int)(intptr_t)lv_obj_get_user_data(btn); - if (self && self->app_) self->app_->showTest(idx); -} diff --git a/Apps/M5UnitTest/main/Source/TestListView.h b/Apps/M5UnitTest/main/Source/TestListView.h index 5f3b32c..ca12ea3 100644 --- a/Apps/M5UnitTest/main/Source/TestListView.h +++ b/Apps/M5UnitTest/main/Source/TestListView.h @@ -1,49 +1,9 @@ #pragma once -#include #include -#include -#include -class M5UnitTest; +struct Context; -class TestListView { -public: - void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app); - void onStop(); - -private: - lv_obj_t* list_ = nullptr; - M5UnitTest* app_ = nullptr; - - static constexpr std::array UNIT_NAMES = {{ - "8Encoder", - "ByteButton", - "Joystick2", - "Scroll", - "PaHub", - "Color LCD", - "LCD Gfx Test", - "Dual-Button", - "CardKB2", - "MIDI / Synth", - "RFID 2", - }}; - // Interface icons from shared Material icon font - static constexpr std::array UNIT_ICONS = {{ - LVGL_ICON_SHARED_SETTINGS, // 8Encoder - I2C - LVGL_ICON_SHARED_SETTINGS, // ByteButton - I2C - LVGL_ICON_SHARED_GAMEPAD, // Joystick2 - I2C - LVGL_ICON_SHARED_SETTINGS, // Scroll - I2C - LVGL_ICON_SHARED_HUB, // PaHub - I2C - LVGL_ICON_SHARED_DEVICES, // Color LCD - I2C - LVGL_ICON_SHARED_AREA_CHART, // LCD Gfx Test - I2C - LVGL_ICON_SHARED_ELECTRIC_BOLT, // Dual-Button - GPIO - LVGL_ICON_SHARED_KEYBOARD_ALT, // CardKB2 - I2C - LVGL_ICON_SHARED_MUSIC_NOTE, // MIDI / Synth - UART - LVGL_ICON_SHARED_WIFI, // RFID 2 - I2C - }}; - static constexpr int UNIT_COUNT = UNIT_NAMES.size(); - - static void onBtnClicked(lv_event_t* e); -}; +// Builds the unit-selection list directly into @a parent. Stateless - nothing to tear down +// beyond deleting the widgets (handled by the caller via lv_obj_clean on the wrapper). +void testListViewCreate(lv_obj_t* parent, Context* app); diff --git a/Apps/M5UnitTest/main/Source/TestUnit8Encoder.cpp b/Apps/M5UnitTest/main/Source/TestUnit8Encoder.cpp index 04e36a0..d5acb96 100644 --- a/Apps/M5UnitTest/main/Source/TestUnit8Encoder.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnit8Encoder.cpp @@ -1,18 +1,66 @@ #include "TestUnit8Encoder.h" +#include "M5UnitTest.h" #include "GroveLookup.h" #include "UiScale.h" #include +#include #include #include +namespace { -void TestUnit8Encoder::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { - app_ = app; - memset(counters_, 0, sizeof(counters_)); - memset(ledColors_, 0, sizeof(ledColors_)); +void selectIfNeeded(TestUnit8Encoder* self) { + if (self->usingPaHub_ && self->hub_.isPresent()) + self->hub_.select(self->hub_.currentChannel()); +} + +void update(TestUnit8Encoder* self) { + selectIfNeeded(self); + if (!self->enc_.isPresent()) return; + int32_t deltas[8]; + uint8_t buttons[8]; + if (!self->enc_.readAll(deltas, buttons)) return; + + for (int i = 0; i < 8; i++) { + self->counters_[i] += deltas[i]; + lv_label_set_text_fmt(self->lblCounters_[i], "%ld", (long)self->counters_[i]); + + lv_color_t dotCol = buttons[i] ? lv_color_hex(0x00DD44) : lv_color_hex(0x333333); + lv_obj_set_style_bg_color(self->dotButtons_[i], dotCol, 0); + + // Encoder LED: hue cycles with counter position + uint32_t hue = (uint32_t)((self->counters_[i] % 360 + 360) % 360); + lv_color_t c = lv_color_hsv_to_rgb((uint16_t)hue, 100, 78); + lv_color32_t c32 = lv_color_to_32(c, LV_OPA_COVER); + self->ledColors_[i] = ((uint32_t)c32.red << 16) | ((uint32_t)c32.green << 8) | c32.blue; + } + + // Switch (index 8): green=on, dark gray=off; skip update on I2C error + bool sw = false; + if (self->enc_.readSwitch(sw)) { + lv_label_set_text(self->lblSwitch_, sw ? "on" : "off"); + lv_obj_set_style_bg_color(self->dotSwitch_, lv_color_hex(sw ? 0x00DD44 : 0x333333), 0); + self->ledColors_[8] = sw ? 0x00DD44 : 0x000000; + } + + self->enc_.flushLeds(self->ledColors_); +} + +void onTimer(lv_timer_t* t) { + auto* self = static_cast(lv_timer_get_user_data(t)); + if (window_manager_get_state(self->app_->window) != WINDOW_STATE_GRANTED) return; + update(self); +} + +} // namespace + +void testUnit8EncoderStart(TestUnit8Encoder* self, lv_obj_t* parent, Context* app) { + self->app_ = app; + memset(self->counters_, 0, sizeof(self->counters_)); + memset(self->ledColors_, 0, sizeof(self->ledColors_)); - createToolbar(parent, handle, "8Encoder"); - createBanner(parent, "8Encoder", "I2C", COLOR_I2C); + testViewCreateToolbar(parent, app, "8Encoder"); + testViewCreateBanner(parent, "8Encoder", "I2C", COLOR_I2C); int numCols = uiW() >= 800 ? 4 : (uiW() >= 200 ? 2 : 1); int dotSz = (int)(uiShortSide() / 60); @@ -22,11 +70,11 @@ void TestUnit8Encoder::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* a const lv_font_t* fnt = lvgl_get_text_font(uiFont()); // Status label shown when not connected — sits above the grid at full width - lblStatus_ = lv_label_create(parent); - lv_obj_set_style_text_font(lblStatus_, fnt, 0); - lv_obj_set_width(lblStatus_, LV_PCT(100)); - lv_obj_set_style_pad_hor(lblStatus_, uiPad(), 0); - lv_label_set_text(lblStatus_, ""); + self->lblStatus_ = lv_label_create(parent); + lv_obj_set_style_text_font(self->lblStatus_, fnt, 0); + lv_obj_set_width(self->lblStatus_, LV_PCT(100)); + lv_obj_set_style_pad_hor(self->lblStatus_, uiPad(), 0); + lv_label_set_text(self->lblStatus_, ""); lv_obj_t* grid = lv_obj_create(parent); lv_obj_set_width(grid, LV_PCT(100)); @@ -73,12 +121,12 @@ void TestUnit8Encoder::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* a lv_obj_set_style_text_font(num, fnt, 0); lv_obj_set_width(num, LV_SIZE_CONTENT); - lblCounters_[i] = lv_label_create(card); - lv_label_set_text(lblCounters_[i], "0"); - lv_obj_set_style_text_font(lblCounters_[i], fnt, 0); - lv_obj_set_flex_grow(lblCounters_[i], 1); + self->lblCounters_[i] = lv_label_create(card); + lv_label_set_text(self->lblCounters_[i], "0"); + lv_obj_set_style_text_font(self->lblCounters_[i], fnt, 0); + lv_obj_set_flex_grow(self->lblCounters_[i], 1); - dotButtons_[i] = makeDot(card); + self->dotButtons_[i] = makeDot(card); } // Switch row @@ -88,89 +136,48 @@ void TestUnit8Encoder::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* a lv_label_set_text(swLbl, "SW:"); lv_obj_set_style_text_font(swLbl, fnt, 0); lv_obj_set_width(swLbl, LV_SIZE_CONTENT); - lblSwitch_ = lv_label_create(swCard); - lv_label_set_text(lblSwitch_, "off"); - lv_obj_set_style_text_font(lblSwitch_, fnt, 0); - lv_obj_set_flex_grow(lblSwitch_, 1); - dotSwitch_ = makeDot(swCard); + self->lblSwitch_ = lv_label_create(swCard); + lv_label_set_text(self->lblSwitch_, "off"); + lv_obj_set_style_text_font(self->lblSwitch_, fnt, 0); + lv_obj_set_flex_grow(self->lblSwitch_, 1); + self->dotSwitch_ = makeDot(swCard); Device* i2c = findGroveI2cDevice(); if (!i2c) { - lv_label_set_text(lblStatus_, "grove0_i2c not found"); + lv_label_set_text(self->lblStatus_, "grove0_i2c not found"); return; } - if (enc_.begin(i2c)) { - usingPaHub_ = false; - } else if (hub_.begin(i2c)) { - usingPaHub_ = true; + if (self->enc_.begin(i2c)) { + self->usingPaHub_ = false; + } else if (self->hub_.begin(i2c)) { + self->usingPaHub_ = true; bool found = false; for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) { - hub_.select(ch); - if (enc_.begin(i2c)) found = true; + self->hub_.select(ch); + if (self->enc_.begin(i2c)) found = true; } if (!found) { - hub_.deselect(); - lv_label_set_text(lblStatus_, "8Encoder not found"); + self->hub_.deselect(); + lv_label_set_text(self->lblStatus_, "8Encoder not found"); return; } } else { - lv_label_set_text(lblStatus_, "8Encoder not found"); + lv_label_set_text(self->lblStatus_, "8Encoder not found"); return; } - timer_ = lv_timer_create(onTimer, 50, this); - update(); + self->timer_ = lv_timer_create(onTimer, 50, self); + update(self); } -void TestUnit8Encoder::onStop() { - if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; } - selectIfNeeded(); - if (enc_.isPresent()) enc_.setAllLeds(0x000000); - if (usingPaHub_ && hub_.isPresent()) hub_.deselect(); - lblStatus_ = nullptr; - memset(lblCounters_, 0, sizeof(lblCounters_)); - memset(dotButtons_, 0, sizeof(dotButtons_)); - lblSwitch_ = dotSwitch_ = nullptr; -} - -void TestUnit8Encoder::onTimer(lv_timer_t* t) { - static_cast(lv_timer_get_user_data(t))->update(); -} - -void TestUnit8Encoder::selectIfNeeded() { - if (usingPaHub_ && hub_.isPresent()) - hub_.select(hub_.currentChannel()); -} - -void TestUnit8Encoder::update() { - selectIfNeeded(); - if (!enc_.isPresent()) return; - int32_t deltas[8]; - uint8_t buttons[8]; - if (!enc_.readAll(deltas, buttons)) return; - - for (int i = 0; i < 8; i++) { - counters_[i] += deltas[i]; - lv_label_set_text_fmt(lblCounters_[i], "%ld", (long)counters_[i]); - - lv_color_t dotCol = buttons[i] ? lv_color_hex(0x00DD44) : lv_color_hex(0x333333); - lv_obj_set_style_bg_color(dotButtons_[i], dotCol, 0); - - // Encoder LED: hue cycles with counter position - uint32_t hue = (uint32_t)((counters_[i] % 360 + 360) % 360); - lv_color_t c = lv_color_hsv_to_rgb((uint16_t)hue, 100, 78); - lv_color32_t c32 = lv_color_to_32(c, LV_OPA_COVER); - ledColors_[i] = ((uint32_t)c32.red << 16) | ((uint32_t)c32.green << 8) | c32.blue; - } - - // Switch (index 8): green=on, dark gray=off; skip update on I2C error - bool sw = false; - if (enc_.readSwitch(sw)) { - lv_label_set_text(lblSwitch_, sw ? "on" : "off"); - lv_obj_set_style_bg_color(dotSwitch_, lv_color_hex(sw ? 0x00DD44 : 0x333333), 0); - ledColors_[8] = sw ? 0x00DD44 : 0x000000; - } - - enc_.flushLeds(ledColors_); +void testUnit8EncoderStop(TestUnit8Encoder* self) { + if (self->timer_) { lv_timer_delete(self->timer_); self->timer_ = nullptr; } + selectIfNeeded(self); + if (self->enc_.isPresent()) self->enc_.setAllLeds(0x000000); + if (self->usingPaHub_ && self->hub_.isPresent()) self->hub_.deselect(); + self->lblStatus_ = nullptr; + memset(self->lblCounters_, 0, sizeof(self->lblCounters_)); + memset(self->dotButtons_, 0, sizeof(self->dotButtons_)); + self->lblSwitch_ = self->dotSwitch_ = nullptr; } diff --git a/Apps/M5UnitTest/main/Source/TestUnit8Encoder.h b/Apps/M5UnitTest/main/Source/TestUnit8Encoder.h index 6df3e60..b363289 100644 --- a/Apps/M5UnitTest/main/Source/TestUnit8Encoder.h +++ b/Apps/M5UnitTest/main/Source/TestUnit8Encoder.h @@ -3,12 +3,10 @@ #include #include -class TestUnit8Encoder final : public TestViewBase { -public: - void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override; - void onStop() override; +struct Context; -private: +struct TestUnit8Encoder { + Context* app_ = nullptr; UnitPaHub hub_; Unit8Encoder enc_; lv_obj_t* lblStatus_ = nullptr; @@ -20,8 +18,7 @@ class TestUnit8Encoder final : public TestViewBase { int32_t counters_[8] = {}; uint32_t ledColors_[Unit8Encoder::LED_COUNT] = {}; bool usingPaHub_ = false; - - static void onTimer(lv_timer_t* t); - void update(); - void selectIfNeeded(); }; + +void testUnit8EncoderStart(TestUnit8Encoder* self, lv_obj_t* parent, Context* app); +void testUnit8EncoderStop(TestUnit8Encoder* self); diff --git a/Apps/M5UnitTest/main/Source/TestUnitByteButton.cpp b/Apps/M5UnitTest/main/Source/TestUnitByteButton.cpp index 301bc52..6c6db79 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitByteButton.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitByteButton.cpp @@ -1,17 +1,51 @@ #include "TestUnitByteButton.h" +#include "M5UnitTest.h" #include "GroveLookup.h" #include "UiScale.h" #include +#include #include #include -void TestUnitByteButton::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { - app_ = app; - memset(ledColors_, 0, sizeof(ledColors_)); - memset(prevPressed_, 0, sizeof(prevPressed_)); +namespace { - createToolbar(parent, handle, "ByteButton"); - createBanner(parent, "ByteButton", "I2C", COLOR_I2C); +void selectIfNeeded(TestUnitByteButton* self) { + if (self->usingPaHub_ && self->hub_.isPresent()) + self->hub_.select(self->hub_.currentChannel()); +} + +void update(TestUnitByteButton* self) { + selectIfNeeded(self); + if (!self->unit_.isPresent()) return; + uint8_t mask = self->unit_.readButtons(); + for (int i = 0; i < TestUnitByteButton::BTN_COUNT; i++) { + bool pressed = (mask >> i) & 0x01; + // Toggle LED only on rising edge (press, not hold) + if (pressed && !self->prevPressed_[i]) { + self->ledColors_[i] = (self->ledColors_[i] == 0) ? TestUnitByteButton::COLOR_ON : 0; + self->unit_.setLed((uint8_t)i, self->ledColors_[i]); + } + self->prevPressed_[i] = pressed; + lv_color_t col = pressed ? lv_color_hex(TestUnitByteButton::COLOR_PRESSED) : lv_color_hex(TestUnitByteButton::COLOR_OFF); + lv_obj_set_style_bg_color(self->indicators_[i], col, 0); + } +} + +void onTimer(lv_timer_t* t) { + auto* self = static_cast(lv_timer_get_user_data(t)); + if (window_manager_get_state(self->app_->window) != WINDOW_STATE_GRANTED) return; + update(self); +} + +} // namespace + +void testUnitByteButtonStart(TestUnitByteButton* self, lv_obj_t* parent, Context* app) { + self->app_ = app; + memset(self->ledColors_, 0, sizeof(self->ledColors_)); + memset(self->prevPressed_, 0, sizeof(self->prevPressed_)); + + testViewCreateToolbar(parent, app, "ByteButton"); + testViewCreateBanner(parent, "ByteButton", "I2C", COLOR_I2C); int dotSz = (int)(uiShortSide() / 14); if (dotSz < 20) dotSz = 20; @@ -41,16 +75,16 @@ void TestUnitByteButton::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* lv_obj_set_style_border_width(dotGrid, 0, 0); lv_obj_set_style_pad_all(dotGrid, 0, 0); - for (int i = 0; i < BTN_COUNT; i++) { + for (int i = 0; i < TestUnitByteButton::BTN_COUNT; i++) { lv_obj_t* sq = lv_obj_create(dotGrid); lv_obj_set_size(sq, dotSz, dotSz); lv_obj_set_style_radius(sq, 4, 0); - lv_obj_set_style_bg_color(sq, lv_color_hex(COLOR_OFF), 0); + lv_obj_set_style_bg_color(sq, lv_color_hex(TestUnitByteButton::COLOR_OFF), 0); lv_obj_set_style_bg_opa(sq, LV_OPA_COVER, 0); lv_obj_set_style_border_color(sq, lv_color_hex(0x444444), 0); lv_obj_set_style_border_width(sq, 1, 0); lv_obj_remove_flag(sq, LV_OBJ_FLAG_SCROLLABLE); - indicators_[i] = sq; + self->indicators_[i] = sq; } lv_obj_t* hint = lv_label_create(cont); @@ -59,68 +93,42 @@ void TestUnitByteButton::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* Device* i2c = findGroveI2cDevice(); if (!i2c) { - for (int i = 0; i < BTN_COUNT; i++) lv_obj_set_style_bg_color(indicators_[i], lv_color_hex(COLOR_ERROR), 0); + for (int i = 0; i < TestUnitByteButton::BTN_COUNT; i++) lv_obj_set_style_bg_color(self->indicators_[i], lv_color_hex(TestUnitByteButton::COLOR_ERROR), 0); lv_label_set_text(hint, "grove0_i2c not found"); return; } - if (unit_.begin(i2c)) { - usingPaHub_ = false; - } else if (hub_.begin(i2c)) { - usingPaHub_ = true; + if (self->unit_.begin(i2c)) { + self->usingPaHub_ = false; + } else if (self->hub_.begin(i2c)) { + self->usingPaHub_ = true; bool found = false; for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) { - hub_.select(ch); - if (unit_.begin(i2c)) found = true; + self->hub_.select(ch); + if (self->unit_.begin(i2c)) found = true; } if (!found) { - hub_.deselect(); - for (int i = 0; i < BTN_COUNT; i++) lv_obj_set_style_bg_color(indicators_[i], lv_color_hex(COLOR_ERROR), 0); + self->hub_.deselect(); + for (int i = 0; i < TestUnitByteButton::BTN_COUNT; i++) lv_obj_set_style_bg_color(self->indicators_[i], lv_color_hex(TestUnitByteButton::COLOR_ERROR), 0); lv_label_set_text(hint, "ByteButton not found"); return; } } else { - for (int i = 0; i < BTN_COUNT; i++) lv_obj_set_style_bg_color(indicators_[i], lv_color_hex(COLOR_ERROR), 0); + for (int i = 0; i < TestUnitByteButton::BTN_COUNT; i++) lv_obj_set_style_bg_color(self->indicators_[i], lv_color_hex(TestUnitByteButton::COLOR_ERROR), 0); lv_label_set_text(hint, "ByteButton not found"); return; } - timer_ = lv_timer_create(onTimer, 50, this); - update(); -} - -void TestUnitByteButton::onStop() { - if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; } - selectIfNeeded(); - if (unit_.isPresent()) { - for (int i = 0; i < BTN_COUNT; i++) unit_.setLed((uint8_t)i, 0x000000); - } - if (usingPaHub_ && hub_.isPresent()) hub_.deselect(); - memset(indicators_, 0, sizeof(indicators_)); -} - -void TestUnitByteButton::selectIfNeeded() { - if (usingPaHub_ && hub_.isPresent()) - hub_.select(hub_.currentChannel()); + self->timer_ = lv_timer_create(onTimer, 50, self); + update(self); } -void TestUnitByteButton::onTimer(lv_timer_t* t) { - static_cast(lv_timer_get_user_data(t))->update(); -} - -void TestUnitByteButton::update() { - selectIfNeeded(); - if (!unit_.isPresent()) return; - uint8_t mask = unit_.readButtons(); - for (int i = 0; i < BTN_COUNT; i++) { - bool pressed = (mask >> i) & 0x01; - // Toggle LED only on rising edge (press, not hold) - if (pressed && !prevPressed_[i]) { - ledColors_[i] = (ledColors_[i] == 0) ? COLOR_ON : 0; - unit_.setLed((uint8_t)i, ledColors_[i]); - } - prevPressed_[i] = pressed; - lv_color_t col = pressed ? lv_color_hex(COLOR_PRESSED) : lv_color_hex(COLOR_OFF); - lv_obj_set_style_bg_color(indicators_[i], col, 0); +void testUnitByteButtonStop(TestUnitByteButton* self) { + if (self->timer_) { lv_timer_delete(self->timer_); self->timer_ = nullptr; } + selectIfNeeded(self); + if (self->unit_.isPresent()) { + for (int i = 0; i < TestUnitByteButton::BTN_COUNT; i++) self->unit_.setLed((uint8_t)i, 0x000000); } + if (self->usingPaHub_ && self->hub_.isPresent()) self->hub_.deselect(); + memset(self->indicators_, 0, sizeof(self->indicators_)); } diff --git a/Apps/M5UnitTest/main/Source/TestUnitByteButton.h b/Apps/M5UnitTest/main/Source/TestUnitByteButton.h index b33f642..4c11033 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitByteButton.h +++ b/Apps/M5UnitTest/main/Source/TestUnitByteButton.h @@ -3,18 +3,16 @@ #include #include -class TestUnitByteButton final : public TestViewBase { -public: - void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override; - void onStop() override; +struct Context; -private: +struct TestUnitByteButton { static constexpr int BTN_COUNT = UnitByteButton::BUTTON_COUNT; static constexpr uint32_t COLOR_OFF = 0x001100; static constexpr uint32_t COLOR_ON = 0x00FF44; static constexpr uint32_t COLOR_ERROR = 0x440000; static constexpr uint32_t COLOR_PRESSED = 0xFFFF00; + Context* app_ = nullptr; UnitPaHub hub_; UnitByteButton unit_; lv_obj_t* indicators_[BTN_COUNT] = {}; @@ -22,8 +20,7 @@ class TestUnitByteButton final : public TestViewBase { uint32_t ledColors_[BTN_COUNT] = {}; bool prevPressed_[BTN_COUNT]= {}; bool usingPaHub_ = false; - - void selectIfNeeded(); - static void onTimer(lv_timer_t* t); - void update(); }; + +void testUnitByteButtonStart(TestUnitByteButton* self, lv_obj_t* parent, Context* app); +void testUnitByteButtonStop(TestUnitByteButton* self); diff --git a/Apps/M5UnitTest/main/Source/TestUnitCardKB2.cpp b/Apps/M5UnitTest/main/Source/TestUnitCardKB2.cpp index 132519a..06301b0 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitCardKB2.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitCardKB2.cpp @@ -1,17 +1,21 @@ #include "TestUnitCardKB2.h" +#include "M5UnitTest.h" #include "GroveLookup.h" #include "UiScale.h" #include #include +#include #include #include +namespace { + // --------------------------------------------------------------------------- // Physical key layout - 5 rows (4 real + arrow row). // matchChar=0 = modifier key (no highlight target). // Arrow keys produced by Fn+Z/X/D/C in I2C mode; by key-id in UART mode. // --------------------------------------------------------------------------- -static const struct { const char* label; uint8_t matchChar; int grow; } LAYOUT[][12] = { +const struct { const char* label; uint8_t matchChar; int grow; } LAYOUT[][12] = { // Row 0: 1 2 3 4 5 6 7 8 9 0 Del (+ Esc shown but Esc = Fn+1, no direct key-id) { {"1",'1',1},{"2",'2',1},{"3",'3',1},{"4",'4',1},{"5",'5',1},{"6",'6',1}, {"7",'7',1},{"8",'8',1},{"9",'9',1},{"0",'0',1},{"Del",0x08,1},{nullptr,0,0} }, @@ -28,15 +32,18 @@ static const struct { const char* label; uint8_t matchChar; int grow; } LAYOUT[] { {"<",0x1D,1},{"v",0x1F,1},{"^",0x1E,1},{">",0x1C,1}, {nullptr,0,0},{nullptr,0,0},{nullptr,0,0},{nullptr,0,0},{nullptr,0,0},{nullptr,0,0},{nullptr,0,0},{nullptr,0,0} }, }; -static constexpr int ROW_COUNT = 5; -static constexpr int COL_COUNT = 12; +constexpr int ROW_COUNT = 5; +constexpr int COL_COUNT = 12; + +void connectI2C(lv_event_t* e); +void connectUart(lv_event_t* e); // --------------------------------------------------------------------------- // Grid construction // --------------------------------------------------------------------------- -void TestUnitCardKB2::buildGrid(lv_obj_t* parent) { - gridCount_ = 0; +void buildGrid(TestUnitCardKB2* self, lv_obj_t* parent) { + self->gridCount_ = 0; for (int row = 0; row < ROW_COUNT; row++) { lv_obj_t* rowCont = lv_obj_create(parent); lv_obj_set_width(rowCont, LV_PCT(100)); @@ -62,8 +69,8 @@ void TestUnitCardKB2::buildGrid(lv_obj_t* parent) { lv_label_set_text(lbl, LAYOUT[row][col].label); lv_obj_center(lbl); - if (gridCount_ < GRID_KEY_COUNT) { - grid_[gridCount_++] = { LAYOUT[row][col].label, LAYOUT[row][col].matchChar, btn, lbl }; + if (self->gridCount_ < TestUnitCardKB2::GRID_KEY_COUNT) { + self->grid_[self->gridCount_++] = { LAYOUT[row][col].label, LAYOUT[row][col].matchChar, btn, lbl }; } } } @@ -73,103 +80,53 @@ void TestUnitCardKB2::buildGrid(lv_obj_t* parent) { // Connection overlay // --------------------------------------------------------------------------- -void TestUnitCardKB2::showConnectOverlay() { +void showConnectOverlay(TestUnitCardKB2* self) { int pad = uiPad(); int rowGap = uiRowGap(); const lv_font_t* fnt = lvgl_get_text_font(uiFont()); - connectOverlay_ = lv_obj_create(parentRef_); - lv_obj_set_size(connectOverlay_, LV_PCT(100), LV_PCT(100)); - lv_obj_set_pos(connectOverlay_, 0, 0); - lv_obj_set_layout(connectOverlay_, LV_LAYOUT_FLEX); - lv_obj_set_flex_flow(connectOverlay_, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(connectOverlay_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_all(connectOverlay_, pad, 0); - lv_obj_set_style_pad_row(connectOverlay_, rowGap * 2, 0); + self->connectOverlay_ = lv_obj_create(self->parentRef_); + lv_obj_set_size(self->connectOverlay_, LV_PCT(100), LV_PCT(100)); + lv_obj_set_pos(self->connectOverlay_, 0, 0); + lv_obj_set_layout(self->connectOverlay_, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(self->connectOverlay_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(self->connectOverlay_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(self->connectOverlay_, pad, 0); + lv_obj_set_style_pad_row(self->connectOverlay_, rowGap * 2, 0); - lv_obj_t* title = lv_label_create(connectOverlay_); + lv_obj_t* title = lv_label_create(self->connectOverlay_); lv_obj_set_style_text_font(title, fnt, 0); lv_label_set_text(title, "Select connection mode"); - lv_obj_t* hint = lv_label_create(connectOverlay_); + lv_obj_t* hint = lv_label_create(self->connectOverlay_); lv_obj_set_style_text_font(hint, lvgl_get_text_font(FONT_SIZE_SMALL), 0); lv_obj_set_style_text_color(hint, lv_color_hex(0x888888), 0); lv_label_set_text(hint, "Fn+Sym+1 = I2C | Fn+Sym+2 = UART"); auto makeBtn = [&](const char* label, lv_event_cb_t cb) { - lv_obj_t* btn = lv_button_create(connectOverlay_); + lv_obj_t* btn = lv_button_create(self->connectOverlay_); lv_obj_set_width(btn, LV_PCT(60)); - lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, self); lv_obj_t* lbl = lv_label_create(btn); lv_obj_set_style_text_font(lbl, fnt, 0); lv_label_set_text(lbl, label); lv_obj_center(lbl); }; - makeBtn("I2C (Grove port)", onI2CBtn); - makeBtn("UART (Grove port)", onUartBtn); -} - -// --------------------------------------------------------------------------- -// Connect handlers -// --------------------------------------------------------------------------- - -void TestUnitCardKB2::connectI2C() { - lv_obj_delete(connectOverlay_); - connectOverlay_ = nullptr; - - Device* i2c = findGroveI2cDevice(); - if (!i2c) { - buildMainUI(); - lv_label_set_text(lblHistory_, "grove0_i2c not found"); - return; - } - - bool ok = false; - if (unit_.begin(i2c)) { - usingPaHub_ = false; - ok = true; - } else if (hub_.begin(i2c)) { - usingPaHub_ = true; - for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !ok; ch++) { - hub_.select(ch); - if (unit_.begin(i2c)) ok = true; - } - if (!ok) hub_.deselect(); - } - - buildMainUI(); - if (!ok) lv_label_set_text(lblHistory_, "CardKB2 not found"); - else timer_ = lv_timer_create(onTimer, 50, this); -} - -void TestUnitCardKB2::connectUart() { - lv_obj_delete(connectOverlay_); - connectOverlay_ = nullptr; - - Device* uart = findGroveUartDevice(); - buildMainUI(); - if (!uart) { - lv_label_set_text(lblHistory_, "grove0_uart not found"); - return; - } - if (!unit_.beginUart(uart)) { - lv_label_set_text(lblHistory_, "UART open failed"); - return; - } - timer_ = lv_timer_create(onTimer, 50, this); + makeBtn("I2C (Grove port)", connectI2C); + makeBtn("UART (Grove port)", connectUart); } // --------------------------------------------------------------------------- // Main content UI (built after connection type selected) // --------------------------------------------------------------------------- -void TestUnitCardKB2::buildMainUI() { - memset(history_, 0, sizeof(history_)); - histLen_ = 0; - gridCount_ = 0; - activeBtn_ = nullptr; +void buildMainUI(TestUnitCardKB2* self) { + memset(self->history_, 0, sizeof(self->history_)); + self->histLen_ = 0; + self->gridCount_ = 0; + self->activeBtn_ = nullptr; - lv_obj_t* cont = lv_obj_create(parentRef_); + lv_obj_t* cont = lv_obj_create(self->parentRef_); lv_obj_set_width(cont, LV_PCT(100)); lv_obj_set_flex_grow(cont, 1); lv_obj_set_layout(cont, LV_LAYOUT_FLEX); @@ -180,105 +137,150 @@ void TestUnitCardKB2::buildMainUI() { lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(cont, 0, 0); - if (uiW() >= 200) buildGrid(cont); - - lblHistory_ = lv_label_create(cont); - lv_obj_set_style_text_font(lblHistory_, lvgl_get_text_font(uiFont()), 0); - lv_label_set_text(lblHistory_, ""); - lv_obj_set_width(lblHistory_, LV_PCT(100)); - lv_label_set_long_mode(lblHistory_, LV_LABEL_LONG_WRAP); - lv_obj_set_flex_grow(lblHistory_, 1); -} - -// --------------------------------------------------------------------------- -// Lifecycle -// --------------------------------------------------------------------------- - -void TestUnitCardKB2::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { - app_ = app; - parentRef_ = parent; - handleRef_ = handle; - - createToolbar(parent, handle, "CardKB2"); - createBanner(parent, "CardKB2", "I2C/UART", COLOR_I2C); + if (uiW() >= 200) buildGrid(self, cont); - showConnectOverlay(); -} - -void TestUnitCardKB2::onStop() { - if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; } - if (usingPaHub_ && hub_.isPresent()) hub_.deselect(); - unit_.end(); - lblHistory_ = nullptr; - connectOverlay_ = nullptr; - activeBtn_ = nullptr; - gridCount_ = 0; -} - -// --------------------------------------------------------------------------- -// Static callbacks -// --------------------------------------------------------------------------- - -void TestUnitCardKB2::onI2CBtn(lv_event_t* e) { - static_cast(lv_event_get_user_data(e))->connectI2C(); -} - -void TestUnitCardKB2::onUartBtn(lv_event_t* e) { - static_cast(lv_event_get_user_data(e))->connectUart(); -} - -void TestUnitCardKB2::onTimer(lv_timer_t* t) { - static_cast(lv_timer_get_user_data(t))->update(); + self->lblHistory_ = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblHistory_, lvgl_get_text_font(uiFont()), 0); + lv_label_set_text(self->lblHistory_, ""); + lv_obj_set_width(self->lblHistory_, LV_PCT(100)); + lv_label_set_long_mode(self->lblHistory_, LV_LABEL_LONG_WRAP); + lv_obj_set_flex_grow(self->lblHistory_, 1); } // --------------------------------------------------------------------------- // PaHub helper // --------------------------------------------------------------------------- -void TestUnitCardKB2::selectIfNeeded() { - if (usingPaHub_ && hub_.isPresent()) - hub_.select(hub_.currentChannel()); +void selectIfNeeded(TestUnitCardKB2* self) { + if (self->usingPaHub_ && self->hub_.isPresent()) + self->hub_.select(self->hub_.currentChannel()); } // --------------------------------------------------------------------------- // Update (called from timer) // --------------------------------------------------------------------------- -void TestUnitCardKB2::update() { - if (!unit_.isPresent()) return; - if (unit_.mode() == UnitCardKB2::Mode::I2C) selectIfNeeded(); +void update(TestUnitCardKB2* self) { + if (!self->unit_.isPresent()) return; + if (self->unit_.mode() == UnitCardKB2::Mode::I2C) selectIfNeeded(self); - char c = unit_.getKey(); + char c = self->unit_.getKey(); // Grid highlight - if (gridCount_ > 0) { + if (self->gridCount_ > 0) { lv_obj_t* newActive = nullptr; if (c != 0) { - for (int i = 0; i < gridCount_; i++) { - if (grid_[i].matchChar == 0 || grid_[i].btn == nullptr) continue; - uint8_t mc = grid_[i].matchChar; + for (int i = 0; i < self->gridCount_; i++) { + if (self->grid_[i].matchChar == 0 || self->grid_[i].btn == nullptr) continue; + uint8_t mc = self->grid_[i].matchChar; // Match lower and uppercase variants of letter keys bool match = (mc == (uint8_t)c) || (mc >= 'a' && mc <= 'z' && mc == (uint8_t)(c | 0x20)); - if (match) { newActive = grid_[i].btn; break; } + if (match) { newActive = self->grid_[i].btn; break; } } } - if (newActive != activeBtn_) { - if (activeBtn_) lv_obj_remove_state(activeBtn_, LV_STATE_PRESSED); + if (newActive != self->activeBtn_) { + if (self->activeBtn_) lv_obj_remove_state(self->activeBtn_, LV_STATE_PRESSED); if (newActive) lv_obj_add_state(newActive, LV_STATE_PRESSED); - activeBtn_ = newActive; + self->activeBtn_ = newActive; } } // History strip - printable chars only if (c != 0 && c >= 0x20 && c < 0x7F) { - if (histLen_ < sizeof(history_) - 1) { - history_[histLen_++] = c; + if (self->histLen_ < sizeof(self->history_) - 1) { + self->history_[self->histLen_++] = c; } else { - memmove(history_, history_ + 1, histLen_ - 1); - history_[histLen_ - 1] = c; + memmove(self->history_, self->history_ + 1, self->histLen_ - 1); + self->history_[self->histLen_ - 1] = c; + } + self->history_[self->histLen_] = '\0'; + lv_label_set_text(self->lblHistory_, self->history_); + } +} + +void onTimer(lv_timer_t* t) { + auto* self = static_cast(lv_timer_get_user_data(t)); + if (window_manager_get_state(self->app_->window) != WINDOW_STATE_GRANTED) return; + update(self); +} + +// --------------------------------------------------------------------------- +// Connect handlers +// --------------------------------------------------------------------------- + +void doConnectI2C(TestUnitCardKB2* self) { + lv_obj_delete(self->connectOverlay_); + self->connectOverlay_ = nullptr; + + Device* i2c = findGroveI2cDevice(); + if (!i2c) { + buildMainUI(self); + lv_label_set_text(self->lblHistory_, "grove0_i2c not found"); + return; + } + + bool ok = false; + if (self->unit_.begin(i2c)) { + self->usingPaHub_ = false; + ok = true; + } else if (self->hub_.begin(i2c)) { + self->usingPaHub_ = true; + for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !ok; ch++) { + self->hub_.select(ch); + if (self->unit_.begin(i2c)) ok = true; } - history_[histLen_] = '\0'; - lv_label_set_text(lblHistory_, history_); + if (!ok) self->hub_.deselect(); } + + buildMainUI(self); + if (!ok) lv_label_set_text(self->lblHistory_, "CardKB2 not found"); + else self->timer_ = lv_timer_create(onTimer, 50, self); +} + +void doConnectUart(TestUnitCardKB2* self) { + lv_obj_delete(self->connectOverlay_); + self->connectOverlay_ = nullptr; + + Device* uart = findGroveUartDevice(); + buildMainUI(self); + if (!uart) { + lv_label_set_text(self->lblHistory_, "grove0_uart not found"); + return; + } + if (!self->unit_.beginUart(uart)) { + lv_label_set_text(self->lblHistory_, "UART open failed"); + return; + } + self->timer_ = lv_timer_create(onTimer, 50, self); +} + +void connectI2C(lv_event_t* e) { + doConnectI2C(static_cast(lv_event_get_user_data(e))); +} + +void connectUart(lv_event_t* e) { + doConnectUart(static_cast(lv_event_get_user_data(e))); +} + +} // namespace + +void testUnitCardKB2Start(TestUnitCardKB2* self, lv_obj_t* parent, Context* app) { + self->app_ = app; + self->parentRef_ = parent; + + testViewCreateToolbar(parent, app, "CardKB2"); + testViewCreateBanner(parent, "CardKB2", "I2C/UART", COLOR_I2C); + + showConnectOverlay(self); +} + +void testUnitCardKB2Stop(TestUnitCardKB2* self) { + if (self->timer_) { lv_timer_delete(self->timer_); self->timer_ = nullptr; } + if (self->usingPaHub_ && self->hub_.isPresent()) self->hub_.deselect(); + self->unit_.end(); + self->lblHistory_ = nullptr; + self->connectOverlay_ = nullptr; + self->activeBtn_ = nullptr; + self->gridCount_ = 0; } diff --git a/Apps/M5UnitTest/main/Source/TestUnitCardKB2.h b/Apps/M5UnitTest/main/Source/TestUnitCardKB2.h index b1dce77..ba518c0 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitCardKB2.h +++ b/Apps/M5UnitTest/main/Source/TestUnitCardKB2.h @@ -3,12 +3,19 @@ #include #include -class TestUnitCardKB2 final : public TestViewBase { -public: - void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override; - void onStop() override; +struct Context; -private: +struct TestUnitCardKB2 { + static constexpr int GRID_KEY_COUNT = 52; + + struct KeyCell { + const char* label; + uint8_t matchChar; // ASCII to highlight; 0 = not matchable + lv_obj_t* btn = nullptr; + lv_obj_t* lbl = nullptr; + }; + + Context* app_ = nullptr; UnitPaHub hub_; UnitCardKB2 unit_; lv_timer_t* timer_ = nullptr; @@ -23,29 +30,12 @@ class TestUnitCardKB2 final : public TestViewBase { uint8_t histLen_ = 0; // Keyboard grid (only built on screens >= 200px wide) - struct KeyCell { - const char* label; - uint8_t matchChar; // ASCII to highlight; 0 = not matchable - lv_obj_t* btn = nullptr; - lv_obj_t* lbl = nullptr; - }; - static constexpr int GRID_KEY_COUNT = 52; KeyCell grid_[GRID_KEY_COUNT] = {}; int gridCount_ = 0; lv_obj_t* activeBtn_ = nullptr; lv_obj_t* parentRef_ = nullptr; - AppHandle handleRef_ = nullptr; - - void showConnectOverlay(); - void connectI2C(); - void connectUart(); - void buildMainUI(); - void buildGrid(lv_obj_t* parent); - void selectIfNeeded(); - - static void onTimer(lv_timer_t* t); - static void onI2CBtn(lv_event_t* e); - static void onUartBtn(lv_event_t* e); - void update(); }; + +void testUnitCardKB2Start(TestUnitCardKB2* self, lv_obj_t* parent, Context* app); +void testUnitCardKB2Stop(TestUnitCardKB2* self); diff --git a/Apps/M5UnitTest/main/Source/TestUnitDualButton.cpp b/Apps/M5UnitTest/main/Source/TestUnitDualButton.cpp index 64a32e5..c207150 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitDualButton.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitDualButton.cpp @@ -1,6 +1,8 @@ #include "TestUnitDualButton.h" +#include "M5UnitTest.h" #include "UiScale.h" #include +#include #include static constexpr gpio_pin_t PIN_MIN = 0; @@ -11,22 +13,24 @@ static constexpr lv_color_t COLOR_A_DIM = LV_COLOR_MAKE(0x40, 0x10, 0x10); static constexpr lv_color_t COLOR_B_ACTIVE = LV_COLOR_MAKE(0x30, 0x60, 0xE0); static constexpr lv_color_t COLOR_B_DIM = LV_COLOR_MAKE(0x10, 0x20, 0x50); +namespace { + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // Scale UI elements off the shorter display side so config looks good in both orientations. -static lv_coord_t uiShort() { +lv_coord_t uiShort() { lv_coord_t w = lv_display_get_horizontal_resolution(nullptr); lv_coord_t h = lv_display_get_vertical_resolution(nullptr); return w < h ? w : h; } // Button size and value label width proportional to short side, clamped to reasonable range. -static lv_coord_t uiBtnSize() { lv_coord_t s = uiShort() / 10; return s < 36 ? 36 : (s > 80 ? 80 : s); } -static lv_coord_t uiValWidth() { lv_coord_t s = uiShort() / 8; return s < 48 ? 48 : (s > 100 ? 100 : s); } +lv_coord_t uiBtnSize() { lv_coord_t s = uiShort() / 10; return s < 36 ? 36 : (s > 80 ? 80 : s); } +lv_coord_t uiValWidth() { lv_coord_t s = uiShort() / 8; return s < 48 ? 48 : (s > 100 ? 100 : s); } -static lv_obj_t* makePinRow(lv_obj_t* parent, const char* label, +lv_obj_t* makePinRow(lv_obj_t* parent, const char* label, lv_event_cb_t cbDown, lv_event_cb_t cbUp, lv_obj_t** outLbl, void* userData) { int pad = uiPad(); @@ -77,57 +81,61 @@ static lv_obj_t* makePinRow(lv_obj_t* parent, const char* label, return row; } -// --------------------------------------------------------------------------- -// onStart -// --------------------------------------------------------------------------- - -void TestUnitDualButton::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { - app_ = app; - createToolbar(parent, handle, "Dual-Button"); - createBanner(parent, "Dual-Button", "GPIO", COLOR_GPIO); - buildConfigScreen(parent); -} +void update(TestUnitDualButton* self) { + if (!self->unit_.isPresent() || !self->circleA_) return; -void TestUnitDualButton::buildConfigScreen(lv_obj_t* parent) { - int pad = uiPad(); - int gap = uiRowGap(); + bool pressA = self->unit_.isButtonAPressed(); + lv_obj_set_style_bg_color(self->circleA_, pressA ? COLOR_A_ACTIVE : COLOR_A_DIM, 0); + lv_label_set_text(self->circleLblA_, pressA ? "PRESSED" : "A"); + lv_obj_set_style_text_color(self->circleLblA_, + pressA ? lv_color_white() : lv_color_make(0x80, 0x40, 0x40), 0); - lv_obj_t* cont = lv_obj_create(parent); - lv_obj_set_width(cont, LV_PCT(100)); - lv_obj_set_flex_grow(cont, 1); - lv_obj_set_layout(cont, LV_LAYOUT_FLEX); - lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_row(cont, gap, 0); - lv_obj_set_style_pad_all(cont, pad, 0); - lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(cont, 0, 0); + bool pressB = self->unit_.isButtonBPressed(); + lv_obj_set_style_bg_color(self->circleB_, pressB ? COLOR_B_ACTIVE : COLOR_B_DIM, 0); + lv_label_set_text(self->circleLblB_, pressB ? "PRESSED" : "B"); + lv_obj_set_style_text_color(self->circleLblB_, + pressB ? lv_color_white() : lv_color_make(0x40, 0x50, 0x80), 0); +} - makePinRow(cont, "Pin A:", onPinADown, onPinAUp, &lblPinA_, this); - lv_label_set_text_fmt(lblPinA_, "%d", (int)pinA_); +void onTimer(lv_timer_t* t) { + auto* self = static_cast(lv_timer_get_user_data(t)); + if (window_manager_get_state(self->app_->window) != WINDOW_STATE_GRANTED) return; + update(self); +} - makePinRow(cont, "Pin B:", onPinBDown, onPinBUp, &lblPinB_, this); - lv_label_set_text_fmt(lblPinB_, "%d", (int)pinB_); +void onPinADown(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (self->pinA_ > PIN_MIN) { + self->pinA_--; + lv_label_set_text_fmt(self->lblPinA_, "%d", (int)self->pinA_); + } +} - enum LvglFontSize fnt = uiShort() >= 400 ? FONT_SIZE_LARGE : FONT_SIZE_DEFAULT; - lv_coord_t btnH = uiBtnSize() * 3 / 2; +void onPinAUp(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (self->pinA_ < PIN_MAX) { + self->pinA_++; + lv_label_set_text_fmt(self->lblPinA_, "%d", (int)self->pinA_); + } +} - lv_obj_t* btnConnect = lv_button_create(cont); - lv_obj_set_width(btnConnect, LV_PCT(60)); - lv_obj_set_height(btnConnect, btnH); - lv_obj_add_event_cb(btnConnect, onConnect, LV_EVENT_CLICKED, this); - lv_obj_t* lbl = lv_label_create(btnConnect); - lv_obj_set_style_text_font(lbl, lvgl_get_text_font(fnt), 0); - lv_label_set_text(lbl, "Connect"); - lv_obj_center(lbl); +void onPinBDown(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (self->pinB_ > PIN_MIN) { + self->pinB_--; + lv_label_set_text_fmt(self->lblPinB_, "%d", (int)self->pinB_); + } +} - lblError_ = lv_label_create(cont); - lv_obj_set_style_text_color(lblError_, lv_color_make(0xE0, 0x40, 0x40), 0); - lv_obj_set_style_text_font(lblError_, lvgl_get_text_font(fnt), 0); - lv_label_set_text(lblError_, ""); +void onPinBUp(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (self->pinB_ < PIN_MAX) { + self->pinB_++; + lv_label_set_text_fmt(self->lblPinB_, "%d", (int)self->pinB_); + } } -void TestUnitDualButton::buildTestScreen(lv_obj_t* parent) { +void buildTestScreen(TestUnitDualButton* self, lv_obj_t* parent) { // Remove config screen (last child added - the cont with flex_grow=1) // We clean the parent area by deleting children after toolbar+banner (first 2), // so instead we track via the cont pointer approach: just clean parent and rebuild @@ -138,7 +146,7 @@ void TestUnitDualButton::buildTestScreen(lv_obj_t* parent) { for (uint32_t i = childCnt; i > 2; --i) { lv_obj_delete(lv_obj_get_child(parent, i - 1)); } - lblPinA_ = lblPinB_ = lblError_ = nullptr; + self->lblPinA_ = self->lblPinB_ = self->lblError_ = nullptr; int pad = uiPad(); @@ -178,128 +186,117 @@ void TestUnitDualButton::buildTestScreen(lv_obj_t* parent) { lv_obj_set_style_border_width(cont, 0, 0); // Circle A (red) - circleA_ = lv_obj_create(cont); - lv_obj_set_size(circleA_, diam, diam); - lv_obj_set_style_radius(circleA_, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_color(circleA_, COLOR_A_DIM, 0); - lv_obj_set_style_bg_opa(circleA_, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(circleA_, 0, 0); - lv_obj_set_style_pad_all(circleA_, 0, 0); - - circleLblA_ = lv_label_create(circleA_); - lv_label_set_text(circleLblA_, "A"); - lv_obj_set_style_text_font(circleLblA_, lvgl_get_text_font(FONT_SIZE_LARGE), 0); - lv_obj_set_style_text_color(circleLblA_, lv_color_make(0x80, 0x40, 0x40), 0); - lv_obj_set_style_text_align(circleLblA_, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_align(circleLblA_, LV_ALIGN_CENTER, 0, 0); + self->circleA_ = lv_obj_create(cont); + lv_obj_set_size(self->circleA_, diam, diam); + lv_obj_set_style_radius(self->circleA_, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(self->circleA_, COLOR_A_DIM, 0); + lv_obj_set_style_bg_opa(self->circleA_, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(self->circleA_, 0, 0); + lv_obj_set_style_pad_all(self->circleA_, 0, 0); + + self->circleLblA_ = lv_label_create(self->circleA_); + lv_label_set_text(self->circleLblA_, "A"); + lv_obj_set_style_text_font(self->circleLblA_, lvgl_get_text_font(FONT_SIZE_LARGE), 0); + lv_obj_set_style_text_color(self->circleLblA_, lv_color_make(0x80, 0x40, 0x40), 0); + lv_obj_set_style_text_align(self->circleLblA_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(self->circleLblA_, LV_ALIGN_CENTER, 0, 0); // Circle B (blue) - circleB_ = lv_obj_create(cont); - lv_obj_set_size(circleB_, diam, diam); - lv_obj_set_style_radius(circleB_, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_color(circleB_, COLOR_B_DIM, 0); - lv_obj_set_style_bg_opa(circleB_, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(circleB_, 0, 0); - lv_obj_set_style_pad_all(circleB_, 0, 0); - - circleLblB_ = lv_label_create(circleB_); - lv_label_set_text(circleLblB_, "B"); - lv_obj_set_style_text_font(circleLblB_, lvgl_get_text_font(FONT_SIZE_LARGE), 0); - lv_obj_set_style_text_color(circleLblB_, lv_color_make(0x40, 0x50, 0x80), 0); - lv_obj_set_style_text_align(circleLblB_, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_align(circleLblB_, LV_ALIGN_CENTER, 0, 0); - - timer_ = lv_timer_create(onTimer, 50, this); - update(); + self->circleB_ = lv_obj_create(cont); + lv_obj_set_size(self->circleB_, diam, diam); + lv_obj_set_style_radius(self->circleB_, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(self->circleB_, COLOR_B_DIM, 0); + lv_obj_set_style_bg_opa(self->circleB_, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(self->circleB_, 0, 0); + lv_obj_set_style_pad_all(self->circleB_, 0, 0); + + self->circleLblB_ = lv_label_create(self->circleB_); + lv_label_set_text(self->circleLblB_, "B"); + lv_obj_set_style_text_font(self->circleLblB_, lvgl_get_text_font(FONT_SIZE_LARGE), 0); + lv_obj_set_style_text_color(self->circleLblB_, lv_color_make(0x40, 0x50, 0x80), 0); + lv_obj_set_style_text_align(self->circleLblB_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(self->circleLblB_, LV_ALIGN_CENTER, 0, 0); + + self->timer_ = lv_timer_create(onTimer, 50, self); + update(self); } -// --------------------------------------------------------------------------- -// onStop -// --------------------------------------------------------------------------- - -void TestUnitDualButton::onStop() { - if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; } - if (connected_) { unit_.end(); connected_ = false; } - lblPinA_ = lblPinB_ = lblError_ = nullptr; - circleA_ = circleB_ = nullptr; - circleLblA_ = circleLblB_ = nullptr; - pinA_ = 0; - pinB_ = 49; +void onConnect(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + Device* gpio = nullptr; + bool ok = device_get_by_name("gpio0", &gpio) == ERROR_NONE; + if (ok) { + ok = self->unit_.begin(gpio, self->pinA_, self->pinB_); + device_put(gpio); + } + if (!ok) { + if (self->lblError_) { + lv_label_set_text(self->lblError_, "GPIO init failed - check pins"); + } + return; + } + self->connected_ = true; + // Obtain the parent (grandparent of the button's container) + lv_obj_t* btn = lv_event_get_target_obj(e); + lv_obj_t* cont = lv_obj_get_parent(btn); + lv_obj_t* parent = lv_obj_get_parent(cont); + buildTestScreen(self, parent); } -// --------------------------------------------------------------------------- -// update (polls hardware, refreshes circles) -// --------------------------------------------------------------------------- - -void TestUnitDualButton::update() { - if (!unit_.isPresent() || !circleA_) return; +void buildConfigScreen(TestUnitDualButton* self, lv_obj_t* parent) { + int pad = uiPad(); + int gap = uiRowGap(); - bool pressA = unit_.isButtonAPressed(); - lv_obj_set_style_bg_color(circleA_, pressA ? COLOR_A_ACTIVE : COLOR_A_DIM, 0); - lv_label_set_text(circleLblA_, pressA ? "PRESSED" : "A"); - lv_obj_set_style_text_color(circleLblA_, - pressA ? lv_color_white() : lv_color_make(0x80, 0x40, 0x40), 0); + lv_obj_t* cont = lv_obj_create(parent); + lv_obj_set_width(cont, LV_PCT(100)); + lv_obj_set_flex_grow(cont, 1); + lv_obj_set_layout(cont, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(cont, gap, 0); + lv_obj_set_style_pad_all(cont, pad, 0); + lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(cont, 0, 0); - bool pressB = unit_.isButtonBPressed(); - lv_obj_set_style_bg_color(circleB_, pressB ? COLOR_B_ACTIVE : COLOR_B_DIM, 0); - lv_label_set_text(circleLblB_, pressB ? "PRESSED" : "B"); - lv_obj_set_style_text_color(circleLblB_, - pressB ? lv_color_white() : lv_color_make(0x40, 0x50, 0x80), 0); -} + makePinRow(cont, "Pin A:", onPinADown, onPinAUp, &self->lblPinA_, self); + lv_label_set_text_fmt(self->lblPinA_, "%d", (int)self->pinA_); -// --------------------------------------------------------------------------- -// Static callbacks -// --------------------------------------------------------------------------- + makePinRow(cont, "Pin B:", onPinBDown, onPinBUp, &self->lblPinB_, self); + lv_label_set_text_fmt(self->lblPinB_, "%d", (int)self->pinB_); -void TestUnitDualButton::onTimer(lv_timer_t* t) { - static_cast(lv_timer_get_user_data(t))->update(); -} + enum LvglFontSize fnt = uiShort() >= 400 ? FONT_SIZE_LARGE : FONT_SIZE_DEFAULT; + lv_coord_t btnH = uiBtnSize() * 3 / 2; -void TestUnitDualButton::onPinADown(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (self->pinA_ > PIN_MIN) { - self->pinA_--; - lv_label_set_text_fmt(self->lblPinA_, "%d", (int)self->pinA_); - } -} + lv_obj_t* btnConnect = lv_button_create(cont); + lv_obj_set_width(btnConnect, LV_PCT(60)); + lv_obj_set_height(btnConnect, btnH); + lv_obj_add_event_cb(btnConnect, onConnect, LV_EVENT_CLICKED, self); + lv_obj_t* lbl = lv_label_create(btnConnect); + lv_obj_set_style_text_font(lbl, lvgl_get_text_font(fnt), 0); + lv_label_set_text(lbl, "Connect"); + lv_obj_center(lbl); -void TestUnitDualButton::onPinAUp(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (self->pinA_ < PIN_MAX) { - self->pinA_++; - lv_label_set_text_fmt(self->lblPinA_, "%d", (int)self->pinA_); - } + self->lblError_ = lv_label_create(cont); + lv_obj_set_style_text_color(self->lblError_, lv_color_make(0xE0, 0x40, 0x40), 0); + lv_obj_set_style_text_font(self->lblError_, lvgl_get_text_font(fnt), 0); + lv_label_set_text(self->lblError_, ""); } -void TestUnitDualButton::onPinBDown(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (self->pinB_ > PIN_MIN) { - self->pinB_--; - lv_label_set_text_fmt(self->lblPinB_, "%d", (int)self->pinB_); - } -} +} // namespace -void TestUnitDualButton::onPinBUp(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (self->pinB_ < PIN_MAX) { - self->pinB_++; - lv_label_set_text_fmt(self->lblPinB_, "%d", (int)self->pinB_); - } +void testUnitDualButtonStart(TestUnitDualButton* self, lv_obj_t* parent, Context* app) { + self->app_ = app; + testViewCreateToolbar(parent, app, "Dual-Button"); + testViewCreateBanner(parent, "Dual-Button", "GPIO", COLOR_GPIO); + buildConfigScreen(self, parent); } -void TestUnitDualButton::onConnect(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - Device* gpio = device_find_by_name("gpio0"); - if (!gpio || !self->unit_.begin(gpio, self->pinA_, self->pinB_)) { - if (self->lblError_) { - lv_label_set_text(self->lblError_, "GPIO init failed - check pins"); - } - return; - } - self->connected_ = true; - // Obtain the parent (grandparent of the button's container) - lv_obj_t* btn = lv_event_get_target_obj(e); - lv_obj_t* cont = lv_obj_get_parent(btn); - lv_obj_t* parent = lv_obj_get_parent(cont); - self->buildTestScreen(parent); +void testUnitDualButtonStop(TestUnitDualButton* self) { + if (self->timer_) { lv_timer_delete(self->timer_); self->timer_ = nullptr; } + if (self->connected_) { self->unit_.end(); self->connected_ = false; } + self->lblPinA_ = self->lblPinB_ = self->lblError_ = nullptr; + self->circleA_ = self->circleB_ = nullptr; + self->circleLblA_ = self->circleLblB_ = nullptr; + self->pinA_ = 0; + self->pinB_ = 49; } diff --git a/Apps/M5UnitTest/main/Source/TestUnitDualButton.h b/Apps/M5UnitTest/main/Source/TestUnitDualButton.h index ba38b37..6b9fb1e 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitDualButton.h +++ b/Apps/M5UnitTest/main/Source/TestUnitDualButton.h @@ -3,12 +3,10 @@ #include #include -class TestUnitDualButton final : public TestViewBase { -public: - void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override; - void onStop() override; +struct Context; -private: +struct TestUnitDualButton { + Context* app_ = nullptr; UnitDualButton unit_; bool connected_ = false; @@ -26,15 +24,7 @@ class TestUnitDualButton final : public TestViewBase { lv_obj_t* circleLblB_ = nullptr; lv_timer_t* timer_ = nullptr; - - void buildConfigScreen(lv_obj_t* parent); - void buildTestScreen(lv_obj_t* parent); - void update(); - - static void onTimer(lv_timer_t* t); - static void onPinADown(lv_event_t* e); - static void onPinAUp(lv_event_t* e); - static void onPinBDown(lv_event_t* e); - static void onPinBUp(lv_event_t* e); - static void onConnect(lv_event_t* e); }; + +void testUnitDualButtonStart(TestUnitDualButton* self, lv_obj_t* parent, Context* app); +void testUnitDualButtonStop(TestUnitDualButton* self); diff --git a/Apps/M5UnitTest/main/Source/TestUnitJoystick2.cpp b/Apps/M5UnitTest/main/Source/TestUnitJoystick2.cpp index 4a6c7b7..26f6a1a 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitJoystick2.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitJoystick2.cpp @@ -1,16 +1,73 @@ #include "TestUnitJoystick2.h" +#include "M5UnitTest.h" #include "GroveLookup.h" #include "UiScale.h" #include +#include #include #include #include -void TestUnitJoystick2::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { - app_ = app; +namespace { - createToolbar(parent, handle, "Joystick2"); - createBanner(parent, "Joystick2", "I2C", COLOR_I2C); +void selectIfNeeded(TestUnitJoystick2* self) { + if (self->usingPaHub_ && self->hub_.isPresent()) + self->hub_.select(self->hub_.currentChannel()); +} + +void update(TestUnitJoystick2* self) { + selectIfNeeded(self); + if (!self->unit_.isPresent()) return; + + int16_t x = 0, y = 0; + self->unit_.readXY12(&x, &y); + bool pressed = self->unit_.isPressed(); + lv_label_set_text_fmt(self->lblXY_, "X: %d Y: %d", (int)x, (int)y); + lv_label_set_text_fmt(self->lblButton_, "Button: %s", pressed ? "PRESSED" : "released"); + + // Map ±2048 joystick range to dot position within a circle. + // Work in float to do circular clamping, then snap back to int pixels. + // Negate both axes to match LVGL screen coordinates and joystick orientation. + // Grove connector facing away from the user. + float radius = (float)(self->joyArea_ - self->dotSize_) / 2.0f; + float nx = -(float)x / 2048.0f; // normalised -1..1 + float ny = -(float)y / 2048.0f; // normalised -1..1 + float dist2 = nx * nx + ny * ny; + if (dist2 > 1.0f) { + float inv = 1.0f / std::sqrt(dist2); + nx *= inv; + ny *= inv; + } + int cx = (int)(radius + nx * radius); + int cy = (int)(radius + ny * radius); + lv_obj_set_pos(self->dot_, cx, cy); + + // LED: hue cycles with X position, blue when pressed + if (pressed) { + self->unit_.setLed(0x0000FF); + } else { + uint16_t hue = (uint16_t)((int)x * 360 / 4096 + 180); // map -2048..2048 -> 0..360 + lv_color_t c = lv_color_hsv_to_rgb(hue % 360, 100, 78); + lv_color32_t c32 = lv_color_to_32(c, LV_OPA_COVER); + self->unit_.setLed(((uint32_t)c32.red << 16) | ((uint32_t)c32.green << 8) | c32.blue); + } + + lv_obj_set_style_bg_color(self->dot_, pressed ? lv_color_hex(0xFF4400) : lv_color_hex(0x00FF00), 0); +} + +void onTimer(lv_timer_t* t) { + auto* self = static_cast(lv_timer_get_user_data(t)); + if (window_manager_get_state(self->app_->window) != WINDOW_STATE_GRANTED) return; + update(self); +} + +} // namespace + +void testUnitJoystick2Start(TestUnitJoystick2* self, lv_obj_t* parent, Context* app) { + self->app_ = app; + + testViewCreateToolbar(parent, app, "Joystick2"); + testViewCreateBanner(parent, "Joystick2", "I2C", COLOR_I2C); // Scale joystick area to shorter display dimension, clamped 80..300px lv_coord_t minDim = std::min(uiW(), uiH()); @@ -32,113 +89,64 @@ void TestUnitJoystick2::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* const lv_font_t* fnt = lvgl_get_text_font(uiFont()); - lblXY_ = lv_label_create(cont); - lv_obj_set_style_text_font(lblXY_, fnt, 0); + self->lblXY_ = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblXY_, fnt, 0); - lblButton_ = lv_label_create(cont); - lv_obj_set_style_text_font(lblButton_, fnt, 0); + self->lblButton_ = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblButton_, fnt, 0); - joyArea_ = JOY_AREA; - dotSize_ = DOT_SIZE; + self->joyArea_ = JOY_AREA; + self->dotSize_ = DOT_SIZE; // Visual joystick area - joyCont_ = lv_obj_create(cont); - lv_obj_set_size(joyCont_, JOY_AREA, JOY_AREA); - lv_obj_set_style_bg_color(joyCont_, lv_color_hex(0x222222), 0); - lv_obj_set_style_radius(joyCont_, JOY_AREA / 2, 0); - lv_obj_set_style_border_width(joyCont_, 2, 0); - lv_obj_set_style_pad_all(joyCont_, 0, 0); - lv_obj_remove_flag(joyCont_, LV_OBJ_FLAG_SCROLLABLE); - - dot_ = lv_obj_create(joyCont_); - lv_obj_set_size(dot_, DOT_SIZE, DOT_SIZE); - lv_obj_set_style_radius(dot_, DOT_SIZE / 2, 0); - lv_obj_set_style_bg_color(dot_, lv_color_hex(0x00FF00), 0); - lv_obj_set_style_border_width(dot_, 0, 0); - lv_obj_set_pos(dot_, (JOY_AREA - DOT_SIZE) / 2, (JOY_AREA - DOT_SIZE) / 2); + self->joyCont_ = lv_obj_create(cont); + lv_obj_set_size(self->joyCont_, JOY_AREA, JOY_AREA); + lv_obj_set_style_bg_color(self->joyCont_, lv_color_hex(0x222222), 0); + lv_obj_set_style_radius(self->joyCont_, JOY_AREA / 2, 0); + lv_obj_set_style_border_width(self->joyCont_, 2, 0); + lv_obj_set_style_pad_all(self->joyCont_, 0, 0); + lv_obj_remove_flag(self->joyCont_, LV_OBJ_FLAG_SCROLLABLE); + + self->dot_ = lv_obj_create(self->joyCont_); + lv_obj_set_size(self->dot_, DOT_SIZE, DOT_SIZE); + lv_obj_set_style_radius(self->dot_, DOT_SIZE / 2, 0); + lv_obj_set_style_bg_color(self->dot_, lv_color_hex(0x00FF00), 0); + lv_obj_set_style_border_width(self->dot_, 0, 0); + lv_obj_set_pos(self->dot_, (JOY_AREA - DOT_SIZE) / 2, (JOY_AREA - DOT_SIZE) / 2); Device* i2c = findGroveI2cDevice(); if (!i2c) { - lv_label_set_text(lblXY_, "grove0_i2c not found"); + lv_label_set_text(self->lblXY_, "grove0_i2c not found"); return; } - if (unit_.begin(i2c)) { - usingPaHub_ = false; - } else if (hub_.begin(i2c)) { - usingPaHub_ = true; + if (self->unit_.begin(i2c)) { + self->usingPaHub_ = false; + } else if (self->hub_.begin(i2c)) { + self->usingPaHub_ = true; bool found = false; for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) { - hub_.select(ch); - if (unit_.begin(i2c)) found = true; + self->hub_.select(ch); + if (self->unit_.begin(i2c)) found = true; } if (!found) { - hub_.deselect(); - lv_label_set_text(lblXY_, "Joystick2 not found"); + self->hub_.deselect(); + lv_label_set_text(self->lblXY_, "Joystick2 not found"); return; } } else { - lv_label_set_text(lblXY_, "Joystick2 not found"); + lv_label_set_text(self->lblXY_, "Joystick2 not found"); return; } - timer_ = lv_timer_create(onTimer, 50, this); - update(); -} - -void TestUnitJoystick2::onStop() { - if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; } - selectIfNeeded(); - if (unit_.isPresent()) unit_.setLed(0x000000); - if (usingPaHub_ && hub_.isPresent()) hub_.deselect(); - lblXY_ = lblButton_ = dot_ = joyCont_ = nullptr; -} - -void TestUnitJoystick2::selectIfNeeded() { - if (usingPaHub_ && hub_.isPresent()) - hub_.select(hub_.currentChannel()); + self->timer_ = lv_timer_create(onTimer, 50, self); + update(self); } -void TestUnitJoystick2::onTimer(lv_timer_t* t) { - static_cast(lv_timer_get_user_data(t))->update(); -} - -void TestUnitJoystick2::update() { - selectIfNeeded(); - if (!unit_.isPresent()) return; - - int16_t x = 0, y = 0; - unit_.readXY12(&x, &y); - bool pressed = unit_.isPressed(); - lv_label_set_text_fmt(lblXY_, "X: %d Y: %d", (int)x, (int)y); - lv_label_set_text_fmt(lblButton_, "Button: %s", pressed ? "PRESSED" : "released"); - - // Map ±2048 joystick range to dot position within a circle. - // Work in float to do circular clamping, then snap back to int pixels. - // Negate both axes to match LVGL screen coordinates and joystick orientation. - // Grove connector facing away from the user. - float radius = (float)(joyArea_ - dotSize_) / 2.0f; - float nx = -(float)x / 2048.0f; // normalised -1..1 - float ny = -(float)y / 2048.0f; // normalised -1..1 - float dist2 = nx * nx + ny * ny; - if (dist2 > 1.0f) { - float inv = 1.0f / std::sqrt(dist2); - nx *= inv; - ny *= inv; - } - int cx = (int)(radius + nx * radius); - int cy = (int)(radius + ny * radius); - lv_obj_set_pos(dot_, cx, cy); - - // LED: hue cycles with X position, blue when pressed - if (pressed) { - unit_.setLed(0x0000FF); - } else { - uint16_t hue = (uint16_t)((int)x * 360 / 4096 + 180); // map -2048..2048 -> 0..360 - lv_color_t c = lv_color_hsv_to_rgb(hue % 360, 100, 78); - lv_color32_t c32 = lv_color_to_32(c, LV_OPA_COVER); - unit_.setLed(((uint32_t)c32.red << 16) | ((uint32_t)c32.green << 8) | c32.blue); - } - - lv_obj_set_style_bg_color(dot_, pressed ? lv_color_hex(0xFF4400) : lv_color_hex(0x00FF00), 0); +void testUnitJoystick2Stop(TestUnitJoystick2* self) { + if (self->timer_) { lv_timer_delete(self->timer_); self->timer_ = nullptr; } + selectIfNeeded(self); + if (self->unit_.isPresent()) self->unit_.setLed(0x000000); + if (self->usingPaHub_ && self->hub_.isPresent()) self->hub_.deselect(); + self->lblXY_ = self->lblButton_ = self->dot_ = self->joyCont_ = nullptr; } diff --git a/Apps/M5UnitTest/main/Source/TestUnitJoystick2.h b/Apps/M5UnitTest/main/Source/TestUnitJoystick2.h index 56db6c2..18e3bf4 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitJoystick2.h +++ b/Apps/M5UnitTest/main/Source/TestUnitJoystick2.h @@ -3,12 +3,10 @@ #include #include -class TestUnitJoystick2 final : public TestViewBase { -public: - void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override; - void onStop() override; +struct Context; -private: +struct TestUnitJoystick2 { + Context* app_ = nullptr; UnitPaHub hub_; UnitJoystick2 unit_; lv_obj_t* lblXY_ = nullptr; @@ -19,8 +17,7 @@ class TestUnitJoystick2 final : public TestViewBase { bool usingPaHub_ = false; int joyArea_ = 120; int dotSize_ = 16; - - void selectIfNeeded(); - static void onTimer(lv_timer_t* t); - void update(); }; + +void testUnitJoystick2Start(TestUnitJoystick2* self, lv_obj_t* parent, Context* app); +void testUnitJoystick2Stop(TestUnitJoystick2* self); diff --git a/Apps/M5UnitTest/main/Source/TestUnitLcd.cpp b/Apps/M5UnitTest/main/Source/TestUnitLcd.cpp index b4a9c25..c543df7 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitLcd.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitLcd.cpp @@ -1,16 +1,86 @@ #include "TestUnitLcd.h" +#include "M5UnitTest.h" #include "GroveLookup.h" #include "UiScale.h" #include #include -void TestUnitLcd::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { - app_ = app; - rotation_ = 0; - usingPaHub_ = false; +namespace { - createToolbar(parent, handle, "Color LCD"); - createBanner(parent, "Color LCD", "I2C", COLOR_I2C); +void selectIfNeeded(TestUnitLcd* self) { + if (self->usingPaHub_ && self->hub_.isPresent()) + self->hub_.select(self->lcdChannel_); +} + +void onBrightnessChanged(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + selectIfNeeded(self); + if (!self->lcd_.isPresent()) return; + self->lcd_.setBrightness((uint8_t)lv_slider_get_value(self->sliderBr_)); +} + +void onRotateClicked(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + selectIfNeeded(self); + if (!self->lcd_.isPresent()) return; + self->rotation_ = (self->rotation_ + 1) & 0x03; + self->lcd_.setRotation(self->rotation_); + const char* names[] = { "0 (portrait)", "1 (landscape)", "2 (portrait flip)", "3 (landscape flip)" }; + lv_label_set_text_fmt(self->lblRotation_, "Rotation: %s", names[self->rotation_]); +} + +void onFillRedClicked(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + selectIfNeeded(self); + if (!self->lcd_.isPresent()) return; + self->lcd_.fillScreen(UnitLcd::rgb888to565(0xFF0000)); +} + +void onFillBlueClicked(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + selectIfNeeded(self); + if (!self->lcd_.isPresent()) return; + self->lcd_.fillScreen(UnitLcd::rgb888to565(0x0000FF)); +} + +void onWriteTextClicked(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + selectIfNeeded(self); + if (!self->lcd_.isPresent()) return; + self->lcd_.fillScreen(0x0000); + uint16_t white = UnitLcd::rgb888to565(0xFFFFFF); + uint16_t yellow = UnitLcd::rgb888to565(0xFFFF00); + uint16_t cyan = UnitLcd::rgb888to565(0x00FFFF); + uint16_t red = UnitLcd::rgb888to565(0xFF4444); + uint16_t black = 0x0000; + uint16_t h = self->lcd_.height(); + uint16_t w = self->lcd_.width(); + // 5x7 bitmap font: 1 char = 7px tall, 6px wide; scale-1 row pitch = 9px (7+2 gap) + static constexpr uint16_t FONT1_ROW_H = 9; // row height at scale 1 + static constexpr uint16_t FONT1_CHAR_W = 6; // char advance at scale 1 + static constexpr uint16_t FONT1_COL_W = FONT1_CHAR_W * 2 + 1; // "R" + margin + int16_t bottomY = (h > FONT1_ROW_H) ? (int16_t)(h - FONT1_ROW_H) : 0; + int16_t middleY = (int16_t)(h / 2); + int16_t rightX = (w > FONT1_COL_W) ? (int16_t)(w - FONT1_COL_W) : 0; + // Scale-2 text: 14px tall, 12px pitch + self->lcd_.drawText(4, 8, "HELLO", yellow, black, 2); + self->lcd_.drawText(4, 32, "WORLD", cyan, black, 2); + self->lcd_.drawText(4, 4, "TOP-LEFT", white, black, 1); + self->lcd_.drawText(4, bottomY, "BOTTOM", red, black, 1); + self->lcd_.drawText(4, middleY, "MIDDLE", white, black, 1); + // Right-side marker so portrait/landscape are visually distinct + self->lcd_.drawText(rightX, 4, "R", white, black, 1); +} + +} // namespace + +void testUnitLcdStart(TestUnitLcd* self, lv_obj_t* parent, Context* app) { + self->app_ = app; + self->rotation_ = 0; + self->usingPaHub_ = false; + + testViewCreateToolbar(parent, app, "Color LCD"); + testViewCreateBanner(parent, "Color LCD", "I2C", COLOR_I2C); lv_obj_t* cont = lv_obj_create(parent); lv_obj_set_width(cont, LV_PCT(100)); @@ -24,8 +94,8 @@ void TestUnitLcd::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { const lv_font_t* fnt = lvgl_get_text_font(uiFont()); - lblStatus_ = lv_label_create(cont); - lv_obj_set_style_text_font(lblStatus_, fnt, 0); + self->lblStatus_ = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblStatus_, fnt, 0); // Brightness row lv_obj_t* brRow = lv_obj_create(cont); @@ -41,18 +111,18 @@ void TestUnitLcd::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { lv_label_set_text(brLbl, "Bright:"); lv_obj_set_style_text_font(brLbl, fnt, 0); lv_obj_set_width(brLbl, LV_SIZE_CONTENT); - sliderBr_ = lv_slider_create(brRow); - lv_slider_set_range(sliderBr_, 0, 255); - lv_slider_set_value(sliderBr_, 128, LV_ANIM_OFF); - lv_obj_set_flex_grow(sliderBr_, 1); - lv_obj_add_event_cb(sliderBr_, onBrightnessChanged, LV_EVENT_VALUE_CHANGED, this); + self->sliderBr_ = lv_slider_create(brRow); + lv_slider_set_range(self->sliderBr_, 0, 255); + lv_slider_set_value(self->sliderBr_, 128, LV_ANIM_OFF); + lv_obj_set_flex_grow(self->sliderBr_, 1); + lv_obj_add_event_cb(self->sliderBr_, onBrightnessChanged, LV_EVENT_VALUE_CHANGED, self); // Rotation - lblRotation_ = lv_label_create(cont); - lv_obj_set_style_text_font(lblRotation_, fnt, 0); + self->lblRotation_ = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblRotation_, fnt, 0); lv_obj_t* btnRot = lv_button_create(cont); - lv_obj_add_event_cb(btnRot, onRotateClicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(btnRot, onRotateClicked, LV_EVENT_CLICKED, self); lv_obj_t* lbl = lv_label_create(btnRot); lv_label_set_text(lbl, "Rotate 90"); lv_obj_set_style_text_font(lbl, fnt, 0); @@ -70,124 +140,59 @@ void TestUnitLcd::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { lv_obj_set_style_pad_all(fillRow, 0, 0); lv_obj_t* btnRed = lv_button_create(fillRow); - lv_obj_add_event_cb(btnRed, onFillRedClicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(btnRed, onFillRedClicked, LV_EVENT_CLICKED, self); lv_obj_t* lRed = lv_label_create(btnRed); lv_label_set_text(lRed, "Fill Red"); lv_obj_set_style_text_font(lRed, fnt, 0); lv_obj_t* btnBlue = lv_button_create(fillRow); - lv_obj_add_event_cb(btnBlue, onFillBlueClicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(btnBlue, onFillBlueClicked, LV_EVENT_CLICKED, self); lv_obj_t* lBlue = lv_label_create(btnBlue); lv_label_set_text(lBlue, "Fill Blue"); lv_obj_set_style_text_font(lBlue, fnt, 0); // Text test button lv_obj_t* btnText = lv_button_create(cont); - lv_obj_add_event_cb(btnText, onWriteTextClicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(btnText, onWriteTextClicked, LV_EVENT_CLICKED, self); lv_obj_t* lText = lv_label_create(btnText); lv_label_set_text(lText, "Write Text"); lv_obj_set_style_text_font(lText, fnt, 0); Device* i2c = findGroveI2cDevice(); if (!i2c) { - lv_label_set_text(lblStatus_, "grove0_i2c not found"); + lv_label_set_text(self->lblStatus_, "grove0_i2c not found"); return; } // Try standalone first, then scan PaHub channels - if (lcd_.begin(i2c)) { - usingPaHub_ = false; - } else if (hub_.begin(i2c)) { - usingPaHub_ = true; + if (self->lcd_.begin(i2c)) { + self->usingPaHub_ = false; + } else if (self->hub_.begin(i2c)) { + self->usingPaHub_ = true; bool found = false; for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) { - hub_.select(ch); - if (lcd_.begin(i2c)) { found = true; lcdChannel_ = ch; } + self->hub_.select(ch); + if (self->lcd_.begin(i2c)) { found = true; self->lcdChannel_ = ch; } } if (!found) { - hub_.deselect(); - lv_label_set_text(lblStatus_, "LCD Unit not found"); + self->hub_.deselect(); + lv_label_set_text(self->lblStatus_, "LCD Unit not found"); return; } } else { - lv_label_set_text(lblStatus_, "LCD Unit not found"); + lv_label_set_text(self->lblStatus_, "LCD Unit not found"); return; } - lv_label_set_text(lblStatus_, "LCD ready"); - lv_label_set_text_fmt(lblRotation_, "Rotation: %d (portrait)", (int)rotation_); - lcd_.setBrightness(128); - lcd_.fillScreen(0x0000); -} - -void TestUnitLcd::selectIfNeeded() { - if (usingPaHub_ && hub_.isPresent()) - hub_.select(lcdChannel_); -} - -void TestUnitLcd::onStop() { - selectIfNeeded(); - if (lcd_.isPresent()) lcd_.setBrightness(0); - if (usingPaHub_ && hub_.isPresent()) hub_.deselect(); - lblStatus_ = sliderBr_ = lblRotation_ = nullptr; -} - -void TestUnitLcd::onBrightnessChanged(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - self->selectIfNeeded(); - if (!self->lcd_.isPresent()) return; - self->lcd_.setBrightness((uint8_t)lv_slider_get_value(self->sliderBr_)); -} - -void TestUnitLcd::onRotateClicked(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - self->selectIfNeeded(); - if (!self->lcd_.isPresent()) return; - self->rotation_ = (self->rotation_ + 1) & 0x03; - self->lcd_.setRotation(self->rotation_); - const char* names[] = { "0 (portrait)", "1 (landscape)", "2 (portrait flip)", "3 (landscape flip)" }; - lv_label_set_text_fmt(self->lblRotation_, "Rotation: %s", names[self->rotation_]); -} - -void TestUnitLcd::onFillRedClicked(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - self->selectIfNeeded(); - if (!self->lcd_.isPresent()) return; - self->lcd_.fillScreen(UnitLcd::rgb888to565(0xFF0000)); -} - -void TestUnitLcd::onFillBlueClicked(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - self->selectIfNeeded(); - if (!self->lcd_.isPresent()) return; - self->lcd_.fillScreen(UnitLcd::rgb888to565(0x0000FF)); + lv_label_set_text(self->lblStatus_, "LCD ready"); + lv_label_set_text_fmt(self->lblRotation_, "Rotation: %d (portrait)", (int)self->rotation_); + self->lcd_.setBrightness(128); + self->lcd_.fillScreen(0x0000); } -void TestUnitLcd::onWriteTextClicked(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - self->selectIfNeeded(); - if (!self->lcd_.isPresent()) return; - self->lcd_.fillScreen(0x0000); - uint16_t white = UnitLcd::rgb888to565(0xFFFFFF); - uint16_t yellow = UnitLcd::rgb888to565(0xFFFF00); - uint16_t cyan = UnitLcd::rgb888to565(0x00FFFF); - uint16_t red = UnitLcd::rgb888to565(0xFF4444); - uint16_t black = 0x0000; - uint16_t h = self->lcd_.height(); - uint16_t w = self->lcd_.width(); - // 5x7 bitmap font: 1 char = 7px tall, 6px wide; scale-1 row pitch = 9px (7+2 gap) - static constexpr uint16_t FONT1_ROW_H = 9; // row height at scale 1 - static constexpr uint16_t FONT1_CHAR_W = 6; // char advance at scale 1 - static constexpr uint16_t FONT1_COL_W = FONT1_CHAR_W * 2 + 1; // "R" + margin - int16_t bottomY = (h > FONT1_ROW_H) ? (int16_t)(h - FONT1_ROW_H) : 0; - int16_t middleY = (int16_t)(h / 2); - int16_t rightX = (w > FONT1_COL_W) ? (int16_t)(w - FONT1_COL_W) : 0; - // Scale-2 text: 14px tall, 12px pitch - self->lcd_.drawText(4, 8, "HELLO", yellow, black, 2); - self->lcd_.drawText(4, 32, "WORLD", cyan, black, 2); - self->lcd_.drawText(4, 4, "TOP-LEFT", white, black, 1); - self->lcd_.drawText(4, bottomY, "BOTTOM", red, black, 1); - self->lcd_.drawText(4, middleY, "MIDDLE", white, black, 1); - // Right-side marker so portrait/landscape are visually distinct - self->lcd_.drawText(rightX, 4, "R", white, black, 1); +void testUnitLcdStop(TestUnitLcd* self) { + selectIfNeeded(self); + if (self->lcd_.isPresent()) self->lcd_.setBrightness(0); + if (self->usingPaHub_ && self->hub_.isPresent()) self->hub_.deselect(); + self->lblStatus_ = self->sliderBr_ = self->lblRotation_ = nullptr; } diff --git a/Apps/M5UnitTest/main/Source/TestUnitLcd.h b/Apps/M5UnitTest/main/Source/TestUnitLcd.h index cf29d69..ca6086d 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitLcd.h +++ b/Apps/M5UnitTest/main/Source/TestUnitLcd.h @@ -3,12 +3,10 @@ #include #include -class TestUnitLcd final : public TestViewBase { -public: - void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override; - void onStop() override; +struct Context; -private: +struct TestUnitLcd { + Context* app_ = nullptr; UnitPaHub hub_; UnitLcd lcd_; lv_obj_t* lblStatus_ = nullptr; @@ -17,12 +15,7 @@ class TestUnitLcd final : public TestViewBase { uint8_t rotation_ = 0; bool usingPaHub_ = false; uint8_t lcdChannel_ = 0; - - void selectIfNeeded(); - - static void onBrightnessChanged(lv_event_t* e); - static void onRotateClicked(lv_event_t* e); - static void onFillRedClicked(lv_event_t* e); - static void onFillBlueClicked(lv_event_t* e); - static void onWriteTextClicked(lv_event_t* e); }; + +void testUnitLcdStart(TestUnitLcd* self, lv_obj_t* parent, Context* app); +void testUnitLcdStop(TestUnitLcd* self); diff --git a/Apps/M5UnitTest/main/Source/TestUnitLcdGfx.cpp b/Apps/M5UnitTest/main/Source/TestUnitLcdGfx.cpp index 5088753..6507da3 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitLcdGfx.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitLcdGfx.cpp @@ -1,18 +1,22 @@ #include "TestUnitLcdGfx.h" +#include "M5UnitTest.h" #include "GroveLookup.h" #include "UiScale.h" #include +#include #include #include #include #include #include -static inline uint32_t usNow() { +namespace { + +uint32_t usNow() { return (uint32_t)esp_timer_get_time(); } -static const char* PHASE_NAMES[] = { +const char* PHASE_NAMES[] = { "Screen fill", "Text", "Pixels", @@ -30,171 +34,222 @@ static const char* PHASE_NAMES[] = { "Round rects (outline)", "Results", }; -static constexpr int PHASE_COUNT = (int)(sizeof(PHASE_NAMES) / sizeof(PHASE_NAMES[0])); +constexpr int PHASE_COUNT = (int)(sizeof(PHASE_NAMES) / sizeof(PHASE_NAMES[0])); +// --------------------------------------------------------------------------- +// Benchmark phases - matching PDQ exactly // --------------------------------------------------------------------------- -void TestUnitLcdGfx::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { - app_ = app; - phase_ = 0; - logBuf_[0] = '\0'; - memset(results_, 0, sizeof(results_)); - - createToolbar(parent, handle, "LCD Gfx Test"); - createBanner(parent, "LCD Gfx", "I2C", COLOR_I2C); +uint32_t testFillScreen(TestUnitLcdGfx* self) { + uint32_t s = usNow(); + self->lcd_.fillScreen(0xFFFF); + self->lcd_.fillScreen(0xF800); + self->lcd_.fillScreen(0x07E0); + self->lcd_.fillScreen(0x001F); + self->lcd_.fillScreen(0x0000); + return usNow() - s; +} - lv_obj_t* cont = lv_obj_create(parent); - lv_obj_set_width(cont, LV_PCT(100)); - lv_obj_set_flex_grow(cont, 1); - lv_obj_set_layout(cont, LV_LAYOUT_FLEX); - lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(cont, uiPad(), 0); - lv_obj_set_style_pad_row(cont, uiRowGap(), 0); - lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(cont, 0, 0); +uint32_t testText(TestUnitLcdGfx* self) { + // Mirror PDQ testText() - for a 135px wide screen tsa/tsb/tsc all = 1. + // Scale 2 is used once at the end (fits: "Size 2" = 6 chars × 12px = 72px). + uint16_t black = 0x0000; + self->lcd_.fillScreen(black); + uint32_t s = usNow(); + uint8_t y = 0; + self->lcd_.drawText(0, y, "Hello World!", 0xFFFF, black, 1); y += 9; + self->lcd_.drawText(0, y, "RED GREEN BLUE", UnitLcd::color565(255,0,0), black, 1); y += 9; + self->lcd_.drawText(0, y, "1234.56", UnitLcd::color565(255,255,0), black, 1); y += 9; + self->lcd_.drawText(0, y, "0xDEADBEEF", 0xFFFF, black, 1); y += 9; + self->lcd_.drawText(0, y, "Groop,", UnitLcd::color565(0,255,255), black, 1); y += 9; + self->lcd_.drawText(0, y, "I implore thee,", UnitLcd::color565(255,0,255), black, 1); y += 9; + self->lcd_.drawText(0, y, "my foonting", UnitLcd::color565(0,0,200), black, 1); y += 9; + self->lcd_.drawText(0, y, "turlingdromes.", UnitLcd::color565(0,128,0), black, 1); y += 9; + self->lcd_.drawText(0, y, "crinkly bindlewurdles",UnitLcd::color565(0,128,128), black, 1); y += 9; + self->lcd_.drawText(0, y, "Or I will rend thee", UnitLcd::color565(128,0,0), black, 1); y += 9; + self->lcd_.drawText(0, y, "gobberwartsb", UnitLcd::color565(128,0,128), black, 1); y += 9; + self->lcd_.drawText(0, y, "blurglecruncheon,", UnitLcd::color565(128,128,0), black, 1); y += 9; + self->lcd_.drawText(0, y, "see if I don't!", UnitLcd::color565(64,64,64), black, 1); y += 9; + self->lcd_.drawText(0, y, "Size 2", UnitLcd::color565(255,0,0), black, 2); y += 18; + self->lcd_.drawText(0, y, "Size 3", UnitLcd::color565(255,165,0), black, 2); // capped at 2 + return usNow() - s; +} - const lv_font_t* fnt = lvgl_get_text_font(uiFont()); - const lv_font_t* fntS = lvgl_get_text_font(FONT_SIZE_SMALL); +uint32_t testPixels(TestUnitLcdGfx* self) { + self->lcd_.fillScreen(0x0000); + uint32_t s = usNow(); + for (int16_t y = 0; y < self->h_; y++) { + for (int16_t x = 0; x < self->w_; x++) { + self->lcd_.drawPixel((uint8_t)x, (uint8_t)y, + UnitLcd::color565((uint8_t)(x << 3), (uint8_t)(y << 3), + (uint8_t)((x * y) & 0xFF))); + } + } + return usNow() - s; +} - lblPhase_ = lv_label_create(cont); - lv_obj_set_style_text_font(lblPhase_, fnt, 0); - lv_label_set_text(lblPhase_, "Searching..."); +uint32_t testLines(TestUnitLcdGfx* self) { + uint16_t blue = 0x001F; + int16_t w_ = self->w_, h_ = self->h_; + self->lcd_.fillScreen(0x0000); + uint32_t s = usNow(); + // All 4 corners x 2 sweeps each (matching PDQ exactly) + for (int16_t x = 0; x < w_; x += 6) self->lcd_.drawLine(0, 0, x, h_-1, blue); + for (int16_t y = 0; y < h_; y += 6) self->lcd_.drawLine(0, 0, w_-1, y, blue); + self->lcd_.fillScreen(0x0000); + for (int16_t x = 0; x < w_; x += 6) self->lcd_.drawLine(w_-1, 0, x, h_-1, blue); + for (int16_t y = 0; y < h_; y += 6) self->lcd_.drawLine(w_-1, 0, 0, y, blue); + self->lcd_.fillScreen(0x0000); + for (int16_t x = 0; x < w_; x += 6) self->lcd_.drawLine(0, h_-1, x, 0, blue); + for (int16_t y = 0; y < h_; y += 6) self->lcd_.drawLine(0, h_-1, w_-1, y, blue); + self->lcd_.fillScreen(0x0000); + for (int16_t x = 0; x < w_; x += 6) self->lcd_.drawLine(w_-1, h_-1, x, 0, blue); + for (int16_t y = 0; y < h_; y += 6) self->lcd_.drawLine(w_-1, h_-1, 0, y, blue); + return usNow() - s; +} - lblLog_ = lv_label_create(cont); - lv_obj_set_style_text_font(lblLog_, fntS, 0); - lv_label_set_long_mode(lblLog_, LV_LABEL_LONG_WRAP); - lv_obj_set_width(lblLog_, LV_PCT(100)); - lv_label_set_text(lblLog_, ""); +uint32_t testFastLines(TestUnitLcdGfx* self) { + self->lcd_.fillScreen(0x0000); + uint32_t s = usNow(); + for (int16_t y = 0; y < self->h_; y += 5) self->lcd_.drawHLine(0, (uint8_t)y, (uint8_t)self->w_, 0xF800); + for (int16_t x = 0; x < self->w_; x += 5) self->lcd_.drawVLine((uint8_t)x, 0, (uint8_t)self->h_, 0x001F); + return usNow() - s; +} - Device* i2c = findGroveI2cDevice(); - if (!i2c) { lv_label_set_text(lblPhase_, "grove0_i2c not found"); return; } +uint32_t testFilledRects(TestUnitLcdGfx* self) { + self->lcd_.fillScreen(0x0000); + uint32_t s = usNow(); + for (int16_t i = self->minDim_; i > 0; i -= 6) { + int16_t half = i / 2; + self->lcd_.fillRect((uint8_t)(self->cx_ - half), (uint8_t)(self->cy_ - half), + (uint8_t)(self->cx_ + half - 1), (uint8_t)(self->cy_ + half - 1), + UnitLcd::color565((uint8_t)std::min((int)i, 255), + (uint8_t)std::min((int)i, 255), 0)); + } + return usNow() - s; +} - if (lcd_.begin(i2c)) { - usingPaHub_ = false; - } else if (hub_.begin(i2c)) { - usingPaHub_ = true; - bool found = false; - for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) { - hub_.select(ch); - if (lcd_.begin(i2c)) found = true; - } - if (!found) { - hub_.deselect(); - lv_label_set_text(lblPhase_, "LCD not found"); - return; - } - } else { - lv_label_set_text(lblPhase_, "LCD not found"); - return; +uint32_t testRects(TestUnitLcdGfx* self) { + // Don't clear - runs on top of filled rects (matches PDQ) + uint32_t s = usNow(); + for (int16_t i = 2; i < self->minDim_; i += 6) { + int16_t half = i / 2; + self->lcd_.drawRect((uint8_t)(self->cx_ - half), (uint8_t)(self->cy_ - half), + (uint8_t)i, (uint8_t)i, 0x07E0); } + return usNow() - s; +} - lcd_.setBrightness(180); - lcd_.setRotation(0); +uint32_t testFilledTriangles(TestUnitLcdGfx* self) { + self->lcd_.fillScreen(0x0000); + uint32_t s = usNow(); + for (int16_t i = self->cMin1_; i > 10; i -= 5) { + self->lcd_.fillTriangle(self->cx1_, self->cy1_ - i, self->cx1_ - i, self->cy1_ + i, self->cx1_ + i, self->cy1_ + i, + UnitLcd::color565(0, (uint8_t)std::min(i*2, 255), (uint8_t)std::min(i*2, 255))); + } + return usNow() - s; +} - // Pre-compute layout constants - w_ = (int16_t)lcd_.width(); - h_ = (int16_t)lcd_.height(); - minDim_ = std::min(w_, h_); - minDim1_= minDim_ - 1; - cx_ = w_ / 2; - cy_ = h_ / 2; - cx1_ = cx_ - 1; - cy1_ = cy_ - 1; - cMin_ = std::min(cx1_, cy1_); - cMin1_ = cMin_ - 1; - - lv_label_set_text(lblPhase_, PHASE_NAMES[0]); - timer_ = lv_timer_create(onTimer, 200, this); - lv_timer_set_repeat_count(timer_, 1); +uint32_t testTriangles(TestUnitLcdGfx* self) { + // Don't clear - runs on top (matches PDQ) + uint32_t s = usNow(); + for (int16_t i = 0; i < self->cMin_; i += 5) { + self->lcd_.drawTriangle(self->cx1_, self->cy1_ - i, self->cx1_ - i, self->cy1_ + i, self->cx1_ + i, self->cy1_ + i, + UnitLcd::color565(0, 0, (uint8_t)std::min(i*4, 255))); + } + return usNow() - s; } -void TestUnitLcdGfx::onStop() { - if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; } - selectIfNeeded(); - if (lcd_.isPresent()) lcd_.setBrightness(0); - if (usingPaHub_ && hub_.isPresent()) hub_.deselect(); - lblPhase_ = lblLog_ = nullptr; +uint32_t testFilledCircles(TestUnitLcdGfx* self) { + self->lcd_.fillScreen(0x0000); + uint32_t s = usNow(); + for (int16_t x = 10; x < (int16_t)self->w_; x += 20) + for (int16_t y = 10; y < (int16_t)self->h_; y += 20) + self->lcd_.fillCircle(x, y, 10, 0xF81F); + return usNow() - s; } -void TestUnitLcdGfx::selectIfNeeded() { - if (usingPaHub_ && hub_.isPresent()) - hub_.select(hub_.currentChannel()); +uint32_t testCircles(TestUnitLcdGfx* self) { + // Don't clear (matches PDQ) + uint32_t s = usNow(); + for (int16_t x = 0; x <= (int16_t)self->w_ + 10; x += 20) + for (int16_t y = 0; y <= (int16_t)self->h_ + 10; y += 20) + self->lcd_.drawCircle(x, y, 10, 0xFFFF); + return usNow() - s; } -void TestUnitLcdGfx::onTimer(lv_timer_t* t) { - static_cast(lv_timer_get_user_data(t))->runNextPhase(); +uint32_t testFillArcs(TestUnitLcdGfx* self) { + self->lcd_.fillScreen(0x0000); + int16_t r = (self->cMin_ > 0) ? (360 / self->cMin_) : 6; + uint32_t s = usNow(); + for (int16_t i = 6; i < self->cMin_; i += 6) + self->lcd_.fillArc(self->cx1_, self->cy1_, i, i - 3, 0.0f, (float)(i * r), 0xF800); + return usNow() - s; } -void TestUnitLcdGfx::appendLog(const char* name, uint32_t us) { - size_t len = strlen(logBuf_); - size_t rem = sizeof(logBuf_) - len; - snprintf(logBuf_ + len, rem, "%-20s %lu\n", name, (unsigned long)us); - lv_label_set_text(lblLog_, logBuf_); +uint32_t testArcs(TestUnitLcdGfx* self) { + // Don't clear (matches PDQ) + int16_t r = (self->cMin_ > 0) ? (360 / self->cMin_) : 6; + uint32_t s = usNow(); + for (int16_t i = 6; i < self->cMin_; i += 6) + self->lcd_.drawArc(self->cx1_, self->cy1_, i, i - 3, 0.0f, (float)(i * r), 0xFFFF); + return usNow() - s; } -void TestUnitLcdGfx::runNextPhase() { - timer_ = nullptr; - if (!lcd_.isPresent()) return; - selectIfNeeded(); +uint32_t testFilledRoundRects(TestUnitLcdGfx* self) { + self->lcd_.fillScreen(0x0000); + uint32_t s = usNow(); + for (int16_t i = self->minDim1_; i > 20; i -= 6) { + int16_t half = i / 2; + self->lcd_.fillRoundRect(self->cx_ - half, self->cy_ - half, i, i, i / 8, + UnitLcd::color565(0, (uint8_t)std::min(i*2, 255), 0)); + } + return usNow() - s; +} - if (phase_ >= PHASE_COUNT) { lv_label_set_text(lblPhase_, "Done!"); return; } +uint32_t testRoundRects(TestUnitLcdGfx* self) { + // Don't clear (matches PDQ) + uint32_t s = usNow(); + for (int16_t i = 20; i < self->minDim1_; i += 6) { + int16_t half = i / 2; + self->lcd_.drawRoundRect(self->cx_ - half, self->cy_ - half, i, i, i / 8, + UnitLcd::color565((uint8_t)std::min(i*2, 255), 0, 0)); + } + return usNow() - s; +} - lv_label_set_text(lblPhase_, PHASE_NAMES[phase_]); +// --------------------------------------------------------------------------- - if (phase_ < PHASE_COUNT - 1) { - // Benchmark phase - uint32_t us = 0; - switch (phase_) { - case 0: us = testFillScreen(); break; - case 1: us = testText(); break; - case 2: us = testPixels(); break; - case 3: us = testLines(); break; - case 4: us = testFastLines(); break; - case 5: us = testFilledRects(); break; - case 6: us = testRects(); break; - case 7: us = testFilledTriangles(); break; - case 8: us = testTriangles(); break; - case 9: us = testFilledCircles(); break; - case 10: us = testCircles(); break; - case 11: us = testFillArcs(); break; - case 12: us = testArcs(); break; - case 13: us = testFilledRoundRects(); break; - case 14: us = testRoundRects(); break; - } - results_[phase_] = us; - appendLog(PHASE_NAMES[phase_], us); - } else { - // Results screen on the LCD itself - drawResultsOnLcd(); - lv_label_set_text(lblPhase_, "Done!"); - phase_++; - return; - } +void selectIfNeeded(TestUnitLcdGfx* self) { + if (self->usingPaHub_ && self->hub_.isPresent()) + self->hub_.select(self->hub_.currentChannel()); +} - phase_++; - // Short pause between phases so the LCD buffer drains - timer_ = lv_timer_create(onTimer, 80, this); - lv_timer_set_repeat_count(timer_, 1); +void appendLog(TestUnitLcdGfx* self, const char* name, uint32_t us) { + size_t len = strlen(self->logBuf_); + size_t rem = sizeof(self->logBuf_) - len; + snprintf(self->logBuf_ + len, rem, "%-20s %lu\n", name, (unsigned long)us); + lv_label_set_text(self->lblLog_, self->logBuf_); } // Draw the timing summary on the LCD unit itself, matching PDQ's results screen. // PDQ background: c cycles 4..11 and is used directly as an RGB565 value // (these are near-black blues: 0x0004..0x000B). The subtle banding effect is // identical to the Arduino PDQ sketch's final loop. -void TestUnitLcdGfx::drawResultsOnLcd() { +void drawResultsOnLcd(TestUnitLcdGfx* self) { uint16_t cyan = UnitLcd::rgb888to565(0x00FFFF); uint16_t yellow = UnitLcd::rgb888to565(0xFFFF00); uint16_t green = UnitLcd::rgb888to565(0x00FF00); uint16_t magenta = UnitLcd::rgb888to565(0xFF00FF); - uint16_t W = lcd_.width(), H = lcd_.height(); + uint16_t W = self->lcd_.width(), H = self->lcd_.height(); // PDQ blue-band background - c is a raw RGB565 value cycling 4..11 { uint16_t c = 4; int8_t d = 1; for (uint16_t i = 0; i < H; i++) { - lcd_.drawHLine(0, (uint8_t)i, (uint8_t)W, c); + self->lcd_.drawHLine(0, (uint8_t)i, (uint8_t)W, c); c = (uint16_t)(c + d); if (c <= 4 || c >= 11) d = -d; } @@ -202,10 +257,10 @@ void TestUnitLcdGfx::drawResultsOnLcd() { // Title - "LCD GFX PDQ" in magenta (PDQ uses "Arduino GFX PDQ") uint8_t y = 2; - lcd_.drawText(2, y, "LCD GFX PDQ", magenta, 0x0006, 1); y += 10; + self->lcd_.drawText(2, y, "LCD GFX PDQ", magenta, 0x0006, 1); y += 10; // Header line - green, matching PDQ's "\nBenchmark micro-secs" - lcd_.drawText(2, y, "Benchmark micro-secs", green, 0x0006, 1); y += 10; + self->lcd_.drawText(2, y, "Benchmark micro-secs", green, 0x0006, 1); y += 10; // Results - cyan label + yellow number, one per row, 9px line height // Names padded to 12 chars; number right-aligned in 9 chars (matches PDQ comma style) @@ -226,13 +281,13 @@ void TestUnitLcdGfx::drawResultsOnLcd() { "RoundRects F", "RoundRects ", }; - for (int i = 0; i < RESULT_COUNT; i++) { + for (int i = 0; i < TestUnitLcdGfx::RESULT_COUNT; i++) { if ((int)y + 9 > (int)H - 9) break; // Label in cyan - lcd_.drawText(2, y, SHORT_NAMES[i], cyan, 0x0006, 1); + self->lcd_.drawText(2, y, SHORT_NAMES[i], cyan, 0x0006, 1); // Number in yellow, formatted with commas like PDQ's printnice() char num[14]; - snprintf(num, sizeof(num), "%lu", (unsigned long)results_[i]); + snprintf(num, sizeof(num), "%lu", (unsigned long)self->results_[i]); // Insert commas right-to-left (PDQ style) for (char* p = (num + strlen(num)) - 3; p > num; p -= 3) { memmove(p + 1, p, strlen(p) + 1); @@ -240,190 +295,146 @@ void TestUnitLcdGfx::drawResultsOnLcd() { } // Right-align in the remaining width (screen is 135px, label ~72px, 63px left) // Draw at fixed x so numbers line up - lcd_.drawText(74, y, num, yellow, 0x0006, 1); + self->lcd_.drawText(74, y, num, yellow, 0x0006, 1); y += 9; } - lcd_.drawText(2, (uint8_t)(H - 9), "Benchmark Complete!", green, 0x0006, 1); + self->lcd_.drawText(2, (uint8_t)(H - 9), "Benchmark Complete!", green, 0x0006, 1); } -// --------------------------------------------------------------------------- -// Benchmark phases - matching PDQ exactly -// --------------------------------------------------------------------------- +void onTimer(lv_timer_t* t); -uint32_t TestUnitLcdGfx::testFillScreen() { - uint32_t s = usNow(); - lcd_.fillScreen(0xFFFF); - lcd_.fillScreen(0xF800); - lcd_.fillScreen(0x07E0); - lcd_.fillScreen(0x001F); - lcd_.fillScreen(0x0000); - return usNow() - s; -} +void runNextPhase(TestUnitLcdGfx* self) { + self->timer_ = nullptr; + if (!self->lcd_.isPresent()) return; + selectIfNeeded(self); -uint32_t TestUnitLcdGfx::testText() { - // Mirror PDQ testText() - for a 135px wide screen tsa/tsb/tsc all = 1. - // Scale 2 is used once at the end (fits: "Size 2" = 6 chars × 12px = 72px). - uint16_t black = 0x0000; - lcd_.fillScreen(black); - uint32_t s = usNow(); - uint8_t y = 0; - lcd_.drawText(0, y, "Hello World!", 0xFFFF, black, 1); y += 9; - lcd_.drawText(0, y, "RED GREEN BLUE", UnitLcd::color565(255,0,0), black, 1); y += 9; - lcd_.drawText(0, y, "1234.56", UnitLcd::color565(255,255,0), black, 1); y += 9; - lcd_.drawText(0, y, "0xDEADBEEF", 0xFFFF, black, 1); y += 9; - lcd_.drawText(0, y, "Groop,", UnitLcd::color565(0,255,255), black, 1); y += 9; - lcd_.drawText(0, y, "I implore thee,", UnitLcd::color565(255,0,255), black, 1); y += 9; - lcd_.drawText(0, y, "my foonting", UnitLcd::color565(0,0,200), black, 1); y += 9; - lcd_.drawText(0, y, "turlingdromes.", UnitLcd::color565(0,128,0), black, 1); y += 9; - lcd_.drawText(0, y, "crinkly bindlewurdles",UnitLcd::color565(0,128,128), black, 1); y += 9; - lcd_.drawText(0, y, "Or I will rend thee", UnitLcd::color565(128,0,0), black, 1); y += 9; - lcd_.drawText(0, y, "gobberwartsb", UnitLcd::color565(128,0,128), black, 1); y += 9; - lcd_.drawText(0, y, "blurglecruncheon,", UnitLcd::color565(128,128,0), black, 1); y += 9; - lcd_.drawText(0, y, "see if I don't!", UnitLcd::color565(64,64,64), black, 1); y += 9; - lcd_.drawText(0, y, "Size 2", UnitLcd::color565(255,0,0), black, 2); y += 18; - lcd_.drawText(0, y, "Size 3", UnitLcd::color565(255,165,0), black, 2); // capped at 2 - return usNow() - s; -} + if (self->phase_ >= PHASE_COUNT) { lv_label_set_text(self->lblPhase_, "Done!"); return; } -uint32_t TestUnitLcdGfx::testPixels() { - lcd_.fillScreen(0x0000); - uint32_t s = usNow(); - for (int16_t y = 0; y < h_; y++) { - for (int16_t x = 0; x < w_; x++) { - lcd_.drawPixel((uint8_t)x, (uint8_t)y, - UnitLcd::color565((uint8_t)(x << 3), (uint8_t)(y << 3), - (uint8_t)((x * y) & 0xFF))); + lv_label_set_text(self->lblPhase_, PHASE_NAMES[self->phase_]); + + if (self->phase_ < PHASE_COUNT - 1) { + // Benchmark phase + uint32_t us = 0; + switch (self->phase_) { + case 0: us = testFillScreen(self); break; + case 1: us = testText(self); break; + case 2: us = testPixels(self); break; + case 3: us = testLines(self); break; + case 4: us = testFastLines(self); break; + case 5: us = testFilledRects(self); break; + case 6: us = testRects(self); break; + case 7: us = testFilledTriangles(self); break; + case 8: us = testTriangles(self); break; + case 9: us = testFilledCircles(self); break; + case 10: us = testCircles(self); break; + case 11: us = testFillArcs(self); break; + case 12: us = testArcs(self); break; + case 13: us = testFilledRoundRects(self); break; + case 14: us = testRoundRects(self); break; } + self->results_[self->phase_] = us; + appendLog(self, PHASE_NAMES[self->phase_], us); + } else { + // Results screen on the LCD itself + drawResultsOnLcd(self); + lv_label_set_text(self->lblPhase_, "Done!"); + self->phase_++; + return; } - return usNow() - s; -} -uint32_t TestUnitLcdGfx::testLines() { - uint16_t blue = 0x001F; - lcd_.fillScreen(0x0000); - uint32_t s = usNow(); - // All 4 corners x 2 sweeps each (matching PDQ exactly) - for (int16_t x = 0; x < w_; x += 6) lcd_.drawLine(0, 0, x, h_-1, blue); - for (int16_t y = 0; y < h_; y += 6) lcd_.drawLine(0, 0, w_-1, y, blue); - lcd_.fillScreen(0x0000); - for (int16_t x = 0; x < w_; x += 6) lcd_.drawLine(w_-1, 0, x, h_-1, blue); - for (int16_t y = 0; y < h_; y += 6) lcd_.drawLine(w_-1, 0, 0, y, blue); - lcd_.fillScreen(0x0000); - for (int16_t x = 0; x < w_; x += 6) lcd_.drawLine(0, h_-1, x, 0, blue); - for (int16_t y = 0; y < h_; y += 6) lcd_.drawLine(0, h_-1, w_-1, y, blue); - lcd_.fillScreen(0x0000); - for (int16_t x = 0; x < w_; x += 6) lcd_.drawLine(w_-1, h_-1, x, 0, blue); - for (int16_t y = 0; y < h_; y += 6) lcd_.drawLine(w_-1, h_-1, 0, y, blue); - return usNow() - s; + self->phase_++; + // Short pause between phases so the LCD buffer drains + self->timer_ = lv_timer_create(onTimer, 80, self); + lv_timer_set_repeat_count(self->timer_, 1); } -uint32_t TestUnitLcdGfx::testFastLines() { - lcd_.fillScreen(0x0000); - uint32_t s = usNow(); - for (int16_t y = 0; y < h_; y += 5) lcd_.drawHLine(0, (uint8_t)y, (uint8_t)w_, 0xF800); - for (int16_t x = 0; x < w_; x += 5) lcd_.drawVLine((uint8_t)x, 0, (uint8_t)h_, 0x001F); - return usNow() - s; +void onTimer(lv_timer_t* t) { + auto* self = static_cast(lv_timer_get_user_data(t)); + if (window_manager_get_state(self->app_->window) != WINDOW_STATE_GRANTED) return; + runNextPhase(self); } -uint32_t TestUnitLcdGfx::testFilledRects() { - lcd_.fillScreen(0x0000); - uint32_t s = usNow(); - for (int16_t i = minDim_; i > 0; i -= 6) { - int16_t half = i / 2; - lcd_.fillRect((uint8_t)(cx_ - half), (uint8_t)(cy_ - half), - (uint8_t)(cx_ + half - 1), (uint8_t)(cy_ + half - 1), - UnitLcd::color565((uint8_t)std::min((int)i, 255), - (uint8_t)std::min((int)i, 255), 0)); - } - return usNow() - s; -} +} // namespace -uint32_t TestUnitLcdGfx::testRects() { - // Don't clear - runs on top of filled rects (matches PDQ) - uint32_t s = usNow(); - for (int16_t i = 2; i < minDim_; i += 6) { - int16_t half = i / 2; - lcd_.drawRect((uint8_t)(cx_ - half), (uint8_t)(cy_ - half), - (uint8_t)i, (uint8_t)i, 0x07E0); - } - return usNow() - s; -} +void testUnitLcdGfxStart(TestUnitLcdGfx* self, lv_obj_t* parent, Context* app) { + self->app_ = app; + self->phase_ = 0; + self->logBuf_[0] = '\0'; + memset(self->results_, 0, sizeof(self->results_)); -uint32_t TestUnitLcdGfx::testFilledTriangles() { - lcd_.fillScreen(0x0000); - uint32_t s = usNow(); - for (int16_t i = cMin1_; i > 10; i -= 5) { - lcd_.fillTriangle(cx1_, cy1_ - i, cx1_ - i, cy1_ + i, cx1_ + i, cy1_ + i, - UnitLcd::color565(0, (uint8_t)std::min(i*2, 255), (uint8_t)std::min(i*2, 255))); - } - return usNow() - s; -} + testViewCreateToolbar(parent, app, "LCD Gfx Test"); + testViewCreateBanner(parent, "LCD Gfx", "I2C", COLOR_I2C); -uint32_t TestUnitLcdGfx::testTriangles() { - // Don't clear - runs on top (matches PDQ) - uint32_t s = usNow(); - for (int16_t i = 0; i < cMin_; i += 5) { - lcd_.drawTriangle(cx1_, cy1_ - i, cx1_ - i, cy1_ + i, cx1_ + i, cy1_ + i, - UnitLcd::color565(0, 0, (uint8_t)std::min(i*4, 255))); - } - return usNow() - s; -} + lv_obj_t* cont = lv_obj_create(parent); + lv_obj_set_width(cont, LV_PCT(100)); + lv_obj_set_flex_grow(cont, 1); + lv_obj_set_layout(cont, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(cont, uiPad(), 0); + lv_obj_set_style_pad_row(cont, uiRowGap(), 0); + lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(cont, 0, 0); -uint32_t TestUnitLcdGfx::testFilledCircles() { - lcd_.fillScreen(0x0000); - uint32_t s = usNow(); - for (int16_t x = 10; x < (int16_t)w_; x += 20) - for (int16_t y = 10; y < (int16_t)h_; y += 20) - lcd_.fillCircle(x, y, 10, 0xF81F); - return usNow() - s; -} + const lv_font_t* fnt = lvgl_get_text_font(uiFont()); + const lv_font_t* fntS = lvgl_get_text_font(FONT_SIZE_SMALL); -uint32_t TestUnitLcdGfx::testCircles() { - // Don't clear (matches PDQ) - uint32_t s = usNow(); - for (int16_t x = 0; x <= (int16_t)w_ + 10; x += 20) - for (int16_t y = 0; y <= (int16_t)h_ + 10; y += 20) - lcd_.drawCircle(x, y, 10, 0xFFFF); - return usNow() - s; -} + self->lblPhase_ = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblPhase_, fnt, 0); + lv_label_set_text(self->lblPhase_, "Searching..."); -uint32_t TestUnitLcdGfx::testFillArcs() { - lcd_.fillScreen(0x0000); - int16_t r = (cMin_ > 0) ? (360 / cMin_) : 6; - uint32_t s = usNow(); - for (int16_t i = 6; i < cMin_; i += 6) - lcd_.fillArc(cx1_, cy1_, i, i - 3, 0.0f, (float)(i * r), 0xF800); - return usNow() - s; -} + self->lblLog_ = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblLog_, fntS, 0); + lv_label_set_long_mode(self->lblLog_, LV_LABEL_LONG_WRAP); + lv_obj_set_width(self->lblLog_, LV_PCT(100)); + lv_label_set_text(self->lblLog_, ""); -uint32_t TestUnitLcdGfx::testArcs() { - // Don't clear (matches PDQ) - int16_t r = (cMin_ > 0) ? (360 / cMin_) : 6; - uint32_t s = usNow(); - for (int16_t i = 6; i < cMin_; i += 6) - lcd_.drawArc(cx1_, cy1_, i, i - 3, 0.0f, (float)(i * r), 0xFFFF); - return usNow() - s; -} + Device* i2c = findGroveI2cDevice(); + if (!i2c) { lv_label_set_text(self->lblPhase_, "grove0_i2c not found"); return; } -uint32_t TestUnitLcdGfx::testFilledRoundRects() { - lcd_.fillScreen(0x0000); - uint32_t s = usNow(); - for (int16_t i = minDim1_; i > 20; i -= 6) { - int16_t half = i / 2; - lcd_.fillRoundRect(cx_ - half, cy_ - half, i, i, i / 8, - UnitLcd::color565(0, (uint8_t)std::min(i*2, 255), 0)); + if (self->lcd_.begin(i2c)) { + self->usingPaHub_ = false; + } else if (self->hub_.begin(i2c)) { + self->usingPaHub_ = true; + bool found = false; + for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) { + self->hub_.select(ch); + if (self->lcd_.begin(i2c)) found = true; + } + if (!found) { + self->hub_.deselect(); + lv_label_set_text(self->lblPhase_, "LCD not found"); + return; + } + } else { + lv_label_set_text(self->lblPhase_, "LCD not found"); + return; } - return usNow() - s; + + self->lcd_.setBrightness(180); + self->lcd_.setRotation(0); + + // Pre-compute layout constants + self->w_ = (int16_t)self->lcd_.width(); + self->h_ = (int16_t)self->lcd_.height(); + self->minDim_ = std::min(self->w_, self->h_); + self->minDim1_= self->minDim_ - 1; + self->cx_ = self->w_ / 2; + self->cy_ = self->h_ / 2; + self->cx1_ = self->cx_ - 1; + self->cy1_ = self->cy_ - 1; + self->cMin_ = std::min(self->cx1_, self->cy1_); + self->cMin1_ = self->cMin_ - 1; + + lv_label_set_text(self->lblPhase_, PHASE_NAMES[0]); + self->timer_ = lv_timer_create(onTimer, 200, self); + lv_timer_set_repeat_count(self->timer_, 1); } -uint32_t TestUnitLcdGfx::testRoundRects() { - // Don't clear (matches PDQ) - uint32_t s = usNow(); - for (int16_t i = 20; i < minDim1_; i += 6) { - int16_t half = i / 2; - lcd_.drawRoundRect(cx_ - half, cy_ - half, i, i, i / 8, - UnitLcd::color565((uint8_t)std::min(i*2, 255), 0, 0)); - } - return usNow() - s; +void testUnitLcdGfxStop(TestUnitLcdGfx* self) { + if (self->timer_) { lv_timer_delete(self->timer_); self->timer_ = nullptr; } + selectIfNeeded(self); + if (self->lcd_.isPresent()) self->lcd_.setBrightness(0); + if (self->usingPaHub_ && self->hub_.isPresent()) self->hub_.deselect(); + self->lblPhase_ = self->lblLog_ = nullptr; } diff --git a/Apps/M5UnitTest/main/Source/TestUnitLcdGfx.h b/Apps/M5UnitTest/main/Source/TestUnitLcdGfx.h index 6bdd9cd..5876740 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitLcdGfx.h +++ b/Apps/M5UnitTest/main/Source/TestUnitLcdGfx.h @@ -3,15 +3,15 @@ #include #include +struct Context; + // PDQ graphics benchmark test, matching the Arduino_GFX PDQgraphicstest sketch. // Phases run sequentially via one-shot LVGL timers; results appear in the LVGL // log and are also drawn back onto the LCD unit at the end. -class TestUnitLcdGfx final : public TestViewBase { -public: - void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override; - void onStop() override; +struct TestUnitLcdGfx { + static constexpr int RESULT_COUNT = 15; -private: + Context* app_ = nullptr; UnitPaHub hub_; UnitLcd lcd_; bool usingPaHub_ = false; @@ -20,39 +20,16 @@ class TestUnitLcdGfx final : public TestViewBase { lv_obj_t* lblLog_ = nullptr; lv_timer_t* timer_ = nullptr; - static constexpr int RESULT_COUNT = 15; - int phase_ = 0; char logBuf_[768] = {}; uint32_t results_[RESULT_COUNT] = {}; - // Pre-computed layout constants (set in onStart after lcd_.begin) + // Pre-computed layout constants (set in start after lcd_.begin) int16_t w_ = 0, h_ = 0; int16_t minDim_ = 0, minDim1_ = 0; // min(w,h) and min(w,h)-1 int16_t cx_ = 0, cy_ = 0, cx1_ = 0, cy1_ = 0; int16_t cMin_ = 0, cMin1_ = 0; // min(cx1,cy1) and min(cx1,cy1)-1 - - void selectIfNeeded(); - void runNextPhase(); - void appendLog(const char* name, uint32_t us); - void drawResultsOnLcd(); - - static void onTimer(lv_timer_t* t); - - // Benchmark phases - matching PDQ order exactly - uint32_t testFillScreen(); - uint32_t testText(); - uint32_t testPixels(); - uint32_t testLines(); - uint32_t testFastLines(); - uint32_t testFilledRects(); - uint32_t testRects(); - uint32_t testFilledTriangles(); - uint32_t testTriangles(); - uint32_t testFilledCircles(); - uint32_t testCircles(); - uint32_t testFillArcs(); - uint32_t testArcs(); - uint32_t testFilledRoundRects(); - uint32_t testRoundRects(); }; + +void testUnitLcdGfxStart(TestUnitLcdGfx* self, lv_obj_t* parent, Context* app); +void testUnitLcdGfxStop(TestUnitLcdGfx* self); diff --git a/Apps/M5UnitTest/main/Source/TestUnitMidi.cpp b/Apps/M5UnitTest/main/Source/TestUnitMidi.cpp index 31ef15d..3b94232 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitMidi.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitMidi.cpp @@ -1,14 +1,80 @@ #include "TestUnitMidi.h" +#include "M5UnitTest.h" #include "GroveLookup.h" #include "UiScale.h" #include #include -void TestUnitMidi::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { - app_ = app; +namespace { - createToolbar(parent, handle, "MIDI / Synth"); - createBanner(parent, "MIDI", "UART", COLOR_UART); +void updateLabels(TestUnitMidi* self) { + lv_label_set_text_fmt(self->lblChannel_, "Channel: %d", (int)self->channel_ + 1); + lv_label_set_text_fmt(self->lblProgram_, "Program: %d", (int)self->program_ + 1); +} + +void onNoteOnClicked(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (!self->unit_.isPresent()) return; + self->unit_.noteOn(self->channel_, self->note_, 100); + self->notePlaying_ = true; +} + +void onNoteOffClicked(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (!self->unit_.isPresent()) return; + self->unit_.noteOff(self->channel_, self->note_); + self->notePlaying_ = false; +} + +void onChDown(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (self->channel_ > 0) { + if (self->notePlaying_ && self->unit_.isPresent()) { + self->unit_.noteOff(self->channel_, self->note_); + self->notePlaying_ = false; + } + self->channel_--; + updateLabels(self); + } +} + +void onChUp(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (self->channel_ < 15) { + if (self->notePlaying_ && self->unit_.isPresent()) { + self->unit_.noteOff(self->channel_, self->note_); + self->notePlaying_ = false; + } + self->channel_++; + updateLabels(self); + } +} + +void onProgDown(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (self->program_ > 0) { + self->program_--; + if (self->unit_.isPresent()) self->unit_.programChange(self->channel_, self->program_); + updateLabels(self); + } +} + +void onProgUp(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + if (self->program_ < 127) { + self->program_++; + if (self->unit_.isPresent()) self->unit_.programChange(self->channel_, self->program_); + updateLabels(self); + } +} + +} // namespace + +void testUnitMidiStart(TestUnitMidi* self, lv_obj_t* parent, Context* app) { + self->app_ = app; + + testViewCreateToolbar(parent, app, "MIDI / Synth"); + testViewCreateBanner(parent, "MIDI", "UART", COLOR_UART); lv_obj_t* cont = lv_obj_create(parent); lv_obj_set_width(cont, LV_PCT(100)); @@ -23,14 +89,14 @@ void TestUnitMidi::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) const lv_font_t* fnt = lvgl_get_text_font(uiFont()); const lv_font_t* fntS = lvgl_get_text_font(FONT_SIZE_SMALL); - lblStatus_ = lv_label_create(cont); - lv_obj_set_style_text_font(lblStatus_, fnt, 0); + self->lblStatus_ = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblStatus_, fnt, 0); - lblChannel_ = lv_label_create(cont); - lv_obj_set_style_text_font(lblChannel_, fntS, 0); + self->lblChannel_ = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblChannel_, fntS, 0); - lblProgram_ = lv_label_create(cont); - lv_obj_set_style_text_font(lblProgram_, fntS, 0); + self->lblProgram_ = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblProgram_, fntS, 0); // Channel row auto makeAdjRow = [&](const char* name, lv_event_cb_t down, lv_event_cb_t up) { @@ -51,12 +117,12 @@ void TestUnitMidi::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) lv_obj_set_width(lbl, LV_SIZE_CONTENT); lv_obj_t* bDown = lv_button_create(row); - lv_obj_add_event_cb(bDown, down, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(bDown, down, LV_EVENT_CLICKED, self); lv_obj_t* lD = lv_label_create(bDown); lv_label_set_text(lD, "-"); lv_obj_set_style_text_font(lD, fntS, 0); lv_obj_t* bUp = lv_button_create(row); - lv_obj_add_event_cb(bUp, up, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(bUp, up, LV_EVENT_CLICKED, self); lv_obj_t* lU = lv_label_create(bUp); lv_label_set_text(lU, "+"); lv_obj_set_style_text_font(lU, fntS, 0); }; @@ -76,93 +142,32 @@ void TestUnitMidi::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) lv_obj_set_style_border_width(noteRow, 0, 0); lv_obj_t* btnOn = lv_button_create(noteRow); - lv_obj_add_event_cb(btnOn, onNoteOnClicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(btnOn, onNoteOnClicked, LV_EVENT_CLICKED, self); lv_obj_t* lOn = lv_label_create(btnOn); lv_label_set_text(lOn, "Note On (C4)"); lv_obj_set_style_text_font(lOn, fntS, 0); lv_obj_t* btnOff = lv_button_create(noteRow); - lv_obj_add_event_cb(btnOff, onNoteOffClicked, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(btnOff, onNoteOffClicked, LV_EVENT_CLICKED, self); lv_obj_t* lOff = lv_label_create(btnOff); lv_label_set_text(lOff, "Note Off"); lv_obj_set_style_text_font(lOff, fntS, 0); Device* uart = findGroveUartDevice(); - if (!uart || !device_is_ready(uart) || !unit_.begin(uart)) { - lv_label_set_text(lblStatus_, "MIDI UART not available"); - updateLabels(); + if (!uart || !device_is_ready(uart) || !self->unit_.begin(uart)) { + lv_label_set_text(self->lblStatus_, "MIDI UART not available"); + updateLabels(self); return; } - lv_label_set_text(lblStatus_, "MIDI ready (31250 bps)"); - unit_.programChange(channel_, program_); - updateLabels(); -} - -void TestUnitMidi::onStop() { - if (notePlaying_ && unit_.isPresent()) { - unit_.noteOff(channel_, note_); - notePlaying_ = false; - } - unit_.end(); - lblStatus_ = lblChannel_ = lblProgram_ = nullptr; + lv_label_set_text(self->lblStatus_, "MIDI ready (31250 bps)"); + self->unit_.programChange(self->channel_, self->program_); + updateLabels(self); } -void TestUnitMidi::updateLabels() { - lv_label_set_text_fmt(lblChannel_, "Channel: %d", (int)channel_ + 1); - lv_label_set_text_fmt(lblProgram_, "Program: %d", (int)program_ + 1); -} - -void TestUnitMidi::onNoteOnClicked(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (!self->unit_.isPresent()) return; - self->unit_.noteOn(self->channel_, self->note_, 100); - self->notePlaying_ = true; -} - -void TestUnitMidi::onNoteOffClicked(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (!self->unit_.isPresent()) return; - self->unit_.noteOff(self->channel_, self->note_); - self->notePlaying_ = false; -} - -void TestUnitMidi::onChDown(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (self->channel_ > 0) { - if (self->notePlaying_ && self->unit_.isPresent()) { - self->unit_.noteOff(self->channel_, self->note_); - self->notePlaying_ = false; - } - self->channel_--; - self->updateLabels(); - } -} - -void TestUnitMidi::onChUp(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (self->channel_ < 15) { - if (self->notePlaying_ && self->unit_.isPresent()) { - self->unit_.noteOff(self->channel_, self->note_); - self->notePlaying_ = false; - } - self->channel_++; - self->updateLabels(); - } -} - -void TestUnitMidi::onProgDown(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (self->program_ > 0) { - self->program_--; - if (self->unit_.isPresent()) self->unit_.programChange(self->channel_, self->program_); - self->updateLabels(); - } -} - -void TestUnitMidi::onProgUp(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (self->program_ < 127) { - self->program_++; - if (self->unit_.isPresent()) self->unit_.programChange(self->channel_, self->program_); - self->updateLabels(); +void testUnitMidiStop(TestUnitMidi* self) { + if (self->notePlaying_ && self->unit_.isPresent()) { + self->unit_.noteOff(self->channel_, self->note_); + self->notePlaying_ = false; } + self->unit_.end(); + self->lblStatus_ = self->lblChannel_ = self->lblProgram_ = nullptr; } diff --git a/Apps/M5UnitTest/main/Source/TestUnitMidi.h b/Apps/M5UnitTest/main/Source/TestUnitMidi.h index 5bc513f..2e7aad7 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitMidi.h +++ b/Apps/M5UnitTest/main/Source/TestUnitMidi.h @@ -2,12 +2,10 @@ #include "TestViewBase.h" #include -class TestUnitMidi final : public TestViewBase { -public: - void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override; - void onStop() override; +struct Context; -private: +struct TestUnitMidi { + Context* app_ = nullptr; UnitMidi unit_; lv_obj_t* lblStatus_ = nullptr; lv_obj_t* lblChannel_ = nullptr; @@ -16,12 +14,7 @@ class TestUnitMidi final : public TestViewBase { uint8_t program_ = 0; uint8_t note_ = 60; // middle C bool notePlaying_= false; - - static void onNoteOnClicked(lv_event_t* e); - static void onNoteOffClicked(lv_event_t* e); - static void onChUp(lv_event_t* e); - static void onChDown(lv_event_t* e); - static void onProgUp(lv_event_t* e); - static void onProgDown(lv_event_t* e); - void updateLabels(); }; + +void testUnitMidiStart(TestUnitMidi* self, lv_obj_t* parent, Context* app); +void testUnitMidiStop(TestUnitMidi* self); diff --git a/Apps/M5UnitTest/main/Source/TestUnitPaHub.cpp b/Apps/M5UnitTest/main/Source/TestUnitPaHub.cpp index 805a46f..ace6fb5 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitPaHub.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitPaHub.cpp @@ -1,15 +1,72 @@ #include "TestUnitPaHub.h" +#include "M5UnitTest.h" #include "GroveLookup.h" #include "UiScale.h" #include #include +#include #include +#include +#include -void TestUnitPaHub::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { - app_ = app; +namespace { - createToolbar(parent, handle, "PaHub"); - createBanner(parent, "PaHub", "I2C", COLOR_I2C); +void probeSelected(TestUnitPaHub* self) { + if (!self->hub_.isPresent() || self->selChannel_ < 0 || self->selChannel_ >= TestUnitPaHub::CH_COUNT) return; + + self->hub_.select((uint8_t)self->selChannel_); + + // Probe known unit addresses only (full-range scan can wedge the ESP-IDF i2c_master bus FSM) + Device* i2c = findGroveI2cDevice(); + if (!i2c) { + lv_label_set_text_fmt(self->lblCh_[self->selChannel_], "CH%d: grove0_i2c not found", self->selChannel_); + self->hub_.deselect(); + return; + } + char found[256] = "Found: "; + bool any = false; + for (uint8_t addr : KNOWN_UNIT_ADDRS) { + if (i2c_controller_has_device_at_address(i2c, addr, + pdMS_TO_TICKS(10)) == ERROR_NONE) { + size_t remaining = sizeof(found) - strlen(found) - 1; + if (remaining < 7) { + strncat(found, "...", remaining); + break; + } + char hex[8]; + snprintf(hex, sizeof(hex), "0x%02X ", addr); + strncat(found, hex, remaining); + any = true; + } + } + if (!any) strcpy(found, "No devices found"); + + lv_label_set_text_fmt(self->lblCh_[self->selChannel_], "CH%d: %s", self->selChannel_, found); + lv_label_set_text_fmt(self->lblStatus_, "Probed CH%d", self->selChannel_); + + self->hub_.deselect(); +} + +void onChannelBtn(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + int ch = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target_obj(e)); + self->selChannel_ = ch; + probeSelected(self); +} + +void onTimer(lv_timer_t* t) { + auto* self = static_cast(lv_timer_get_user_data(t)); + if (window_manager_get_state(self->app_->window) != WINDOW_STATE_GRANTED) return; + if (self->selChannel_ >= 0) probeSelected(self); +} + +} // namespace + +void testUnitPaHubStart(TestUnitPaHub* self, lv_obj_t* parent, Context* app) { + self->app_ = app; + + testViewCreateToolbar(parent, app, "PaHub"); + testViewCreateBanner(parent, "PaHub", "I2C", COLOR_I2C); lv_obj_t* cont = lv_obj_create(parent); lv_obj_set_width(cont, LV_PCT(100)); @@ -37,92 +94,44 @@ void TestUnitPaHub::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) lv_obj_set_style_border_width(btnRow, 0, 0); lv_obj_set_style_pad_all(btnRow, 0, 0); - for (int i = 0; i < CH_COUNT; i++) { + for (int i = 0; i < TestUnitPaHub::CH_COUNT; i++) { lv_obj_t* btn = lv_button_create(btnRow); lv_obj_set_flex_grow(btn, 1); lv_obj_set_style_pad_hor(btn, pad, 0); lv_obj_set_style_pad_ver(btn, gap, 0); lv_obj_set_user_data(btn, (void*)(intptr_t)i); - lv_obj_add_event_cb(btn, onChannelBtn, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(btn, onChannelBtn, LV_EVENT_CLICKED, self); lv_obj_t* lbl = lv_label_create(btn); lv_label_set_text_fmt(lbl, "CH%d", i); lv_obj_set_style_text_font(lbl, fnt, 0); lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0); - btnCh_[i] = btn; + self->btnCh_[i] = btn; } - lblStatus_ = lv_label_create(cont); - lv_obj_set_style_text_font(lblStatus_, fnt, 0); - lv_label_set_text(lblStatus_, "Select a channel to probe"); + self->lblStatus_ = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblStatus_, fnt, 0); + lv_label_set_text(self->lblStatus_, "Select a channel to probe"); - for (int i = 0; i < CH_COUNT; i++) { - lblCh_[i] = lv_label_create(cont); - lv_obj_set_style_text_font(lblCh_[i], fnt, 0); - lv_label_set_text_fmt(lblCh_[i], "CH%d: -", i); + for (int i = 0; i < TestUnitPaHub::CH_COUNT; i++) { + self->lblCh_[i] = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblCh_[i], fnt, 0); + lv_label_set_text_fmt(self->lblCh_[i], "CH%d: -", i); } Device* i2c = findGroveI2cDevice(); - if (!i2c || !hub_.begin(i2c)) { - lv_label_set_text(lblStatus_, "PaHub not found"); + if (!i2c || !self->hub_.begin(i2c)) { + lv_label_set_text(self->lblStatus_, "PaHub not found"); return; } - lv_label_set_text(lblStatus_, "PaHub ready - tap channel to probe"); - - timer_ = lv_timer_create(onTimer, 1000, this); -} - -void TestUnitPaHub::onStop() { - if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; } - if (hub_.isPresent()) hub_.deselect(); - lblStatus_ = nullptr; - for (int i = 0; i < CH_COUNT; i++) { btnCh_[i] = nullptr; lblCh_[i] = nullptr; } -} + lv_label_set_text(self->lblStatus_, "PaHub ready - tap channel to probe"); -void TestUnitPaHub::onChannelBtn(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - int ch = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target_obj(e)); - self->selChannel_ = ch; - self->probeSelected(); + self->timer_ = lv_timer_create(onTimer, 1000, self); } -void TestUnitPaHub::onTimer(lv_timer_t* t) { - auto* self = static_cast(lv_timer_get_user_data(t)); - if (self->selChannel_ >= 0) self->probeSelected(); -} - -void TestUnitPaHub::probeSelected() { - if (!hub_.isPresent() || selChannel_ < 0 || selChannel_ >= CH_COUNT) return; - - hub_.select((uint8_t)selChannel_); - - // Probe known unit addresses only (full-range scan can wedge the ESP-IDF i2c_master bus FSM) - Device* i2c = findGroveI2cDevice(); - if (!i2c) { - lv_label_set_text_fmt(lblCh_[selChannel_], "CH%d: grove0_i2c not found", selChannel_); - hub_.deselect(); - return; - } - char found[256] = "Found: "; - bool any = false; - for (uint8_t addr : KNOWN_UNIT_ADDRS) { - if (i2c_controller_has_device_at_address(i2c, addr, - pdMS_TO_TICKS(10)) == ERROR_NONE) { - size_t remaining = sizeof(found) - strlen(found) - 1; - if (remaining < 7) { - strncat(found, "...", remaining); - break; - } - char hex[8]; - snprintf(hex, sizeof(hex), "0x%02X ", addr); - strncat(found, hex, remaining); - any = true; - } - } - if (!any) strcpy(found, "No devices found"); - - lv_label_set_text_fmt(lblCh_[selChannel_], "CH%d: %s", selChannel_, found); - lv_label_set_text_fmt(lblStatus_, "Probed CH%d", selChannel_); - - hub_.deselect(); +void testUnitPaHubStop(TestUnitPaHub* self) { + if (self->timer_) { lv_timer_delete(self->timer_); self->timer_ = nullptr; } + if (self->hub_.isPresent()) self->hub_.deselect(); + self->lblStatus_ = nullptr; + for (int i = 0; i < TestUnitPaHub::CH_COUNT; i++) { self->btnCh_[i] = nullptr; self->lblCh_[i] = nullptr; } } diff --git a/Apps/M5UnitTest/main/Source/TestUnitPaHub.h b/Apps/M5UnitTest/main/Source/TestUnitPaHub.h index ed25ddf..3fbfe3e 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitPaHub.h +++ b/Apps/M5UnitTest/main/Source/TestUnitPaHub.h @@ -2,22 +2,19 @@ #include "TestViewBase.h" #include -class TestUnitPaHub final : public TestViewBase { -public: - void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override; - void onStop() override; +struct Context; -private: +struct TestUnitPaHub { static constexpr int CH_COUNT = UnitPaHub::NUM_CHANNELS; + Context* app_ = nullptr; UnitPaHub hub_; lv_obj_t* lblStatus_ = nullptr; lv_obj_t* btnCh_[CH_COUNT] = {}; lv_obj_t* lblCh_[CH_COUNT] = {}; lv_timer_t* timer_ = nullptr; int selChannel_ = -1; - - static void onChannelBtn(lv_event_t* e); - static void onTimer(lv_timer_t* t); - void probeSelected(); }; + +void testUnitPaHubStart(TestUnitPaHub* self, lv_obj_t* parent, Context* app); +void testUnitPaHubStop(TestUnitPaHub* self); diff --git a/Apps/M5UnitTest/main/Source/TestUnitRfid2.cpp b/Apps/M5UnitTest/main/Source/TestUnitRfid2.cpp index bc2b309..f18a4f3 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitRfid2.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitRfid2.cpp @@ -1,21 +1,91 @@ #include "TestUnitRfid2.h" +#include "M5UnitTest.h" #include "GroveLookup.h" #include "UiScale.h" #include +#include #include #include #include #include -// --------------------------------------------------------------------------- -// onStart -// --------------------------------------------------------------------------- +namespace { -void TestUnitRfid2::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { - app_ = app; +void selectIfNeeded(TestUnitRfid2* self) { + if (self->usingPaHub_ && self->hub_.isPresent()) + self->hub_.select(self->hub_.currentChannel()); +} + +void showCard(TestUnitRfid2* self, const UnitRfid2::Uid& uid) { + self->lastUid_ = uid; + self->cardType_ = self->unit_.getCardType(uid); + self->cardShown_ = true; + + // UID string + char uidBuf[40] = "UID: "; + int pos = 5; + uint8_t size = (uid.size <= 10) ? uid.size : 10; + for (uint8_t i = 0; i < size; i++) + pos += snprintf(uidBuf + pos, sizeof(uidBuf) - (size_t)pos, "%02X ", uid.bytes[i]); + lv_label_set_text(self->lblUid_, uidBuf); + + char typeBuf[64]; + snprintf(typeBuf, sizeof(typeBuf), "Type: %s", self->unit_.cardTypeName(self->cardType_)); + lv_label_set_text(self->lblType_, typeBuf); + + char sakBuf[40]; + snprintf(sakBuf, sizeof(sakBuf), "SAK: %02X ATQA: %02X %02X", + uid.sak, uid.atqa[0], uid.atqa[1]); + lv_label_set_text(self->lblSak_, sakBuf); + + lv_obj_add_flag(self->idleGroup_, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(self->cardGroup_, LV_OBJ_FLAG_HIDDEN); + self->unit_.haltCard(); +} + +void onTimer(lv_timer_t* t) { + auto* self = static_cast(lv_timer_get_user_data(t)); + if (window_manager_get_state(self->app_->window) != WINDOW_STATE_GRANTED) return; + if (self->cardShown_) return; + + selectIfNeeded(self); + if (!self->unit_.isPresent()) return; + + UnitRfid2::Uid uid = {}; + if (self->unit_.readCard(&uid)) + showCard(self, uid); +} - createToolbar(parent, handle, "RFID 2"); - createBanner(parent, "RFID 2", "I2C", COLOR_I2C); +void onPulseTimer(lv_timer_t* t) { + auto* self = static_cast(lv_timer_get_user_data(t)); + if (window_manager_get_state(self->app_->window) != WINDOW_STATE_GRANTED) return; + if (self->cardShown_ || !self->circle_) return; + + self->pulseOpa_ = static_cast(self->pulseOpa_ + self->pulseDir_); + if (self->pulseOpa_ >= 255) { + self->pulseOpa_ = 255; + self->pulseDir_ = -8; + } else if (self->pulseOpa_ <= 180) { + self->pulseOpa_ = 180; + self->pulseDir_ = 8; + } + lv_obj_set_style_bg_opa(self->circle_, self->pulseOpa_, 0); +} + +void onClear(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + self->cardShown_ = false; + lv_obj_remove_flag(self->idleGroup_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(self->cardGroup_, LV_OBJ_FLAG_HIDDEN); +} + +} // namespace + +void testUnitRfid2Start(TestUnitRfid2* self, lv_obj_t* parent, Context* app) { + self->app_ = app; + + testViewCreateToolbar(parent, app, "RFID 2"); + testViewCreateBanner(parent, "RFID 2", "I2C", COLOR_I2C); int pad = uiPad(); int rowGap = uiRowGap(); @@ -41,61 +111,61 @@ void TestUnitRfid2::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) lv_obj_set_style_border_width(content, 0, 0); // Idle group - idleGroup_ = lv_obj_create(content); - lv_obj_set_width(idleGroup_, LV_SIZE_CONTENT); - lv_obj_set_height(idleGroup_, LV_SIZE_CONTENT); - lv_obj_set_layout(idleGroup_, LV_LAYOUT_FLEX); - lv_obj_set_flex_flow(idleGroup_, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(idleGroup_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_all(idleGroup_, 0, 0); - lv_obj_set_style_pad_row(idleGroup_, rowGap, 0); - lv_obj_set_style_bg_opa(idleGroup_, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(idleGroup_, 0, 0); + self->idleGroup_ = lv_obj_create(content); + lv_obj_set_width(self->idleGroup_, LV_SIZE_CONTENT); + lv_obj_set_height(self->idleGroup_, LV_SIZE_CONTENT); + lv_obj_set_layout(self->idleGroup_, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(self->idleGroup_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(self->idleGroup_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(self->idleGroup_, 0, 0); + lv_obj_set_style_pad_row(self->idleGroup_, rowGap, 0); + lv_obj_set_style_bg_opa(self->idleGroup_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(self->idleGroup_, 0, 0); // Green circle - circle_ = lv_obj_create(idleGroup_); - lv_obj_set_size(circle_, diam, diam); - lv_obj_set_style_radius(circle_, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_color(circle_, LV_COLOR_MAKE(0x20, 0xC0, 0x50), 0); - lv_obj_set_style_bg_opa(circle_, pulseOpa_, 0); - lv_obj_set_style_border_width(circle_, 0, 0); - lv_obj_remove_flag(circle_, LV_OBJ_FLAG_SCROLLABLE); + self->circle_ = lv_obj_create(self->idleGroup_); + lv_obj_set_size(self->circle_, diam, diam); + lv_obj_set_style_radius(self->circle_, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(self->circle_, LV_COLOR_MAKE(0x20, 0xC0, 0x50), 0); + lv_obj_set_style_bg_opa(self->circle_, self->pulseOpa_, 0); + lv_obj_set_style_border_width(self->circle_, 0, 0); + lv_obj_remove_flag(self->circle_, LV_OBJ_FLAG_SCROLLABLE); // "Tap a tag/card..." label - lv_obj_t* tapLabel = lv_label_create(idleGroup_); + lv_obj_t* tapLabel = lv_label_create(self->idleGroup_); lv_obj_set_style_text_font(tapLabel, lvgl_get_text_font(font), 0); lv_label_set_text(tapLabel, "Tap a tag/card..."); // ── Card info group ─────────────────────────────────────────────────────── - cardGroup_ = lv_obj_create(content); - lv_obj_set_width(cardGroup_, LV_PCT(100)); - lv_obj_set_height(cardGroup_, LV_SIZE_CONTENT); - lv_obj_set_layout(cardGroup_, LV_LAYOUT_FLEX); - lv_obj_set_flex_flow(cardGroup_, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(cardGroup_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_set_style_pad_all(cardGroup_, pad, 0); - lv_obj_set_style_pad_row(cardGroup_, rowGap, 0); - lv_obj_set_style_bg_opa(cardGroup_, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(cardGroup_, 0, 0); - lv_obj_add_flag(cardGroup_, LV_OBJ_FLAG_HIDDEN); - - lblUid_ = lv_label_create(cardGroup_); - lv_obj_set_style_text_font(lblUid_, + self->cardGroup_ = lv_obj_create(content); + lv_obj_set_width(self->cardGroup_, LV_PCT(100)); + lv_obj_set_height(self->cardGroup_, LV_SIZE_CONTENT); + lv_obj_set_layout(self->cardGroup_, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(self->cardGroup_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(self->cardGroup_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_all(self->cardGroup_, pad, 0); + lv_obj_set_style_pad_row(self->cardGroup_, rowGap, 0); + lv_obj_set_style_bg_opa(self->cardGroup_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(self->cardGroup_, 0, 0); + lv_obj_add_flag(self->cardGroup_, LV_OBJ_FLAG_HIDDEN); + + self->lblUid_ = lv_label_create(self->cardGroup_); + lv_obj_set_style_text_font(self->lblUid_, lvgl_get_text_font(wide ? FONT_SIZE_LARGE : FONT_SIZE_DEFAULT), 0); - lv_label_set_text(lblUid_, ""); + lv_label_set_text(self->lblUid_, ""); - lblType_ = lv_label_create(cardGroup_); - lv_obj_set_style_text_font(lblType_, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0); - lv_label_set_text(lblType_, ""); + self->lblType_ = lv_label_create(self->cardGroup_); + lv_obj_set_style_text_font(self->lblType_, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0); + lv_label_set_text(self->lblType_, ""); - lblSak_ = lv_label_create(cardGroup_); - lv_obj_set_style_text_font(lblSak_, lvgl_get_text_font(FONT_SIZE_SMALL), 0); - lv_label_set_text(lblSak_, ""); + self->lblSak_ = lv_label_create(self->cardGroup_); + lv_obj_set_style_text_font(self->lblSak_, lvgl_get_text_font(FONT_SIZE_SMALL), 0); + lv_label_set_text(self->lblSak_, ""); - lv_obj_t* btnClear = lv_button_create(cardGroup_); + lv_obj_t* btnClear = lv_button_create(self->cardGroup_); lv_obj_set_style_pad_hor(btnClear, pad * 2, 0); lv_obj_set_style_pad_ver(btnClear, rowGap, 0); - lv_obj_add_event_cb(btnClear, onClear, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(btnClear, onClear, LV_EVENT_CLICKED, self); lv_obj_t* btnLbl = lv_label_create(btnClear); lv_obj_set_style_text_font(btnLbl, lvgl_get_text_font(font), 0); lv_label_set_text(btnLbl, "Clear"); @@ -104,119 +174,33 @@ void TestUnitRfid2::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) Device* i2c = findGroveI2cDevice(); if (!i2c) return; - if (unit_.begin(i2c)) { - usingPaHub_ = false; - } else if (hub_.begin(i2c)) { - usingPaHub_ = true; + if (self->unit_.begin(i2c)) { + self->usingPaHub_ = false; + } else if (self->hub_.begin(i2c)) { + self->usingPaHub_ = true; bool found = false; for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) { - hub_.select(ch); - if (unit_.begin(i2c)) found = true; + self->hub_.select(ch); + if (self->unit_.begin(i2c)) found = true; } if (!found) { - hub_.deselect(); + self->hub_.deselect(); return; } } else { return; } - timer_ = lv_timer_create(onTimer, 100, this); - pulseTimer_ = lv_timer_create(onPulseTimer, 60, this); -} - -// --------------------------------------------------------------------------- -// onStop -// --------------------------------------------------------------------------- - -void TestUnitRfid2::onStop() { - if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; } - if (pulseTimer_) { lv_timer_delete(pulseTimer_); pulseTimer_ = nullptr; } - if (usingPaHub_ && hub_.isPresent()) hub_.deselect(); - - cardShown_ = false; - idleGroup_ = circle_ = nullptr; - cardGroup_ = lblUid_ = lblType_ = lblSak_ = nullptr; -} - -// --------------------------------------------------------------------------- -// PaHub helper -// --------------------------------------------------------------------------- - -void TestUnitRfid2::selectIfNeeded() { - if (usingPaHub_ && hub_.isPresent()) - hub_.select(hub_.currentChannel()); -} - -// --------------------------------------------------------------------------- -// showCard -// --------------------------------------------------------------------------- - -void TestUnitRfid2::showCard(const UnitRfid2::Uid& uid) { - lastUid_ = uid; - cardType_ = unit_.getCardType(uid); - cardShown_ = true; - - // UID string - char uidBuf[40] = "UID: "; - int pos = 5; - uint8_t size = (uid.size <= 10) ? uid.size : 10; - for (uint8_t i = 0; i < size; i++) - pos += snprintf(uidBuf + pos, sizeof(uidBuf) - (size_t)pos, "%02X ", uid.bytes[i]); - lv_label_set_text(lblUid_, uidBuf); - - char typeBuf[64]; - snprintf(typeBuf, sizeof(typeBuf), "Type: %s", unit_.cardTypeName(cardType_)); - lv_label_set_text(lblType_, typeBuf); - - char sakBuf[40]; - snprintf(sakBuf, sizeof(sakBuf), "SAK: %02X ATQA: %02X %02X", - uid.sak, uid.atqa[0], uid.atqa[1]); - lv_label_set_text(lblSak_, sakBuf); - - lv_obj_add_flag(idleGroup_, LV_OBJ_FLAG_HIDDEN); - lv_obj_remove_flag(cardGroup_, LV_OBJ_FLAG_HIDDEN); - unit_.haltCard(); -} - -// --------------------------------------------------------------------------- -// Timer callbacks -// --------------------------------------------------------------------------- - -void TestUnitRfid2::onTimer(lv_timer_t* t) { - auto* self = static_cast(lv_timer_get_user_data(t)); - if (self->cardShown_) return; - - self->selectIfNeeded(); - if (!self->unit_.isPresent()) return; - - UnitRfid2::Uid uid = {}; - if (self->unit_.readCard(&uid)) - self->showCard(uid); + self->timer_ = lv_timer_create(onTimer, 100, self); + self->pulseTimer_ = lv_timer_create(onPulseTimer, 60, self); } -void TestUnitRfid2::onPulseTimer(lv_timer_t* t) { - auto* self = static_cast(lv_timer_get_user_data(t)); - if (self->cardShown_ || !self->circle_) return; +void testUnitRfid2Stop(TestUnitRfid2* self) { + if (self->timer_) { lv_timer_delete(self->timer_); self->timer_ = nullptr; } + if (self->pulseTimer_) { lv_timer_delete(self->pulseTimer_); self->pulseTimer_ = nullptr; } + if (self->usingPaHub_ && self->hub_.isPresent()) self->hub_.deselect(); - self->pulseOpa_ = static_cast(self->pulseOpa_ + self->pulseDir_); - if (self->pulseOpa_ >= 255) { - self->pulseOpa_ = 255; - self->pulseDir_ = -8; - } else if (self->pulseOpa_ <= 180) { - self->pulseOpa_ = 180; - self->pulseDir_ = 8; - } - lv_obj_set_style_bg_opa(self->circle_, self->pulseOpa_, 0); -} - -// --------------------------------------------------------------------------- -// Clear button callback -// --------------------------------------------------------------------------- - -void TestUnitRfid2::onClear(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); self->cardShown_ = false; - lv_obj_remove_flag(self->idleGroup_, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(self->cardGroup_, LV_OBJ_FLAG_HIDDEN); + self->idleGroup_ = self->circle_ = nullptr; + self->cardGroup_ = self->lblUid_ = self->lblType_ = self->lblSak_ = nullptr; } diff --git a/Apps/M5UnitTest/main/Source/TestUnitRfid2.h b/Apps/M5UnitTest/main/Source/TestUnitRfid2.h index f41c914..5854afa 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitRfid2.h +++ b/Apps/M5UnitTest/main/Source/TestUnitRfid2.h @@ -2,14 +2,11 @@ #include "TestViewBase.h" #include #include -#include -class TestUnitRfid2 final : public TestViewBase { -public: - void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override; - void onStop() override; +struct Context; -private: +struct TestUnitRfid2 { + Context* app_ = nullptr; UnitPaHub hub_; UnitRfid2 unit_; lv_timer_t* timer_ = nullptr; @@ -34,11 +31,7 @@ class TestUnitRfid2 final : public TestViewBase { // Stored card info UnitRfid2::Uid lastUid_ = {}; UnitRfid2::CardType cardType_ = UnitRfid2::CardType::Unknown; - - void selectIfNeeded(); - void showCard(const UnitRfid2::Uid& uid); - - static void onTimer(lv_timer_t* t); - static void onPulseTimer(lv_timer_t* t); - static void onClear(lv_event_t* e); }; + +void testUnitRfid2Start(TestUnitRfid2* self, lv_obj_t* parent, Context* app); +void testUnitRfid2Stop(TestUnitRfid2* self); diff --git a/Apps/M5UnitTest/main/Source/TestUnitScroll.cpp b/Apps/M5UnitTest/main/Source/TestUnitScroll.cpp index 57a0977..d3f4e37 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitScroll.cpp +++ b/Apps/M5UnitTest/main/Source/TestUnitScroll.cpp @@ -1,14 +1,54 @@ #include "TestUnitScroll.h" +#include "M5UnitTest.h" #include "GroveLookup.h" #include "UiScale.h" #include +#include #include -void TestUnitScroll::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) { - app_ = app; +namespace { - createToolbar(parent, handle, "Scroll"); - createBanner(parent, "Scroll", "I2C", COLOR_I2C); +void selectIfNeeded(TestUnitScroll* self) { + if (self->usingPaHub_ && self->hub_.isPresent()) + self->hub_.select(self->hub_.currentChannel()); +} + +void update(TestUnitScroll* self) { + selectIfNeeded(self); + if (!self->unit_.isPresent()) return; + self->counter_ -= self->unit_.readDelta(); + lv_label_set_text_fmt(self->lblCounter_, "Counter: %ld", (long)self->counter_); + lv_label_set_text_fmt(self->lblButton_, "Button: %s", self->unit_.isPressed() ? "PRESSED" : "released"); +} + +void updateLedFromSliders(TestUnitScroll* self) { + selectIfNeeded(self); + if (!self->unit_.isPresent()) return; + uint32_t r = (uint32_t)lv_slider_get_value(self->sliderR_); + uint32_t g = (uint32_t)lv_slider_get_value(self->sliderG_); + uint32_t b = (uint32_t)lv_slider_get_value(self->sliderB_); + uint32_t rgb = (r << 16) | (g << 8) | b; + self->unit_.setLed(rgb); + lv_label_set_text_fmt(self->lblLed_, "LED: #%06lX", (unsigned long)rgb); +} + +void onTimer(lv_timer_t* t) { + auto* self = static_cast(lv_timer_get_user_data(t)); + if (window_manager_get_state(self->app_->window) != WINDOW_STATE_GRANTED) return; + update(self); +} + +void onSliderChanged(lv_event_t* e) { + updateLedFromSliders(static_cast(lv_event_get_user_data(e))); +} + +} // namespace + +void testUnitScrollStart(TestUnitScroll* self, lv_obj_t* parent, Context* app) { + self->app_ = app; + + testViewCreateToolbar(parent, app, "Scroll"); + testViewCreateBanner(parent, "Scroll", "I2C", COLOR_I2C); lv_obj_t* cont = lv_obj_create(parent); lv_obj_set_width(cont, LV_PCT(100)); @@ -22,14 +62,14 @@ void TestUnitScroll::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app const lv_font_t* fnt = lvgl_get_text_font(uiFont()); - lblCounter_ = lv_label_create(cont); - lv_obj_set_style_text_font(lblCounter_, lvgl_get_text_font(uiW() < 200 ? FONT_SIZE_DEFAULT : FONT_SIZE_LARGE), 0); + self->lblCounter_ = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblCounter_, lvgl_get_text_font(uiW() < 200 ? FONT_SIZE_DEFAULT : FONT_SIZE_LARGE), 0); - lblButton_ = lv_label_create(cont); - lv_obj_set_style_text_font(lblButton_, fnt, 0); + self->lblButton_ = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblButton_, fnt, 0); - lblLed_ = lv_label_create(cont); - lv_obj_set_style_text_font(lblLed_, fnt, 0); + self->lblLed_ = lv_label_create(cont); + lv_obj_set_style_text_font(self->lblLed_, fnt, 0); // RGB sliders auto makeSlider = [&](const char* name) -> lv_obj_t* { @@ -50,80 +90,48 @@ void TestUnitScroll::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app lv_slider_set_range(sl, 0, 255); lv_slider_set_value(sl, 0, LV_ANIM_OFF); lv_obj_set_flex_grow(sl, 1); - lv_obj_add_event_cb(sl, onSliderChanged, LV_EVENT_VALUE_CHANGED, this); + lv_obj_add_event_cb(sl, onSliderChanged, LV_EVENT_VALUE_CHANGED, self); return sl; }; - sliderR_ = makeSlider("R"); - sliderG_ = makeSlider("G"); - sliderB_ = makeSlider("B"); - lv_label_set_text(lblLed_, "LED: #000000"); + self->sliderR_ = makeSlider("R"); + self->sliderG_ = makeSlider("G"); + self->sliderB_ = makeSlider("B"); + lv_label_set_text(self->lblLed_, "LED: #000000"); Device* i2c = findGroveI2cDevice(); if (!i2c) { - lv_label_set_text(lblCounter_, "grove0_i2c not found"); + lv_label_set_text(self->lblCounter_, "grove0_i2c not found"); return; } - if (unit_.begin(i2c)) { - usingPaHub_ = false; - } else if (hub_.begin(i2c)) { - usingPaHub_ = true; + if (self->unit_.begin(i2c)) { + self->usingPaHub_ = false; + } else if (self->hub_.begin(i2c)) { + self->usingPaHub_ = true; bool found = false; for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) { - hub_.select(ch); - if (unit_.begin(i2c)) found = true; + self->hub_.select(ch); + if (self->unit_.begin(i2c)) found = true; } if (!found) { - hub_.deselect(); - lv_label_set_text(lblCounter_, "Scroll not found"); + self->hub_.deselect(); + lv_label_set_text(self->lblCounter_, "Scroll not found"); return; } } else { - lv_label_set_text(lblCounter_, "Scroll not found"); + lv_label_set_text(self->lblCounter_, "Scroll not found"); return; } - timer_ = lv_timer_create(onTimer, 50, this); - update(); -} - -void TestUnitScroll::onStop() { - if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; } - selectIfNeeded(); - if (unit_.isPresent()) unit_.setLed(0x000000); - if (usingPaHub_ && hub_.isPresent()) hub_.deselect(); - lblCounter_ = lblButton_ = lblLed_ = nullptr; - sliderR_ = sliderG_ = sliderB_ = nullptr; -} - -void TestUnitScroll::selectIfNeeded() { - if (usingPaHub_ && hub_.isPresent()) - hub_.select(hub_.currentChannel()); -} - -void TestUnitScroll::onTimer(lv_timer_t* t) { - static_cast(lv_timer_get_user_data(t))->update(); -} - -void TestUnitScroll::onSliderChanged(lv_event_t* e) { - static_cast(lv_event_get_user_data(e))->updateLedFromSliders(); + self->timer_ = lv_timer_create(onTimer, 50, self); + update(self); } -void TestUnitScroll::update() { - selectIfNeeded(); - if (!unit_.isPresent()) return; - counter_ -= unit_.readDelta(); - lv_label_set_text_fmt(lblCounter_, "Counter: %ld", (long)counter_); - lv_label_set_text_fmt(lblButton_, "Button: %s", unit_.isPressed() ? "PRESSED" : "released"); -} - -void TestUnitScroll::updateLedFromSliders() { - selectIfNeeded(); - if (!unit_.isPresent()) return; - uint32_t r = (uint32_t)lv_slider_get_value(sliderR_); - uint32_t g = (uint32_t)lv_slider_get_value(sliderG_); - uint32_t b = (uint32_t)lv_slider_get_value(sliderB_); - uint32_t rgb = (r << 16) | (g << 8) | b; - unit_.setLed(rgb); - lv_label_set_text_fmt(lblLed_, "LED: #%06lX", (unsigned long)rgb); +void testUnitScrollStop(TestUnitScroll* self) { + if (self->timer_) { lv_timer_delete(self->timer_); self->timer_ = nullptr; } + selectIfNeeded(self); + if (self->unit_.isPresent()) self->unit_.setLed(0x000000); + if (self->usingPaHub_ && self->hub_.isPresent()) self->hub_.deselect(); + self->lblCounter_ = self->lblButton_ = self->lblLed_ = nullptr; + self->sliderR_ = self->sliderG_ = self->sliderB_ = nullptr; } diff --git a/Apps/M5UnitTest/main/Source/TestUnitScroll.h b/Apps/M5UnitTest/main/Source/TestUnitScroll.h index 9448245..7bb5a97 100644 --- a/Apps/M5UnitTest/main/Source/TestUnitScroll.h +++ b/Apps/M5UnitTest/main/Source/TestUnitScroll.h @@ -3,12 +3,10 @@ #include #include -class TestUnitScroll final : public TestViewBase { -public: - void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override; - void onStop() override; +struct Context; -private: +struct TestUnitScroll { + Context* app_ = nullptr; UnitPaHub hub_; UnitScroll unit_; lv_obj_t* lblCounter_ = nullptr; @@ -20,10 +18,7 @@ class TestUnitScroll final : public TestViewBase { lv_timer_t* timer_ = nullptr; int32_t counter_ = 0; bool usingPaHub_ = false; - - void selectIfNeeded(); - static void onTimer(lv_timer_t* t); - static void onSliderChanged(lv_event_t* e); - void update(); - void updateLedFromSliders(); }; + +void testUnitScrollStart(TestUnitScroll* self, lv_obj_t* parent, Context* app); +void testUnitScrollStop(TestUnitScroll* self); diff --git a/Apps/M5UnitTest/main/Source/TestViewBase.cpp b/Apps/M5UnitTest/main/Source/TestViewBase.cpp index 46e30ce..0949fcb 100644 --- a/Apps/M5UnitTest/main/Source/TestViewBase.cpp +++ b/Apps/M5UnitTest/main/Source/TestViewBase.cpp @@ -4,14 +4,20 @@ #include #include -lv_obj_t* TestViewBase::createToolbar(lv_obj_t* parent, AppHandle handle, const char* title) { +static void onBackClicked(lv_event_t* e) { + auto* app = static_cast(lv_event_get_user_data(e)); + if (!app) return; + m5UnitTestShowList(app); +} + +lv_obj_t* testViewCreateToolbar(lv_obj_t* parent, Context* app, const char* title) { lv_obj_t* toolbar = lvgl_toolbar_create(parent, title); - lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LEFT, onBackClicked, this); + lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LEFT, onBackClicked, app); return toolbar; } -void TestViewBase::createBanner(lv_obj_t* parent, const char* unitName, - const char* ifaceBadge, lv_color_t accentColor) { +void testViewCreateBanner(lv_obj_t* parent, const char* unitName, + const char* ifaceBadge, lv_color_t accentColor) { lv_coord_t bannerH = uiH() < 200 ? 16 : 22; lv_obj_t* banner = lv_obj_create(parent); @@ -54,13 +60,3 @@ void TestViewBase::createBanner(lv_obj_t* parent, const char* unitName, lv_obj_set_style_text_font(badgeLabel, lvgl_get_text_font(FONT_SIZE_SMALL), 0); lv_obj_center(badgeLabel); } - -void TestViewBase::onBackClicked(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (!self || !self->app_) return; - self->onStop(); - M5UnitTest* app = self->app_; - app->clearActiveTestView(); - delete self; - app->showList(); -} diff --git a/Apps/M5UnitTest/main/Source/TestViewBase.h b/Apps/M5UnitTest/main/Source/TestViewBase.h index c377652..36a4d98 100644 --- a/Apps/M5UnitTest/main/Source/TestViewBase.h +++ b/Apps/M5UnitTest/main/Source/TestViewBase.h @@ -1,34 +1,19 @@ #pragma once -#include #include -class M5UnitTest; +struct Context; -// Minimal interface shared by all test views. -// To return to the list, concrete views call showList() on the owning M5UnitTest. -// M5UnitTest calls onStop() then deletes the view after the Back button is tapped. -class TestViewBase { -public: - virtual ~TestViewBase() = default; - virtual void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) = 0; - virtual void onStop() = 0; +// Accent colors matching M5Stack product image palette +constexpr lv_color_t COLOR_I2C = LV_COLOR_MAKE(0x1A, 0x6E, 0xC8); // M5 blue +constexpr lv_color_t COLOR_GPIO = LV_COLOR_MAKE(0xC0, 0x20, 0x20); // red +constexpr lv_color_t COLOR_UART = LV_COLOR_MAKE(0x20, 0x90, 0x50); // green -protected: - M5UnitTest* app_ = nullptr; +// Creates a standard toolbar with a Back button that stops the active test view (if any) and +// returns to the list. +lv_obj_t* testViewCreateToolbar(lv_obj_t* parent, Context* app, const char* title); - // Accent colors matching M5Stack product image palette - static constexpr lv_color_t COLOR_I2C = LV_COLOR_MAKE(0x1A, 0x6E, 0xC8); // M5 blue - static constexpr lv_color_t COLOR_GPIO = LV_COLOR_MAKE(0xC0, 0x20, 0x20); // red - static constexpr lv_color_t COLOR_UART = LV_COLOR_MAKE(0x20, 0x90, 0x50); // green - - // Creates a standard toolbar with a Back button that returns to the list. - lv_obj_t* createToolbar(lv_obj_t* parent, AppHandle handle, const char* title); - - // Creates a colored identity banner strip below the toolbar. - // ifaceBadge: short string e.g. "I2C", "GPIO", "UART" - void createBanner(lv_obj_t* parent, const char* unitName, - const char* ifaceBadge, lv_color_t accentColor); - - static void onBackClicked(lv_event_t* e); -}; +// Creates a colored identity banner strip below the toolbar. +// ifaceBadge: short string e.g. "I2C", "GPIO", "UART" +void testViewCreateBanner(lv_obj_t* parent, const char* unitName, + const char* ifaceBadge, lv_color_t accentColor); diff --git a/Apps/M5UnitTest/main/Source/main.cpp b/Apps/M5UnitTest/main/Source/main.cpp index 20fef43..a4f22da 100644 --- a/Apps/M5UnitTest/main/Source/main.cpp +++ b/Apps/M5UnitTest/main/Source/main.cpp @@ -1,10 +1,42 @@ #include "M5UnitTest.h" -#include + +#include +#include +#include + +#include extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + Context ctx; + ctx.appInstanceId = app_instance_id; + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, m5UnitTestCreateWidgets, &ctx); + ctx.window = window; + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(app_instance_id); + should_close = true; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + m5UnitTestTeardown(&ctx); + return 0; } diff --git a/tactility.py b/tactility.py index 769de53..547f08f 100644 --- a/tactility.py +++ b/tactility.py @@ -12,7 +12,7 @@ from urllib.parse import urlparse ttbuild_path = ".tactility" -ttbuild_version = "4.1.0" +ttbuild_version = "4.2.0" ttbuild_cdn = "https://cdn.tactilityproject.org" ttbuild_sdk_json_validity = 3600 # seconds ttport = 6666 @@ -20,6 +20,10 @@ use_local_sdk = False local_base_path = None http_timeout_seconds = 10 +# App install uploads the whole package over HTTP and the device only responds once it's +# fully received, extracted and registered - large packages (e.g. bundled fonts/assets) can +# easily take well over http_timeout_seconds on a slow SD card, so give it a lot more room. +install_timeout_seconds = 120 shell_color_red = "\033[91m" shell_color_orange = "\033[93m" @@ -586,7 +590,7 @@ def install_action(ip, platforms): files = { 'elf': file } - response = requests.put(url, files=files, timeout=http_timeout_seconds) + response = requests.put(url, files=files, timeout=install_timeout_seconds) if response.status_code != 200: print_status_error("Install failed") return False From 531847bd0df7bdfb0c25659041f8a379713bc53c Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Mon, 10 Aug 2026 00:59:25 +0200 Subject: [PATCH 5/7] Second batch of migrated apps --- Apps/Magic8Ball/CMakeLists.txt | 10 +- Apps/Magic8Ball/main/CMakeLists.txt | 3 - Apps/Magic8Ball/main/Source/Magic8Ball.cpp | 107 +-- Apps/Magic8Ball/main/Source/Magic8Ball.h | 23 +- Apps/Magic8Ball/main/Source/main.cpp | 36 +- Apps/MediaKeys/CMakeLists.txt | 10 +- Apps/MediaKeys/main/CMakeLists.txt | 3 - Apps/MediaKeys/main/Source/MediaKeys.cpp | 363 ++++---- Apps/MediaKeys/main/Source/MediaKeys.h | 66 +- Apps/MediaKeys/main/Source/main.cpp | 41 +- Apps/MystifyDemo/CMakeLists.txt | 10 +- Apps/MystifyDemo/main/CMakeLists.txt | 4 +- .../main/Include/drivers/DisplayDriver.h | 2 +- Apps/MystifyDemo/main/Source/Main.cpp | 66 +- Apps/SerialConsole/CMakeLists.txt | 10 +- Apps/SerialConsole/main/CMakeLists.txt | 3 - .../SerialConsole/main/Source/ConnectView.cpp | 206 +++++ Apps/SerialConsole/main/Source/ConnectView.h | 174 +--- .../SerialConsole/main/Source/ConsoleView.cpp | 305 +++++++ Apps/SerialConsole/main/Source/ConsoleView.h | 299 +------ .../main/Source/SerialConsole.cpp | 108 ++- .../SerialConsole/main/Source/SerialConsole.h | 41 +- Apps/SerialConsole/main/Source/View.h | 6 - Apps/SerialConsole/main/Source/main.cpp | 43 +- Apps/Snake/CMakeLists.txt | 10 +- Apps/Snake/main/CMakeLists.txt | 3 - Apps/Snake/main/Source/Snake.cpp | 373 ++++---- Apps/Snake/main/Source/Snake.h | 66 +- Apps/Snake/main/Source/main.cpp | 79 +- Apps/TamaTac/CMakeLists.txt | 10 +- Apps/TamaTac/main/CMakeLists.txt | 2 +- Apps/TamaTac/main/Source/Achievements.cpp | 121 ++- Apps/TamaTac/main/Source/Achievements.h | 41 +- Apps/TamaTac/main/Source/CemeteryView.cpp | 125 ++- Apps/TamaTac/main/Source/CemeteryView.h | 26 +- Apps/TamaTac/main/Source/MainView.cpp | 837 +++++++++--------- Apps/TamaTac/main/Source/MainView.h | 39 +- Apps/TamaTac/main/Source/MenuView.cpp | 113 ++- Apps/TamaTac/main/Source/MenuView.h | 31 +- Apps/TamaTac/main/Source/PatternGame.cpp | 447 +++++----- Apps/TamaTac/main/Source/PatternGame.h | 53 +- Apps/TamaTac/main/Source/PetLogic.cpp | 106 ++- Apps/TamaTac/main/Source/ReactionGame.cpp | 320 +++---- Apps/TamaTac/main/Source/ReactionGame.h | 39 +- Apps/TamaTac/main/Source/SettingsView.cpp | 178 ++-- Apps/TamaTac/main/Source/SettingsView.h | 35 +- Apps/TamaTac/main/Source/StatsView.cpp | 110 +-- Apps/TamaTac/main/Source/StatsView.h | 21 +- Apps/TamaTac/main/Source/TamaTac.cpp | 726 ++++++++------- Apps/TamaTac/main/Source/TamaTac.h | 175 ++-- Apps/TamaTac/main/Source/main.cpp | 71 +- Apps/TodoList/CMakeLists.txt | 10 +- Apps/TodoList/main/CMakeLists.txt | 3 - Apps/TodoList/main/Source/TodoList.cpp | 342 +++---- Apps/TodoList/main/Source/TodoList.h | 39 +- Apps/TodoList/main/Source/main.cpp | 40 +- Apps/TwoEleven/CMakeLists.txt | 10 +- Apps/TwoEleven/main/CMakeLists.txt | 3 - Apps/TwoEleven/main/Source/TwoEleven.cpp | 398 ++++----- Apps/TwoEleven/main/Source/TwoEleven.h | 64 +- Apps/TwoEleven/main/Source/main.cpp | 78 +- 61 files changed, 3811 insertions(+), 3272 deletions(-) create mode 100644 Apps/SerialConsole/main/Source/ConnectView.cpp create mode 100644 Apps/SerialConsole/main/Source/ConsoleView.cpp delete mode 100644 Apps/SerialConsole/main/Source/View.h diff --git a/Apps/Magic8Ball/CMakeLists.txt b/Apps/Magic8Ball/CMakeLists.txt index 798ec19..25ab8be 100644 --- a/Apps/Magic8Ball/CMakeLists.txt +++ b/Apps/Magic8Ball/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(Magic8Ball) tactility_project(Magic8Ball) diff --git a/Apps/Magic8Ball/main/CMakeLists.txt b/Apps/Magic8Ball/main/CMakeLists.txt index 0309010..3d04446 100644 --- a/Apps/Magic8Ball/main/CMakeLists.txt +++ b/Apps/Magic8Ball/main/CMakeLists.txt @@ -4,8 +4,5 @@ file(GLOB_RECURSE SOURCE_FILES idf_component_register( SRCS ${SOURCE_FILES} - # Library headers must be included directly, - # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include REQUIRES TactilitySDK ) diff --git a/Apps/Magic8Ball/main/Source/Magic8Ball.cpp b/Apps/Magic8Ball/main/Source/Magic8Ball.cpp index 940b67c..4d4115e 100644 --- a/Apps/Magic8Ball/main/Source/Magic8Ball.cpp +++ b/Apps/Magic8Ball/main/Source/Magic8Ball.cpp @@ -5,9 +5,11 @@ #include #include +namespace { + /* ── Responses ─────────────────────────────────────────────────────── */ -static const char* responses[] = { +const char* responses[] = { /* Affirmative (10) */ "It is certain.", "It is decidedly so.", @@ -35,43 +37,43 @@ static const char* responses[] = { #define NUM_RESPONSES (sizeof(responses) / sizeof(responses[0])) -static const char* getInputHint() { +const char* getInputHint() { if (device_has_active_by_type(&KEYBOARD_TYPE)) { return "Touch or Space to ask Q to exit"; } return "Touch the ball to ask"; } -/* ── Methods ──────────────────────────────────────────────────────── */ +/* ── Behavior ─────────────────────────────────────────────────────── */ -void Magic8Ball::revealAnswer() { - if (!seeded) { +void revealAnswer(Context* ctx) { + if (!ctx->seeded) { srand((unsigned)time(NULL)); - seeded = true; + ctx->seeded = true; } int idx; do { idx = rand() % NUM_RESPONSES; - } while (idx == lastIdx && NUM_RESPONSES > 1); - lastIdx = idx; + } while (idx == ctx->lastIdx && NUM_RESPONSES > 1); + ctx->lastIdx = idx; - lv_label_set_text(answerLabel, responses[idx]); - lv_label_set_text(hintLabel, getInputHint()); + lv_label_set_text(ctx->answerLabel, responses[idx]); + lv_label_set_text(ctx->hintLabel, getInputHint()); } -void Magic8Ball::onBallClick(lv_event_t* e) { - auto* self = (Magic8Ball*)lv_event_get_user_data(e); - self->revealAnswer(); +void onBallClick(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + revealAnswer(ctx); } -void Magic8Ball::onKey(lv_event_t* e) { - auto* self = (Magic8Ball*)lv_event_get_user_data(e); +void onKey(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); uint32_t key = lv_event_get_key(e); switch (key) { case LV_KEY_ENTER: case ' ': - self->revealAnswer(); + revealAnswer(ctx); break; case LV_KEY_ESC: case 'q': @@ -86,9 +88,13 @@ void Magic8Ball::onKey(lv_event_t* e) { } } +} // namespace + /* ── Lifecycle ────────────────────────────────────────────────────── */ -void Magic8Ball::onShow(AppHandle app, lv_obj_t* parent) { +void magic8BallCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); @@ -111,52 +117,53 @@ void Magic8Ball::onShow(AppHandle app, lv_obj_t* parent) { lv_obj_set_style_radius(cont, 0, 0); /* "8" ball circle */ - ballObj = lv_obj_create(cont); - lv_obj_set_size(ballObj, 120, 120); - lv_obj_set_style_radius(ballObj, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_color(ballObj, lv_color_make(0x10, 0x10, 0x30), 0); - lv_obj_set_style_bg_opa(ballObj, LV_OPA_COVER, 0); - lv_obj_set_style_border_color(ballObj, lv_color_make(0x40, 0x40, 0x80), 0); - lv_obj_set_style_border_width(ballObj, 3, 0); - lv_obj_remove_flag(ballObj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_flex_flow(ballObj, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(ballObj, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + ctx->ballObj = lv_obj_create(cont); + lv_obj_set_size(ctx->ballObj, 120, 120); + lv_obj_set_style_radius(ctx->ballObj, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(ctx->ballObj, lv_color_make(0x10, 0x10, 0x30), 0); + lv_obj_set_style_bg_opa(ctx->ballObj, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(ctx->ballObj, lv_color_make(0x40, 0x40, 0x80), 0); + lv_obj_set_style_border_width(ctx->ballObj, 3, 0); + lv_obj_remove_flag(ctx->ballObj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(ctx->ballObj, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(ctx->ballObj, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); /* Answer text inside the ball */ - answerLabel = lv_label_create(ballObj); - lv_label_set_text(answerLabel, "8"); - lv_obj_set_style_text_color(answerLabel, lv_color_hex(0xFFFFFF), 0); - lv_obj_set_style_text_font(answerLabel, lv_font_get_default(), 0); - lv_obj_set_style_text_align(answerLabel, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_width(answerLabel, 100); - lv_label_set_long_mode(answerLabel, LV_LABEL_LONG_WRAP); + ctx->answerLabel = lv_label_create(ctx->ballObj); + lv_label_set_text(ctx->answerLabel, "8"); + lv_obj_set_style_text_color(ctx->answerLabel, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_text_font(ctx->answerLabel, lv_font_get_default(), 0); + lv_obj_set_style_text_align(ctx->answerLabel, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_width(ctx->answerLabel, 100); + lv_label_set_long_mode(ctx->answerLabel, LV_LABEL_LONG_WRAP); /* Hint text below the ball */ - hintLabel = lv_label_create(cont); - lv_label_set_text(hintLabel, getInputHint()); - lv_obj_set_style_text_color(hintLabel, lv_color_make(0x88, 0x88, 0x88), 0); - lv_obj_set_style_text_font(hintLabel, lv_font_get_default(), 0); + ctx->hintLabel = lv_label_create(cont); + lv_label_set_text(ctx->hintLabel, getInputHint()); + lv_obj_set_style_text_color(ctx->hintLabel, lv_color_make(0x88, 0x88, 0x88), 0); + lv_obj_set_style_text_font(ctx->hintLabel, lv_font_get_default(), 0); /* Make the ball tappable / also space */ - lv_obj_add_flag(ballObj, LV_OBJ_FLAG_CLICKABLE); - lv_obj_add_event_cb(ballObj, onBallClick, LV_EVENT_CLICKED, this); + lv_obj_add_flag(ctx->ballObj, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(ctx->ballObj, onBallClick, LV_EVENT_CLICKED, ctx); /* Keyboard support - no editing mode needed, just focus the ball */ if (device_has_active_by_type(&KEYBOARD_TYPE)) { lv_group_t* grp = lv_group_get_default(); if (grp) { - lv_group_add_obj(grp, ballObj); - lv_group_focus_obj(ballObj); + lv_group_add_obj(grp, ctx->ballObj); + lv_group_focus_obj(ctx->ballObj); } - lv_obj_add_event_cb(ballObj, onKey, LV_EVENT_KEY, this); + lv_obj_add_event_cb(ctx->ballObj, onKey, LV_EVENT_KEY, ctx); } } -void Magic8Ball::onHide(AppHandle app) { - if (device_has_active_by_type(&KEYBOARD_TYPE) && ballObj) { - lv_group_remove_obj(ballObj); - } - answerLabel = nullptr; - hintLabel = nullptr; - ballObj = nullptr; +void magic8BallTeardown(Context* ctx) { + // Don't touch ctx->ballObj here: by the time this runs, window_manager_remove() has already + // deleted the widget tree, and LVGL detaches a deleted object from its group automatically + // as part of that deletion (see lvgl-module keyboard.cpp's comment on obj_delete_core()'s + // ordering) - calling lv_group_remove_obj() on it here would be a use-after-free. + ctx->answerLabel = nullptr; + ctx->hintLabel = nullptr; + ctx->ballObj = nullptr; } diff --git a/Apps/Magic8Ball/main/Source/Magic8Ball.h b/Apps/Magic8Ball/main/Source/Magic8Ball.h index 9ed54d8..7434ef9 100644 --- a/Apps/Magic8Ball/main/Source/Magic8Ball.h +++ b/Apps/Magic8Ball/main/Source/Magic8Ball.h @@ -1,12 +1,14 @@ #pragma once +#include +#include #include -#include -class Magic8Ball final : public App { +struct Context { + AppInstanceId appInstanceId = 0; + WindowId window = 0; -private: - // UI pointers (nulled in onHide) + // UI pointers (nulled in teardown) lv_obj_t* answerLabel = nullptr; lv_obj_t* hintLabel = nullptr; lv_obj_t* ballObj = nullptr; @@ -14,13 +16,10 @@ class Magic8Ball final : public App { // State int lastIdx = -1; bool seeded = false; +}; - void revealAnswer(); - - static void onBallClick(lv_event_t* e); - static void onKey(lv_event_t* e); +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void magic8BallCreateWidgets(lv_obj_t* parent, void* userData); -public: - void onShow(AppHandle context, lv_obj_t* parent) override; - void onHide(AppHandle context) override; -}; +/** Releases widget-tracking state. Call once, after the window has been torn down. */ +void magic8BallTeardown(Context* ctx); diff --git a/Apps/Magic8Ball/main/Source/main.cpp b/Apps/Magic8Ball/main/Source/main.cpp index 3501506..9bef9da 100644 --- a/Apps/Magic8Ball/main/Source/main.cpp +++ b/Apps/Magic8Ball/main/Source/main.cpp @@ -1,10 +1,42 @@ #include "Magic8Ball.h" -#include + +#include +#include +#include + +#include extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + Context ctx; + ctx.appInstanceId = app_instance_id; + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, magic8BallCreateWidgets, &ctx); + ctx.window = window; + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(app_instance_id); + should_close = true; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + magic8BallTeardown(&ctx); + return 0; } diff --git a/Apps/MediaKeys/CMakeLists.txt b/Apps/MediaKeys/CMakeLists.txt index 8d31bb0..577f588 100644 --- a/Apps/MediaKeys/CMakeLists.txt +++ b/Apps/MediaKeys/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(MediaKeys) tactility_project(MediaKeys) diff --git a/Apps/MediaKeys/main/CMakeLists.txt b/Apps/MediaKeys/main/CMakeLists.txt index f0ab1bb..3067e0d 100644 --- a/Apps/MediaKeys/main/CMakeLists.txt +++ b/Apps/MediaKeys/main/CMakeLists.txt @@ -4,8 +4,5 @@ idf_component_register( SRCS ${SOURCE_FILES} - # Library headers must be included directly, - # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include REQUIRES TactilitySDK ) diff --git a/Apps/MediaKeys/main/Source/MediaKeys.cpp b/Apps/MediaKeys/main/Source/MediaKeys.cpp index e55a7bb..057416b 100644 --- a/Apps/MediaKeys/main/Source/MediaKeys.cpp +++ b/Apps/MediaKeys/main/Source/MediaKeys.cpp @@ -8,16 +8,18 @@ #include #include -static const char* TAG = "MediaKeys"; +namespace { + +const char* TAG = "MediaKeys"; // Button layout: two rows of three media-control buttons -static const char* BTN_MAP[] = { +const char* BTN_MAP[] = { LV_SYMBOL_PREV, LV_SYMBOL_PLAY, LV_SYMBOL_NEXT, "\n", LV_SYMBOL_MUTE, LV_SYMBOL_VOLUME_MID, LV_SYMBOL_VOLUME_MAX, "" }; // Physical key → button matrix index mapping (B=prev, P=play, N=next, M=mute, D=vol-, U=vol+) -static const struct { uint32_t key; uint32_t btnIdx; } KEY_MAP[] = { +const struct { uint32_t key; uint32_t btnIdx; } KEY_MAP[] = { { 'b', 0 }, { 'B', 0 }, { 'p', 1 }, { 'P', 1 }, { 'n', 2 }, { 'N', 2 }, @@ -27,7 +29,7 @@ static const struct { uint32_t key; uint32_t btnIdx; } KEY_MAP[] = { }; // HID Consumer Page (0x0C) usage codes for each button, in BTN_MAP order -static const uint16_t CONSUMER_USAGE[6] = { +const uint16_t CONSUMER_USAGE[6] = { 0x00B6, // 0 – PREV: Previous Track 0x00CD, // 1 – PLAY: Play/Pause 0x00B5, // 2 – NEXT: Next Track @@ -42,9 +44,14 @@ struct SendKeyData { uint16_t usage; }; +void enterKeyMode(Context* ctx); +void exitKeyMode(Context* ctx); +void startHid(Context* ctx); +void handleButtonPress(Context* ctx, uint32_t buttonId); + // ---- Static task / callback implementations ---- -void MediaKeys::sendKeyTask(void* param) { +void sendKeyTask(void* param) { SendKeyData* data = static_cast(param); uint8_t press[2] = { @@ -62,92 +69,104 @@ void MediaKeys::sendKeyTask(void* param) { vTaskDelete(nullptr); } -void MediaKeys::onSwitchToggled(lv_event_t* event) { +void onSwitchToggled(lv_event_t* event); +void onButtonPressed(lv_event_t* event); +void onKeyEvent(lv_event_t* event); +void onKeyHighlightTimer(lv_timer_t* t); +void btEventCallback(struct Device* device, void* context, struct BtEvent event); +void handleSwitchToggle(Context* ctx, bool enabled); + +void onSwitchToggled(lv_event_t* event) { if (lv_event_get_code(event) != LV_EVENT_VALUE_CHANGED) return; - MediaKeys* self = static_cast(lv_event_get_user_data(event)); - if (!self) return; + auto* ctx = static_cast(lv_event_get_user_data(event)); + if (!ctx) return; bool enabled = lv_obj_has_state(lv_event_get_target_obj(event), LV_STATE_CHECKED); - self->handleSwitchToggle(enabled); + handleSwitchToggle(ctx, enabled); } -void MediaKeys::onButtonPressed(lv_event_t* event) { +void onButtonPressed(lv_event_t* event) { if (lv_event_get_code(event) != LV_EVENT_VALUE_CHANGED) return; - MediaKeys* self = static_cast(lv_event_get_user_data(event)); - if (!self) return; + auto* ctx = static_cast(lv_event_get_user_data(event)); + if (!ctx) return; uint32_t id = lv_btnmatrix_get_selected_btn(lv_event_get_target_obj(event)); if (id != LV_BTNMATRIX_BTN_NONE) { - self->handleButtonPress(id); + handleButtonPress(ctx, id); } } -void MediaKeys::onKeyEvent(lv_event_t* event) { +void onKeyEvent(lv_event_t* event) { if (lv_event_get_code(event) != LV_EVENT_KEY) return; - MediaKeys* self = static_cast(lv_event_get_user_data(event)); - if (!self || !self->_buttonMatrix) return; + auto* ctx = static_cast(lv_event_get_user_data(event)); + if (!ctx || !ctx->buttonMatrix) return; uint32_t key = lv_event_get_key(event); // Q or Esc exits key mode and returns focus to normal UI navigation if (key == 'q' || key == 'Q' || key == LV_KEY_ESC) { - self->exitKeyMode(); + exitKeyMode(ctx); return; } - if (!self->_isEnabled) return; + if (!ctx->isEnabled) return; for (auto& mapping : KEY_MAP) { if (mapping.key == key) { // Highlight: select the button and mark checked for visual feedback - self->_activeKeyBtn = mapping.btnIdx; - lv_buttonmatrix_set_selected_button(self->_buttonMatrix, mapping.btnIdx); - lv_buttonmatrix_set_button_ctrl(self->_buttonMatrix, mapping.btnIdx, LV_BTNMATRIX_CTRL_CHECKED); + ctx->activeKeyBtn = mapping.btnIdx; + lv_buttonmatrix_set_selected_button(ctx->buttonMatrix, mapping.btnIdx); + lv_buttonmatrix_set_button_ctrl(ctx->buttonMatrix, mapping.btnIdx, LV_BTNMATRIX_CTRL_CHECKED); // Restart the highlight-clear timer - if (self->_keyHighlightTimer) { - lv_timer_reset(self->_keyHighlightTimer); - lv_timer_resume(self->_keyHighlightTimer); + if (ctx->keyHighlightTimer) { + lv_timer_reset(ctx->keyHighlightTimer); + lv_timer_resume(ctx->keyHighlightTimer); } - self->handleButtonPress(mapping.btnIdx); + handleButtonPress(ctx, mapping.btnIdx); return; } } } -void MediaKeys::enterKeyMode() { - if (_keyboardActive || !_buttonMatrix) return; +void enterKeyMode(Context* ctx) { + if (ctx->keyboardActive || !ctx->buttonMatrix) return; lv_group_t* group = lv_group_get_default(); if (!group) return; - lv_group_add_obj(group, _buttonMatrix); - lv_group_focus_obj(_buttonMatrix); + lv_group_add_obj(group, ctx->buttonMatrix); + lv_group_focus_obj(ctx->buttonMatrix); lv_group_set_editing(group, true); - _keyboardActive = true; + ctx->keyboardActive = true; LOG_I(TAG, "Key mode: ON (Q/Esc to exit)"); } -void MediaKeys::exitKeyMode() { - if (!_keyboardActive || !_buttonMatrix) return; +void exitKeyMode(Context* ctx) { + if (!ctx->keyboardActive || !ctx->buttonMatrix) return; lv_group_t* group = lv_group_get_default(); if (group) lv_group_set_editing(group, false); - lv_group_remove_obj(_buttonMatrix); - _keyboardActive = false; + lv_group_remove_obj(ctx->buttonMatrix); + ctx->keyboardActive = false; LOG_I(TAG, "Key mode: OFF"); } -void MediaKeys::onKeyHighlightTimer(lv_timer_t* t) { - MediaKeys* self = static_cast(lv_timer_get_user_data(t)); - if (!self || !self->_buttonMatrix) return; - if (self->_activeKeyBtn != LV_BTNMATRIX_BTN_NONE) { - lv_buttonmatrix_clear_button_ctrl(self->_buttonMatrix, self->_activeKeyBtn, LV_BTNMATRIX_CTRL_CHECKED); - self->_activeKeyBtn = LV_BTNMATRIX_BTN_NONE; +void onKeyHighlightTimer(lv_timer_t* t) { + auto* ctx = static_cast(lv_timer_get_user_data(t)); + if (!ctx || !ctx->buttonMatrix) return; + // Widgets only exist while this window is topmost - skip otherwise (same reasoning as + // GPIO.cpp's periodic status timer: window_manager deletes a buried window's widgets, so + // touching buttonMatrix here would use-after-free it). The timer itself isn't destroyed by + // burial, only by mediaKeysTeardown(), so it can still fire while buried. + if (window_manager_get_state(ctx->window) != WINDOW_STATE_GRANTED) return; + if (ctx->activeKeyBtn != LV_BTNMATRIX_BTN_NONE) { + lv_buttonmatrix_clear_button_ctrl(ctx->buttonMatrix, ctx->activeKeyBtn, LV_BTNMATRIX_CTRL_CHECKED); + ctx->activeKeyBtn = LV_BTNMATRIX_BTN_NONE; } - lv_obj_remove_state(self->_buttonMatrix, LV_STATE_FOCUSED); + lv_obj_remove_state(ctx->buttonMatrix, LV_STATE_FOCUSED); lv_timer_pause(t); } -void MediaKeys::btEventCallback(struct Device* /*device*/, void* context, struct BtEvent event) { - MediaKeys* self = static_cast(context); - if (!self) return; +void btEventCallback(struct Device* /*device*/, void* context, struct BtEvent event) { + auto* ctx = static_cast(context); + if (!ctx) return; if (event.type == BT_EVENT_RADIO_STATE_CHANGED) { LOG_I(TAG, "BT radio state: %d", (int)event.radio_state); @@ -155,22 +174,27 @@ void MediaKeys::btEventCallback(struct Device* /*device*/, void* context, struct if (event.radio_state == BT_RADIO_STATE_ON) { // Radio is now up - start HID (needs LVGL lock for UI update) if (lvgl_try_lock(1000)) { - // Re-check inside lock to avoid TOCTOU race with handleSwitchToggle(false) - if (self->_radioEnabling) { - self->startHid(); + // Re-check inside lock to avoid TOCTOU race with handleSwitchToggle(false). + // Also skip touching widgets if this window has been buried in the meantime + // (window_manager may have already deleted them) - same reasoning as + // onKeyHighlightTimer above. + if (ctx->radioEnabling && window_manager_get_state(ctx->window) == WINDOW_STATE_GRANTED) { + startHid(ctx); } lvgl_unlock(); } - } else if (event.radio_state == BT_RADIO_STATE_OFF && self->_isEnabled) { + } else if (event.radio_state == BT_RADIO_STATE_OFF && ctx->isEnabled) { // Radio dropped while we were active - revert UI LOG_I(TAG, "BT radio turned off, disabling HID"); if (lvgl_try_lock(1000)) { - if (device_has_active_by_type(&KEYBOARD_TYPE)) self->exitKeyMode(); - self->_hidDevice = nullptr; - self->_isEnabled = false; - self->_radioEnabling = false; - if (self->_switchWidget) lv_obj_remove_state(self->_switchWidget, LV_STATE_CHECKED); - if (self->_mainWrapper) lv_obj_add_flag(self->_mainWrapper, LV_OBJ_FLAG_HIDDEN); + if (window_manager_get_state(ctx->window) == WINDOW_STATE_GRANTED) { + if (device_has_active_by_type(&KEYBOARD_TYPE)) exitKeyMode(ctx); + if (ctx->switchWidget) lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED); + if (ctx->mainWrapper) lv_obj_add_flag(ctx->mainWrapper, LV_OBJ_FLAG_HIDDEN); + } + ctx->hidDevice = nullptr; + ctx->isEnabled = false; + ctx->radioEnabling = false; lvgl_unlock(); } } @@ -179,201 +203,226 @@ void MediaKeys::btEventCallback(struct Device* /*device*/, void* context, struct } } -// ---- Instance methods ---- +// ---- Instance-equivalent functions ---- -void MediaKeys::startHid() { +void startHid(Context* ctx) { // Called once the BT radio is confirmed ON (either already was, or just came up). // May be called from the BT event callback thread - LVGL must already be locked by caller. - _radioEnabling = false; + ctx->radioEnabling = false; - _hidDevice = bluetooth_hid_device_get_device(); - if (!_hidDevice) { + ctx->hidDevice = bluetooth_hid_device_get_device(); + if (!ctx->hidDevice) { LOG_E(TAG, "BLE HID device unavailable after radio on"); - if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback); - _btDevice = nullptr; - _isEnabled = false; - if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED); + if (ctx->btDevice) { + bluetooth_remove_event_callback(ctx->btDevice, btEventCallback); + device_put(ctx->btDevice); + ctx->btDevice = nullptr; + } + ctx->isEnabled = false; + if (ctx->switchWidget) lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED); return; } - error_t err = bluetooth_hid_device_start(_hidDevice, BT_HID_DEVICE_MODE_KEYBOARD); + error_t err = bluetooth_hid_device_start(ctx->hidDevice, BT_HID_DEVICE_MODE_KEYBOARD); if (err != ERROR_NONE) { LOG_E(TAG, "Failed to start HID device: %d", (int)err); - if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback); - _btDevice = nullptr; - _hidDevice = nullptr; - _isEnabled = false; - if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED); + if (ctx->btDevice) { + bluetooth_remove_event_callback(ctx->btDevice, btEventCallback); + device_put(ctx->btDevice); + ctx->btDevice = nullptr; + } + ctx->hidDevice = nullptr; + ctx->isEnabled = false; + if (ctx->switchWidget) lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED); return; } - if (_mainWrapper) lv_obj_remove_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN); - if (device_has_active_by_type(&KEYBOARD_TYPE)) enterKeyMode(); + if (ctx->mainWrapper) lv_obj_remove_flag(ctx->mainWrapper, LV_OBJ_FLAG_HIDDEN); + if (device_has_active_by_type(&KEYBOARD_TYPE)) enterKeyMode(ctx); } -void MediaKeys::teardownBt() { +void teardownBt(Context* ctx) { // Remove callback FIRST - stops any in-flight BT events from firing against // our (possibly already freed) UI widget pointers after this returns. - if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback); + if (ctx->btDevice) bluetooth_remove_event_callback(ctx->btDevice, btEventCallback); // Do NOT call bluetooth_hid_device_stop here: it calls ble_gatts_reset() / // ble_gatts_start() which corrupts NimBLE heap while the host task is still // running. HID device is a persistent kernel device; hid_device_start() cleans // up stale context on next use. Explicit stop is handled by handleSwitchToggle. // Restore the radio/device to the state we found them in. - if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false); - if (_btDevice && _deviceWasStarted) device_stop(_btDevice); - _btDevice = nullptr; - _hidDevice = nullptr; - _radioWasOff = false; - _deviceWasStarted = false; + if (ctx->btDevice && ctx->radioWasOff) bluetooth_set_radio_enabled(ctx->btDevice, false); + if (ctx->btDevice && ctx->deviceWasStarted) device_stop(ctx->btDevice); + if (ctx->btDevice) device_put(ctx->btDevice); + ctx->btDevice = nullptr; + ctx->hidDevice = nullptr; + ctx->radioWasOff = false; + ctx->deviceWasStarted = false; } -void MediaKeys::handleSwitchToggle(bool enabled) { +void handleSwitchToggle(Context* ctx, bool enabled) { LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF"); - _isEnabled = enabled; + ctx->isEnabled = enabled; if (enabled) { - _btDevice = device_find_first_by_type(&BLUETOOTH_TYPE); - if (!_btDevice) { + if (device_get_first_by_type(&BLUETOOTH_TYPE, &ctx->btDevice) != ERROR_NONE) { LOG_E(TAG, "No Bluetooth device found"); - _isEnabled = false; - if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED); + ctx->btDevice = nullptr; + ctx->isEnabled = false; + if (ctx->switchWidget) lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED); return; } // Device may not be started yet (BT disabled in DTS by default to save memory). - if (!device_is_ready(_btDevice)) { + if (!device_is_ready(ctx->btDevice)) { LOG_I(TAG, "BT device not started, starting now"); - if (device_start(_btDevice) != ERROR_NONE) { + if (device_start(ctx->btDevice) != ERROR_NONE) { LOG_E(TAG, "Failed to start BT device"); - _btDevice = nullptr; - _isEnabled = false; - if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED); + device_put(ctx->btDevice); + ctx->btDevice = nullptr; + ctx->isEnabled = false; + if (ctx->switchWidget) lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED); return; } - _deviceWasStarted = true; + ctx->deviceWasStarted = true; } - bluetooth_set_device_name(_btDevice, "Tactility Media Keys"); + bluetooth_set_device_name(ctx->btDevice, "Tactility Media Keys"); // Register callback before enabling radio so we don't miss the state-change event. - bluetooth_add_event_callback(_btDevice, this, btEventCallback); + bluetooth_add_event_callback(ctx->btDevice, ctx, btEventCallback); enum BtRadioState radioState; - bluetooth_get_radio_state(_btDevice, &radioState); + bluetooth_get_radio_state(ctx->btDevice, &radioState); if (radioState == BT_RADIO_STATE_ON) { // Radio already up - start HID immediately. - _radioWasOff = false; - startHid(); + ctx->radioWasOff = false; + startHid(ctx); } else { // Turn the radio on; startHid() will be called from btEventCallback // once BT_RADIO_STATE_ON fires. LOG_I(TAG, "BT radio not on (state=%d), enabling...", (int)radioState); - _radioWasOff = true; - _radioEnabling = true; - bluetooth_set_radio_enabled(_btDevice, true); + ctx->radioWasOff = true; + ctx->radioEnabling = true; + bluetooth_set_radio_enabled(ctx->btDevice, true); } } else { - _radioEnabling = false; - if (device_has_active_by_type(&KEYBOARD_TYPE)) exitKeyMode(); + ctx->radioEnabling = false; + if (device_has_active_by_type(&KEYBOARD_TYPE)) exitKeyMode(ctx); // Explicit user toggle-off: stop HID cleanly (safe here since we're on the // LVGL task and the user intentionally disabled, so no race with app teardown). - if (_hidDevice) bluetooth_hid_device_stop(_hidDevice); - teardownBt(); - if (_mainWrapper) lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN); + if (ctx->hidDevice) bluetooth_hid_device_stop(ctx->hidDevice); + teardownBt(ctx); + if (ctx->mainWrapper) lv_obj_add_flag(ctx->mainWrapper, LV_OBJ_FLAG_HIDDEN); } } -void MediaKeys::handleButtonPress(uint32_t buttonId) { - if (!_hidDevice || !_isEnabled || buttonId >= 6) return; +void handleButtonPress(Context* ctx, uint32_t buttonId) { + if (!ctx->hidDevice || !ctx->isEnabled || buttonId >= 6) return; - if (!bluetooth_hid_device_is_connected(_hidDevice)) { + if (!bluetooth_hid_device_is_connected(ctx->hidDevice)) { LOG_W(TAG, "Not connected, ignoring button %lu", buttonId); return; } LOG_I(TAG, "Button %lu pressed", buttonId); - SendKeyData* data = new SendKeyData {_hidDevice, CONSUMER_USAGE[buttonId]}; + SendKeyData* data = new SendKeyData {ctx->hidDevice, CONSUMER_USAGE[buttonId]}; if (xTaskCreate(sendKeyTask, "bt_key", 4096, data, tskIDLE_PRIORITY + 1, nullptr) != pdPASS) { LOG_E(TAG, "Failed to create send task"); delete data; } } -void MediaKeys::onShow(AppHandle appHandle, lv_obj_t* parent) { - _parent = parent; +} // namespace + +void mediaKeysCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Media Keys"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); - _switchWidget = lvgl_toolbar_add_switch_action(toolbar); - lv_obj_add_event_cb(_switchWidget, onSwitchToggled, LV_EVENT_VALUE_CHANGED, this); - - _mainWrapper = lv_obj_create(parent); - lv_obj_set_style_bg_color(_mainWrapper, lv_palette_darken(LV_PALETTE_GREY, 4), LV_PART_MAIN); - lv_obj_set_width(_mainWrapper, LV_PCT(100)); - lv_obj_set_flex_grow(_mainWrapper, 1); - lv_obj_set_style_pad_all(_mainWrapper, 4, LV_PART_MAIN); - lv_obj_set_style_border_width(_mainWrapper, 0, LV_PART_MAIN); - lv_obj_remove_flag(_mainWrapper, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_flex_flow(_mainWrapper, LV_FLEX_FLOW_COLUMN); - - _buttonMatrix = lv_btnmatrix_create(_mainWrapper); - lv_btnmatrix_set_map(_buttonMatrix, BTN_MAP); - lv_obj_set_style_text_font(_buttonMatrix, lvgl_get_text_font(FONT_SIZE_LARGE), LV_PART_ITEMS); - lv_obj_set_style_pad_all(_buttonMatrix, 5, LV_PART_MAIN); - lv_obj_set_style_pad_row(_buttonMatrix, 5, LV_PART_MAIN); - lv_obj_set_style_pad_column(_buttonMatrix, 5, LV_PART_MAIN); - lv_obj_set_style_border_width(_buttonMatrix, 0, LV_PART_MAIN); - lv_obj_set_style_bg_opa(_buttonMatrix, 0, LV_PART_MAIN); + ctx->switchWidget = lvgl_toolbar_add_switch_action(toolbar); + lv_obj_add_event_cb(ctx->switchWidget, onSwitchToggled, LV_EVENT_VALUE_CHANGED, ctx); + + ctx->mainWrapper = lv_obj_create(parent); + lv_obj_set_style_bg_color(ctx->mainWrapper, lv_palette_darken(LV_PALETTE_GREY, 4), LV_PART_MAIN); + lv_obj_set_width(ctx->mainWrapper, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->mainWrapper, 1); + lv_obj_set_style_pad_all(ctx->mainWrapper, 4, LV_PART_MAIN); + lv_obj_set_style_border_width(ctx->mainWrapper, 0, LV_PART_MAIN); + lv_obj_remove_flag(ctx->mainWrapper, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(ctx->mainWrapper, LV_FLEX_FLOW_COLUMN); + + ctx->buttonMatrix = lv_btnmatrix_create(ctx->mainWrapper); + lv_btnmatrix_set_map(ctx->buttonMatrix, BTN_MAP); + lv_obj_set_style_text_font(ctx->buttonMatrix, lvgl_get_text_font(FONT_SIZE_LARGE), LV_PART_ITEMS); + lv_obj_set_style_pad_all(ctx->buttonMatrix, 5, LV_PART_MAIN); + lv_obj_set_style_pad_row(ctx->buttonMatrix, 5, LV_PART_MAIN); + lv_obj_set_style_pad_column(ctx->buttonMatrix, 5, LV_PART_MAIN); + lv_obj_set_style_border_width(ctx->buttonMatrix, 0, LV_PART_MAIN); + lv_obj_set_style_bg_opa(ctx->buttonMatrix, 0, LV_PART_MAIN); if (lv_display_get_horizontal_resolution(nullptr) <= 240 || lv_display_get_vertical_resolution(nullptr) <= 240) { - lv_obj_set_size(_buttonMatrix, lv_pct(100), lv_pct(70)); + lv_obj_set_size(ctx->buttonMatrix, lv_pct(100), lv_pct(70)); } else { - lv_obj_set_size(_buttonMatrix, lv_pct(100), lv_pct(85)); + lv_obj_set_size(ctx->buttonMatrix, lv_pct(100), lv_pct(85)); } - lv_obj_set_flex_grow(_buttonMatrix, 1); + lv_obj_set_flex_grow(ctx->buttonMatrix, 1); - lv_obj_add_event_cb(_buttonMatrix, onButtonPressed, LV_EVENT_VALUE_CHANGED, this); + lv_obj_add_event_cb(ctx->buttonMatrix, onButtonPressed, LV_EVENT_VALUE_CHANGED, ctx); // Physical keyboard support: key events on the matrix (entered when BT enabled, Q/Esc exits) if (device_has_active_by_type(&KEYBOARD_TYPE)) { - lv_obj_add_event_cb(_buttonMatrix, onKeyEvent, LV_EVENT_KEY, this); - _keyHighlightTimer = lv_timer_create(onKeyHighlightTimer, 150, this); - lv_timer_pause(_keyHighlightTimer); + lv_obj_add_event_cb(ctx->buttonMatrix, onKeyEvent, LV_EVENT_KEY, ctx); + ctx->keyHighlightTimer = lv_timer_create(onKeyHighlightTimer, 150, ctx); + lv_timer_pause(ctx->keyHighlightTimer); } - lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(ctx->mainWrapper, LV_OBJ_FLAG_HIDDEN); // Auto-enable if BT is already on (turned on via QuickPanel/Settings before opening app). - struct Device* btDev = device_find_first_by_type(&BLUETOOTH_TYPE); - if (btDev && device_is_ready(btDev)) { - enum BtRadioState radioState; - if (bluetooth_get_radio_state(btDev, &radioState) == ERROR_NONE && radioState == BT_RADIO_STATE_ON) { - lv_obj_add_state(_switchWidget, LV_STATE_CHECKED); - handleSwitchToggle(true); + // Transient lookup only - handleSwitchToggle() acquires its own reference into ctx->btDevice. + struct Device* btDev = nullptr; + if (device_get_first_by_type(&BLUETOOTH_TYPE, &btDev) == ERROR_NONE) { + if (device_is_ready(btDev)) { + enum BtRadioState radioState; + if (bluetooth_get_radio_state(btDev, &radioState) == ERROR_NONE && radioState == BT_RADIO_STATE_ON) { + lv_obj_add_state(ctx->switchWidget, LV_STATE_CHECKED); + handleSwitchToggle(ctx, true); + } } + device_put(btDev); } } -void MediaKeys::onHide(AppHandle /*appHandle*/) { - _radioEnabling = false; - _isEnabled = false; - if (device_has_active_by_type(&KEYBOARD_TYPE)) exitKeyMode(); - teardownBt(); - if (_keyHighlightTimer) { - lv_timer_delete(_keyHighlightTimer); - _keyHighlightTimer = nullptr; +void mediaKeysTeardown(Context* ctx) { + ctx->radioEnabling = false; + ctx->isEnabled = false; + // Don't call exitKeyMode() here: by the time this runs, window_manager_remove() has already + // deleted buttonMatrix, and LVGL detaches a deleted object from its group automatically as + // part of that deletion - calling lv_group_remove_obj() on it here would be a use-after-free + // (same reasoning as Magic8Ball.cpp's teardown). Just clear editing mode on the default + // group directly, since that's independent of buttonMatrix's lifetime. + if (ctx->keyboardActive) { + lv_group_t* group = lv_group_get_default(); + if (group) lv_group_set_editing(group, false); + ctx->keyboardActive = false; + } + teardownBt(ctx); + // keyHighlightTimer is an app-owned lv_timer_t, not part of the window's widget subtree, so + // window_manager_remove() never touches it - still safe (and necessary) to delete here. + if (ctx->keyHighlightTimer) { + lv_timer_delete(ctx->keyHighlightTimer); + ctx->keyHighlightTimer = nullptr; } - _activeKeyBtn = LV_BTNMATRIX_BTN_NONE; + ctx->activeKeyBtn = LV_BTNMATRIX_BTN_NONE; - _parent = nullptr; - _mainWrapper = nullptr; - _switchWidget = nullptr; - _buttonMatrix = nullptr; + ctx->mainWrapper = nullptr; + ctx->switchWidget = nullptr; + ctx->buttonMatrix = nullptr; } diff --git a/Apps/MediaKeys/main/Source/MediaKeys.h b/Apps/MediaKeys/main/Source/MediaKeys.h index 3fd70ff..177dac6 100644 --- a/Apps/MediaKeys/main/Source/MediaKeys.h +++ b/Apps/MediaKeys/main/Source/MediaKeys.h @@ -1,58 +1,40 @@ #pragma once -#include +#include +#include #include #include #include #include #include -#include #include -class MediaKeys final : public App { +struct Context { + AppInstanceId appInstanceId = 0; + WindowId window = 0; + // UI elements - lv_obj_t* _parent = nullptr; - lv_obj_t* _mainWrapper = nullptr; - lv_obj_t* _switchWidget = nullptr; - lv_obj_t* _buttonMatrix = nullptr; - lv_timer_t* _keyHighlightTimer = nullptr; - uint32_t _activeKeyBtn = LV_BTNMATRIX_BTN_NONE; - bool _keyboardActive = false; // true when matrix has focus group + editing mode + lv_obj_t* mainWrapper = nullptr; + lv_obj_t* switchWidget = nullptr; + lv_obj_t* buttonMatrix = nullptr; + lv_timer_t* keyHighlightTimer = nullptr; + uint32_t activeKeyBtn = LV_BTNMATRIX_BTN_NONE; + bool keyboardActive = false; // true when matrix has focus group + editing mode // HAL device handles - struct Device* _btDevice = nullptr; - struct Device* _hidDevice = nullptr; + struct Device* btDevice = nullptr; + struct Device* hidDevice = nullptr; // State - accessed from both LVGL thread and BT callback thread - std::atomic _isEnabled {false}; - std::atomic _radioEnabling {false}; // true while waiting for radio to come ON - std::atomic _radioWasOff {false}; // true if we turned the radio on (restore on exit) - std::atomic _deviceWasStarted{false}; // true if we called device_start (restore on exit) - - // Static event callbacks - static void onSwitchToggled(lv_event_t* e); - static void onButtonPressed(lv_event_t* e); - static void onKeyEvent(lv_event_t* e); - static void onKeyHighlightTimer(lv_timer_t* t); - static void btEventCallback(struct Device* device, void* context, struct BtEvent event); - static void sendKeyTask(void* param); - - // Instance methods called by static callbacks - void teardownBt(); // remove callback + stop HID + restore radio/device state - void handleSwitchToggle(bool enabled); - void handleButtonPress(uint32_t buttonId); - void startHid(); // called once radio is confirmed ON - void enterKeyMode(); - void exitKeyMode(); - -public: + std::atomic isEnabled {false}; + std::atomic radioEnabling {false}; // true while waiting for radio to come ON + std::atomic radioWasOff {false}; // true if we turned the radio on (restore on exit) + std::atomic deviceWasStarted{false}; // true if we called device_start (restore on exit) +}; - MediaKeys() = default; - MediaKeys(const MediaKeys&) = delete; - MediaKeys& operator=(const MediaKeys&) = delete; - MediaKeys(MediaKeys&&) = delete; - MediaKeys& operator=(MediaKeys&&) = delete; +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void mediaKeysCreateWidgets(lv_obj_t* parent, void* userData); - void onShow(AppHandle appHandle, lv_obj_t* parent) override; - void onHide(AppHandle appHandle) override; -}; +/** Removes the BT callback, stops HID, restores radio/device state, releases widget-tracking + * state. Call once, after the window has been torn down. */ +void mediaKeysTeardown(Context* ctx); diff --git a/Apps/MediaKeys/main/Source/main.cpp b/Apps/MediaKeys/main/Source/main.cpp index 7bcccde..6f8de03 100644 --- a/Apps/MediaKeys/main/Source/main.cpp +++ b/Apps/MediaKeys/main/Source/main.cpp @@ -1,10 +1,47 @@ #include "MediaKeys.h" -#include + +#include +#include +#include + +#include + +#include extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + // Heap-allocated: the BT event callback (bluetooth_add_event_callback) captures ctx's + // address for a background BT-stack thread to call back into, so it can't be a stack frame + // that goes away while that callback might still fire. + auto ctx = std::make_unique(); + ctx->appInstanceId = app_instance_id; + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, mediaKeysCreateWidgets, ctx.get()); + ctx->window = window; + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(app_instance_id); + should_close = true; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + mediaKeysTeardown(ctx.get()); + return 0; } diff --git a/Apps/MystifyDemo/CMakeLists.txt b/Apps/MystifyDemo/CMakeLists.txt index 5b66f32..c1fc97d 100644 --- a/Apps/MystifyDemo/CMakeLists.txt +++ b/Apps/MystifyDemo/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(MystifyDemo) tactility_project(MystifyDemo) diff --git a/Apps/MystifyDemo/main/CMakeLists.txt b/Apps/MystifyDemo/main/CMakeLists.txt index e8bdcd6..759aed7 100644 --- a/Apps/MystifyDemo/main/CMakeLists.txt +++ b/Apps/MystifyDemo/main/CMakeLists.txt @@ -2,8 +2,6 @@ file(GLOB_RECURSE SOURCE_FILES Source/*.c*) idf_component_register( SRC_DIRS "Source" - # Library headers must be included directly, - # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS "Include" "../../../Libraries/TactilityCpp/Include" + INCLUDE_DIRS "Include" REQUIRES TactilitySDK ) diff --git a/Apps/MystifyDemo/main/Include/drivers/DisplayDriver.h b/Apps/MystifyDemo/main/Include/drivers/DisplayDriver.h index 7ce0961..5e3916a 100644 --- a/Apps/MystifyDemo/main/Include/drivers/DisplayDriver.h +++ b/Apps/MystifyDemo/main/Include/drivers/DisplayDriver.h @@ -21,7 +21,7 @@ class DisplayDriver { device_put(device); } - bool lock(TickType_t timeout = tt::kernel::MAX_TICKS) const { + bool lock(TickType_t timeout = tt::kernel::FREERTOS_MAX_TICKS) const { return device_try_lock(device, timeout); } diff --git a/Apps/MystifyDemo/main/Source/Main.cpp b/Apps/MystifyDemo/main/Source/Main.cpp index 256f4fb..52a2e52 100644 --- a/Apps/MystifyDemo/main/Source/Main.cpp +++ b/Apps/MystifyDemo/main/Source/Main.cpp @@ -4,8 +4,9 @@ #include -#include -#include +#include +#include +#include #include #include @@ -16,22 +17,55 @@ constexpr auto TAG = "Main"; -static void onCreate(AppHandle appHandle, void* data) { +// Shows a blocking error dialog and waits for it to close (so the user actually gets to read it) +// before the caller finishes this app - this app never creates a window of its own, so there's +// nothing else keeping it around for the dialog to be seen against. +static void showErrorAndWait(AppInstanceId appInstanceId, const char* message) { + const char* argv[] = { "Error", message, "OK" }; + uint32_t dialogInstanceId = 0; + app_manager_start_for_result("AlertDialog", appInstanceId, 3, argv, &dialogInstanceId); + if (dialogInstanceId == 0) { + return; + } + + struct AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + while (true) { + struct AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_RESULT && event.result.launch_id == dialogInstanceId) { + app_manager_stop(dialogInstanceId); + break; + } + } + + app_event_unsubscribe(&sub); +} + +extern "C" { + +int main(int argc, char* argv[]) { + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + struct Device* display_device; if (device_get_first_active_by_type(&DISPLAY_TYPE, &display_device) != ERROR_NONE) { ESP_LOGE(TAG, "No display device found"); - tt_app_stop(); - tt_app_alertdialog_start("Error", "No display device was found.", nullptr, 0); - return; + showErrorAndWait(app_instance_id, "No display device was found."); + app_manager_finish(app_instance_id); + return 0; } struct Device* touch_device; if (device_get_first_active_by_type(&POINTER_TYPE, &touch_device) != ERROR_NONE) { ESP_LOGE(TAG, "No touch device found"); device_put(display_device); - tt_app_stop(); - tt_app_alertdialog_start("Error", "No touch device was found.", nullptr, 0); - return; + showErrorAndWait(app_instance_id, "No touch device was found."); + app_manager_finish(app_instance_id); + return 0; } // Stop LVGL first (because it's currently using the drivers we want to use) @@ -55,25 +89,15 @@ static void onCreate(AppHandle appHandle, void* data) { ESP_LOGI(TAG, "Cleanup touch driver"); delete touch; - ESP_LOGI(TAG, "Stopping application"); - tt_app_stop(); -} - -static void onDestroy(AppHandle appHandle, void* data) { // Restart LVGL to resume rendering of regular apps if (!module_is_started(&lvgl_module)) { ESP_LOGI(TAG, "Restarting LVGL"); module_start(&lvgl_module); } -} -extern "C" { + ESP_LOGI(TAG, "Stopping application"); + app_manager_finish(app_instance_id); -int main(int argc, char* argv[]) { - tt_app_register((AppRegistration) { - .onCreate = onCreate, - .onDestroy = onDestroy - }); return 0; } diff --git a/Apps/SerialConsole/CMakeLists.txt b/Apps/SerialConsole/CMakeLists.txt index 555fa97..035334d 100644 --- a/Apps/SerialConsole/CMakeLists.txt +++ b/Apps/SerialConsole/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(SerialConsole) tactility_project(SerialConsole) diff --git a/Apps/SerialConsole/main/CMakeLists.txt b/Apps/SerialConsole/main/CMakeLists.txt index 0f54d39..94136e8 100644 --- a/Apps/SerialConsole/main/CMakeLists.txt +++ b/Apps/SerialConsole/main/CMakeLists.txt @@ -4,9 +4,6 @@ file(GLOB_RECURSE SOURCE_FILES idf_component_register( SRCS ${SOURCE_FILES} - # Library headers must be included directly, - # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include REQUIRES TactilitySDK ) diff --git a/Apps/SerialConsole/main/Source/ConnectView.cpp b/Apps/SerialConsole/main/Source/ConnectView.cpp new file mode 100644 index 0000000..de5ab99 --- /dev/null +++ b/Apps/SerialConsole/main/Source/ConnectView.cpp @@ -0,0 +1,206 @@ +#include "ConnectView.h" +#include "SerialConsole.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +constexpr TickType_t LVGL_DEFAULT_LOCK_TIME = 500; // 500 ticks = 500 ms + +bool getPreferencesPath(std::string& outPath) { + char root[128]; + if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) { + return false; + } + outPath = std::string(root) + "/serial_console.properties"; + return true; +} + +std::string buildDeviceOptions(const std::vector& uartDevices) { + std::string output; + for (size_t i = 0; i < uartDevices.size(); i++) { + if (i > 0) output.append("\n"); + output.append(uartDevices[i]->name); + } + return output; +} + +int32_t getSpeedInput(ConnectViewState* state) { + auto* speed_text = lv_textarea_get_text(state->speedTextarea); + return atoi(speed_text); +} + +lv_obj_t* createRowWrapper(lv_obj_t* parent) { + auto* wrapper = lv_obj_create(parent); + lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(wrapper, 0, LV_STATE_DEFAULT); + return wrapper; +} + +// Fire-and-forget: nothing in this view needs to react once the user dismisses it. +void showError(Context* app, const char* message) { + const char* argv[] = { "Error", message, "OK" }; + uint32_t dialogInstanceId = 0; + app_manager_start_for_result("AlertDialog", app->appInstanceId, 3, argv, &dialogInstanceId); +} + +void onConnect(Context* app) { + ConnectViewState* state = &app->connectView; + + if (!lvgl_try_lock(LVGL_DEFAULT_LOCK_TIME)) { + return; + } + + uint32_t selected_index = lv_dropdown_get_selected(state->busDropdown); + if (selected_index >= state->uartDevices.size()) { + lvgl_unlock(); + showError(app, "No UART selected"); + return; + } + + int speed = getSpeedInput(state); + if (speed <= 0) { + lvgl_unlock(); + showError(app, "Invalid speed"); + return; + } + + Device* dev = state->uartDevices[selected_index]; + + UartConfig cfg = { + (uint32_t)speed, + UART_CONTROLLER_DATA_8_BITS, + UART_CONTROLLER_PARITY_DISABLE, + UART_CONTROLLER_STOP_BITS_1, + }; + if (uart_controller_set_config(dev, &cfg) != ERROR_NONE) { + lvgl_unlock(); + showError(app, "Failed to set baud rate"); + return; + } + + if (uart_controller_open(dev) != ERROR_NONE) { + lvgl_unlock(); + showError(app, "Failed to open UART"); + return; + } + + lvgl_unlock(); + showConsoleView(app, dev); +} + +void onConnectCallback(lv_event_t* event) { + auto* app = static_cast(lv_event_get_user_data(event)); + onConnect(app); +} + +} // namespace + +void connectViewCreate(lv_obj_t* parent, Context* app) { + ConnectViewState* state = &app->connectView; + + // Enumerate UART controller devices + state->uartDevices.clear(); + device_for_each_of_type(&UART_CONTROLLER_TYPE, &state->uartDevices, [](Device* d, void* ctx) -> bool { + static_cast*>(ctx)->push_back(d); + return true; + }); + + auto* wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(wrapper, 0, LV_STATE_DEFAULT); + + // Bus selection + + auto* bus_wrapper = createRowWrapper(wrapper); + + state->busDropdown = lv_dropdown_create(bus_wrapper); + + auto bus_options = buildDeviceOptions(state->uartDevices); + lv_dropdown_set_options(state->busDropdown, bus_options.empty() ? "none" : bus_options.c_str()); + lv_obj_align(state->busDropdown, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_width(state->busDropdown, LV_PCT(50)); + + std::string prefsPath; + int32_t bus_index = 0; + if (getPreferencesPath(prefsPath)) { + if (Preferences* prefs = preferences_open(prefsPath.c_str())) { + preferences_opt_int32(prefs, "bus", &bus_index); + preferences_close(prefs); + } + } + if (bus_index >= 0 && (size_t)bus_index < state->uartDevices.size()) { + lv_dropdown_set_selected(state->busDropdown, (uint32_t)bus_index); + } + + auto* bus_label = lv_label_create(bus_wrapper); + lv_obj_align(bus_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_label_set_text(bus_label, "Bus"); + + // Baud rate selection + auto* baud_wrapper = createRowWrapper(wrapper); + + int32_t speed = 115200; + if (!prefsPath.empty()) { + if (Preferences* prefs = preferences_open(prefsPath.c_str())) { + preferences_opt_int32(prefs, "speed", &speed); + preferences_close(prefs); + } + } + state->speedTextarea = lv_textarea_create(baud_wrapper); + lv_textarea_set_text(state->speedTextarea, std::to_string(speed).c_str()); + lv_textarea_set_one_line(state->speedTextarea, true); + lv_obj_set_width(state->speedTextarea, LV_PCT(50)); + lv_obj_align(state->speedTextarea, LV_ALIGN_TOP_RIGHT, 0, 0); + + auto* baud_rate_label = lv_label_create(baud_wrapper); + lv_obj_align(baud_rate_label, LV_ALIGN_TOP_LEFT, 0, 0); + lv_label_set_text(baud_rate_label, "Baud"); + + // Connect + auto* connect_wrapper = createRowWrapper(wrapper); + + auto* connect_button = lv_button_create(connect_wrapper); + lv_obj_align(connect_button, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(connect_button, onConnectCallback, LV_EVENT_SHORT_CLICKED, app); + auto* connect_label = lv_label_create(connect_button); + lv_label_set_text(connect_label, "Connect"); +} + +void connectViewStop(Context* app) { + ConnectViewState* state = &app->connectView; + + // busDropdown/speedTextarea only exist while this window is topmost - skip reading them + // otherwise (final app teardown runs this after window_manager_remove() already deleted + // them; same reasoning as GPIO.cpp's periodic status timer guard). + if (window_manager_get_state(app->window) != WINDOW_STATE_GRANTED) { + return; + } + + std::string prefsPath; + if (getPreferencesPath(prefsPath)) { + if (Preferences* prefs = preferences_open(prefsPath.c_str())) { + int speed = getSpeedInput(state); + if (speed > 0) { + preferences_put_int32(prefs, "speed", speed); + } + auto bus_index = static_cast(lv_dropdown_get_selected(state->busDropdown)); + preferences_put_int32(prefs, "bus", bus_index); + preferences_close(prefs); + } + } +} diff --git a/Apps/SerialConsole/main/Source/ConnectView.h b/Apps/SerialConsole/main/Source/ConnectView.h index 2bd3cb8..924cb6c 100644 --- a/Apps/SerialConsole/main/Source/ConnectView.h +++ b/Apps/SerialConsole/main/Source/ConnectView.h @@ -1,174 +1,20 @@ #pragma once -#include "View.h" - -#include -#include #include -#include - -#include -#include -#include -#include -#include - -constexpr TickType_t LVGL_DEFAULT_LOCK_TIME = 500; // 500 ticks = 500 ms - -class ConnectView final : public View { +#include -public: +struct Context; +struct Device; - typedef std::function OnConnectedFunction; +struct ConnectViewState { std::vector uartDevices; - Preferences preferences = Preferences("SerialConsole"); - LvglLock lvglLock; - -private: - - OnConnectedFunction onConnected; lv_obj_t* busDropdown = nullptr; lv_obj_t* speedTextarea = nullptr; +}; - std::string buildDeviceOptions() { - std::string output; - for (size_t i = 0; i < uartDevices.size(); i++) { - if (i > 0) output.append("\n"); - output.append(uartDevices[i]->name); - } - return output; - } - - int32_t getSpeedInput() const { - auto* speed_text = lv_textarea_get_text(speedTextarea); - return atoi(speed_text); - } - - void onConnect() { - auto lock = lvglLock.asScopedLock(); - if (!lock.lock(LVGL_DEFAULT_LOCK_TIME)) { - return; - } - - const char* alert_dialog_labels[] = { "OK" }; - - uint32_t selected_index = lv_dropdown_get_selected(busDropdown); - if (selected_index >= uartDevices.size()) { - tt_app_alertdialog_start("Error", "No UART selected", alert_dialog_labels, 1); - return; - } - - int speed = getSpeedInput(); - if (speed <= 0) { - tt_app_alertdialog_start("Error", "Invalid speed", alert_dialog_labels, 1); - return; - } - - Device* dev = uartDevices[selected_index]; - - UartConfig cfg = { - (uint32_t)speed, - UART_CONTROLLER_DATA_8_BITS, - UART_CONTROLLER_PARITY_DISABLE, - UART_CONTROLLER_STOP_BITS_1, - }; - if (uart_controller_set_config(dev, &cfg) != ERROR_NONE) { - tt_app_alertdialog_start("Error", "Failed to set baud rate", alert_dialog_labels, 1); - return; - } - - if (uart_controller_open(dev) != ERROR_NONE) { - tt_app_alertdialog_start("Error", "Failed to open UART", alert_dialog_labels, 1); - return; - } - - onConnected(dev); - } - - static void onConnectCallback(lv_event_t* event) { - auto* view = static_cast(lv_event_get_user_data(event)); - view->onConnect(); - } - - static lv_obj_t* createRowWrapper(lv_obj_t* parent) { - auto* wrapper = lv_obj_create(parent); - lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(wrapper, 0, LV_STATE_DEFAULT); - return wrapper; - } - -public: - - explicit ConnectView(OnConnectedFunction onConnected) : onConnected(std::move(onConnected)) {} - - void onStart(lv_obj_t* parent) { - // Enumerate UART controller devices - uartDevices.clear(); - device_for_each_of_type(&UART_CONTROLLER_TYPE, &uartDevices, [](Device* d, void* ctx) -> bool { - static_cast*>(ctx)->push_back(d); - return true; - }); - - auto* wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(wrapper, 0, LV_STATE_DEFAULT); - - // Bus selection - - auto* bus_wrapper = createRowWrapper(wrapper); - - busDropdown = lv_dropdown_create(bus_wrapper); - - auto bus_options = buildDeviceOptions(); - lv_dropdown_set_options(busDropdown, bus_options.empty() ? "none" : bus_options.c_str()); - lv_obj_align(busDropdown, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_set_width(busDropdown, LV_PCT(50)); - - int32_t bus_index = 0; - preferences.optInt32("bus", bus_index); - if (bus_index >= 0 && (size_t)bus_index < uartDevices.size()) { - lv_dropdown_set_selected(busDropdown, (uint32_t)bus_index); - } - - auto* bus_label = lv_label_create(bus_wrapper); - lv_obj_align(bus_label, LV_ALIGN_LEFT_MID, 0, 0); - lv_label_set_text(bus_label, "Bus"); - - // Baud rate selection - auto* baud_wrapper = createRowWrapper(wrapper); - - int32_t speed = 115200; - preferences.optInt32("speed", speed); - speedTextarea = lv_textarea_create(baud_wrapper); - lv_textarea_set_text(speedTextarea, std::to_string(speed).c_str()); - lv_textarea_set_one_line(speedTextarea, true); - lv_obj_set_width(speedTextarea, LV_PCT(50)); - lv_obj_align(speedTextarea, LV_ALIGN_TOP_RIGHT, 0, 0); - - auto* baud_rate_label = lv_label_create(baud_wrapper); - lv_obj_align(baud_rate_label, LV_ALIGN_TOP_LEFT, 0, 0); - lv_label_set_text(baud_rate_label, "Baud"); - - // Connect - auto* connect_wrapper = createRowWrapper(wrapper); - - auto* connect_button = lv_button_create(connect_wrapper); - lv_obj_align(connect_button, LV_ALIGN_CENTER, 0, 0); - lv_obj_add_event_cb(connect_button, onConnectCallback, LV_EVENT_SHORT_CLICKED, this); - auto* connect_label = lv_label_create(connect_button); - lv_label_set_text(connect_label, "Connect"); - } - - void onStop() override { - int speed = getSpeedInput(); - if (speed > 0) { - preferences.putInt32("speed", speed); - } +/** Builds the connect form into @a parent. On success (Connect pressed, UART opened), calls + * showConsoleView(app, dev). */ +void connectViewCreate(lv_obj_t* parent, Context* app); - auto bus_index = static_cast(lv_dropdown_get_selected(busDropdown)); - preferences.putInt32("bus", bus_index); - } -}; +/** Persists the current bus/speed selection to preferences. */ +void connectViewStop(Context* app); diff --git a/Apps/SerialConsole/main/Source/ConsoleView.cpp b/Apps/SerialConsole/main/Source/ConsoleView.cpp new file mode 100644 index 0000000..7d14263 --- /dev/null +++ b/Apps/SerialConsole/main/Source/ConsoleView.cpp @@ -0,0 +1,305 @@ +#include "ConsoleView.h" +#include "SerialConsole.h" + +#include +#include +#include + +#include +#include +#include + +#include + +namespace { + +constexpr auto* TAG = "SerialConsole"; + +bool isUartThreadInterrupted(ConsoleViewState* state) { + auto lock = state->mutex.asScopedLock(); + lock.lock(); + return state->uartThreadInterrupted; +} + +bool isViewThreadInterrupted(ConsoleViewState* state) { + auto lock = state->mutex.asScopedLock(); + lock.lock(); + return state->viewThreadInterrupted; +} + +void updateViews(Context* app) { + ConsoleViewState* state = &app->consoleView; + + if (state->parent == nullptr) { + return; + } + + // logTextarea only exists while this window is topmost - skip touching it otherwise + // (window_manager deletes a buried window's widgets, and this runs from a raw background + // thread that has no other way to know that happened; same reasoning as GPIO.cpp's periodic + // status timer guard). + if (window_manager_get_state(app->window) != WINDOW_STATE_GRANTED) { + return; + } + + // Updating the view is expensive, so we only want to set the text once: + // Gather all the lines in a single buffer + if (state->mutex.lock()) { + size_t first_part_size = receiveBufferSize - state->receiveBufferPosition; + memcpy(state->renderBuffer, state->receiveBuffer + state->receiveBufferPosition, first_part_size); + state->renderBuffer[state->receiveBufferPosition] = '\n'; + if (state->receiveBufferPosition > 0) { + memcpy(state->renderBuffer + first_part_size + 1, state->receiveBuffer, (receiveBufferSize - first_part_size)); + state->renderBuffer[receiveBufferSize - 1] = 0x00; + } + state->mutex.unlock(); + } + + if (lvgl_try_lock(tt::kernel::FREERTOS_MAX_TICKS)) { + lv_textarea_set_text(state->logTextarea, (const char*)state->renderBuffer); + lvgl_unlock(); + } +} + +int32_t viewThreadMain(Context* app) { + ConsoleViewState* state = &app->consoleView; + while (!isViewThreadInterrupted(state)) { + auto start_time = tt::kernel::getTicks(); + + updateViews(app); + + auto end_time = tt::kernel::getTicks(); + auto time_diff = end_time - start_time; + auto target_delay = tt::kernel::millisToTicks(500U); + if (time_diff < target_delay) { + tt::kernel::delayTicks(target_delay - time_diff); + } + } + + return 0; +} + +int32_t uartThreadMain(Context* app) { + ConsoleViewState* state = &app->consoleView; + while (!isUartThreadInterrupted(state)) { + uint8_t byte; + error_t err = uart_controller_read_byte(state->uartDev, &byte, tt::kernel::millisToTicks(50)); + + // Thread might've been interrupted in the meanwhile + if (isUartThreadInterrupted(state)) { + break; + } + + if (err == ERROR_NONE) { + state->mutex.lock(); + state->receiveBuffer[state->receiveBufferPosition++] = byte; + if (state->receiveBufferPosition == receiveBufferSize) { + state->receiveBufferPosition = 0; + } + state->mutex.unlock(); + } + } + + return 0; +} + +void onSendClicked(Context* app) { + ConsoleViewState* state = &app->consoleView; + + state->mutex.lock(); + std::string input_text = lv_textarea_get_text(state->inputTextarea); + std::string to_send; + to_send.append(input_text + state->terminatorString); + Device* localUart = state->uartDev; + state->mutex.unlock(); + + if (localUart != nullptr) { + error_t err = uart_controller_write_bytes( + localUart, + reinterpret_cast(to_send.c_str()), + to_send.length(), + tt::kernel::millisToTicks(100) + ); + if (err != ERROR_NONE) { + ESP_LOGE(TAG, "Failed to send \"%s\"", input_text.c_str()); + } + } + + lv_textarea_set_text(state->inputTextarea, ""); +} + +void onSendClickedCallback(lv_event_t* event) { + auto* app = static_cast(lv_event_get_user_data(event)); + onSendClicked(app); +} + +void onTerminatorDropDownValueChanged(Context* app, lv_event_t* event) { + ConsoleViewState* state = &app->consoleView; + auto* dropdown = static_cast(lv_event_get_target(event)); + state->mutex.lock(); + switch (lv_dropdown_get_selected(dropdown)) { + case 0: + state->terminatorString = "\n"; + break; + case 1: + state->terminatorString = "\r\n"; + break; + } + state->mutex.unlock(); +} + +void onTerminatorDropdownValueChangedCallback(lv_event_t* event) { + auto* app = static_cast(lv_event_get_user_data(event)); + onTerminatorDropDownValueChanged(app, event); +} + +void startLogic(Context* app, Device* dev) { + ConsoleViewState* state = &app->consoleView; + assert(dev != nullptr); + if (dev == nullptr) return; + + memset(state->receiveBuffer, 0, receiveBufferSize); + + assert(state->uartThread == nullptr); + assert(state->uartDev == nullptr); + + state->uartDev = dev; + + state->uartThreadInterrupted = false; + state->uartThread = std::make_unique( + "SerConsUart", + 4096, + [app] { return uartThreadMain(app); } + ); + state->uartThread->setPriority(tt::Thread::Priority::High); + state->uartThread->start(); +} + +// Builds the console widgets into @a parent. Safe to call more than once for the same session +// (e.g. this window was buried under another app and is now resurfacing) - only touches +// widget-tracking state, never the background threads, which keep running unaffected by burial +// (window_manager only ever destroys the widget tree, never app-owned threads). +void buildWidgets(Context* app, lv_obj_t* parent) { + ConsoleViewState* state = &app->consoleView; + state->parent = parent; + + lv_obj_set_style_pad_gap(parent, 2, 0); + + state->logTextarea = lv_textarea_create(parent); + lv_textarea_set_placeholder_text(state->logTextarea, "Waiting for data..."); + lv_obj_set_flex_grow(state->logTextarea, 1); + lv_obj_set_width(state->logTextarea, LV_PCT(100)); + lv_obj_add_state(state->logTextarea, LV_STATE_DISABLED); + lv_obj_set_style_margin_ver(state->logTextarea, 0, 0); + + auto* input_wrapper = lv_obj_create(parent); + lv_obj_set_size(input_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(input_wrapper, 0, 0); + lv_obj_set_style_border_width(input_wrapper, 0, 0); + lv_obj_set_width(input_wrapper, LV_PCT(100)); + lv_obj_set_flex_flow(input_wrapper, LV_FLEX_FLOW_ROW); + + state->inputTextarea = lv_textarea_create(input_wrapper); + lv_textarea_set_one_line(state->inputTextarea, true); + lv_textarea_set_placeholder_text(state->inputTextarea, "Text to send"); + lv_obj_set_width(state->inputTextarea, LV_PCT(100)); + lv_obj_set_flex_grow(state->inputTextarea, 1); + + auto* terminator_dropdown = lv_dropdown_create(input_wrapper); + lv_dropdown_set_options(terminator_dropdown, "\\n\n\\r\\n"); + lv_obj_set_width(terminator_dropdown, 70); + lv_obj_add_event_cb(terminator_dropdown, onTerminatorDropdownValueChangedCallback, LV_EVENT_VALUE_CHANGED, app); + + auto* button = lv_button_create(input_wrapper); + auto* button_label = lv_label_create(button); + lv_label_set_text(button_label, "Send"); + lv_obj_add_event_cb(button, onSendClickedCallback, LV_EVENT_SHORT_CLICKED, app); +} + +// Starts the periodic UI-refresh thread. Only call once per session (see buildWidgets()'s +// comment on why rebuilding widgets alone is enough on resurface). +void startViewThread(Context* app) { + ConsoleViewState* state = &app->consoleView; + state->viewThreadInterrupted = false; + state->viewThread = std::make_unique( + "SerConsView", + 4096, + [app] { return viewThreadMain(app); } + ); + state->viewThread->setPriority(tt::Thread::Priority::Higher); + state->viewThread->start(); +} + +void stopLogic(Context* app) { + ConsoleViewState* state = &app->consoleView; + auto lock = state->mutex.asScopedLock(); + lock.lock(); + + state->uartThreadInterrupted = true; + + // Detach thread, it will auto-delete when leaving the current scope + auto old_uart_thread = std::move(state->uartThread); + // Unlock so thread can lock + lock.unlock(); + + if (old_uart_thread->getState() != tt::Thread::State::Stopped) { + // Wait for thread to finish + old_uart_thread->join(); + } +} + +void stopViews(Context* app) { + ConsoleViewState* state = &app->consoleView; + auto lock = state->mutex.asScopedLock(); + lock.lock(); + + state->viewThreadInterrupted = true; + + // Detach thread, it will auto-delete when leaving the current scope + auto old_view_thread = std::move(state->viewThread); + + // Unlock so thread can lock + lock.unlock(); + + if (old_view_thread->getState() != tt::Thread::State::Stopped) { + // Wait for thread to finish + old_view_thread->join(); + } +} + +void stopUart(Context* app) { + ConsoleViewState* state = &app->consoleView; + auto lock = state->mutex.asScopedLock(); + lock.lock(); + + if (state->uartDev != nullptr) { + uart_controller_close(state->uartDev); + state->uartDev = nullptr; + } +} + +} // namespace + +void consoleViewCreate(lv_obj_t* parent, Context* app, Device* dev) { + ConsoleViewState* state = &app->consoleView; + auto lock = state->mutex.asScopedLock(); + lock.lock(); + + startLogic(app, dev); + buildWidgets(app, parent); + startViewThread(app); +} + +void consoleViewRebuildWidgets(lv_obj_t* parent, Context* app) { + ConsoleViewState* state = &app->consoleView; + auto lock = state->mutex.asScopedLock(); + lock.lock(); + + buildWidgets(app, parent); +} + +void consoleViewStop(Context* app) { + stopViews(app); + stopLogic(app); + stopUart(app); +} diff --git a/Apps/SerialConsole/main/Source/ConsoleView.h b/Apps/SerialConsole/main/Source/ConsoleView.h index 28686a9..55c83c0 100644 --- a/Apps/SerialConsole/main/Source/ConsoleView.h +++ b/Apps/SerialConsole/main/Source/ConsoleView.h @@ -1,288 +1,45 @@ #pragma once -#include "View.h" -#include "esp_log.h" - -#include -#include -#include - #include #include -#include -#include -#include +#include +#include +#include +#include + +struct Context; +struct Device; constexpr size_t receiveBufferSize = 512; constexpr size_t renderBufferSize = receiveBufferSize + 2; // Leave space for newline at split and null terminator at the end -class ConsoleView final : public View { - - const char* TAG = "SerialConsole"; - - lv_obj_t* _Nullable parent = nullptr; - lv_obj_t* _Nullable logTextarea = nullptr; - lv_obj_t* _Nullable inputTextarea = nullptr; +struct ConsoleViewState { + lv_obj_t* parent = nullptr; + lv_obj_t* logTextarea = nullptr; + lv_obj_t* inputTextarea = nullptr; Device* uartDev = nullptr; - std::unique_ptr uartThread _Nullable = nullptr; + std::unique_ptr uartThread; bool uartThreadInterrupted = false; - std::unique_ptr viewThread _Nullable = nullptr; + std::unique_ptr viewThread; bool viewThreadInterrupted = false; tt::RecursiveMutex mutex; - uint8_t receiveBuffer[receiveBufferSize]; - uint8_t renderBuffer[renderBufferSize]; + uint8_t receiveBuffer[receiveBufferSize] = {}; + uint8_t renderBuffer[renderBufferSize] = {}; size_t receiveBufferPosition = 0; std::string terminatorString = "\n"; +}; - LvglLock lvglLock; - - bool isUartThreadInterrupted() const { - auto lock = mutex.asScopedLock(); - lock.lock(); - return uartThreadInterrupted; - } - - bool isViewThreadInterrupted() const { - auto lock = mutex.asScopedLock(); - lock.lock(); - return viewThreadInterrupted; - } - - void updateViews() { - if (parent == nullptr) { - return; - } - - // Updating the view is expensive, so we only want to set the text once: - // Gather all the lines in a single buffer - if (mutex.lock()) { - size_t first_part_size = receiveBufferSize - receiveBufferPosition; - memcpy(renderBuffer, receiveBuffer + receiveBufferPosition, first_part_size); - renderBuffer[receiveBufferPosition] = '\n'; - if (receiveBufferPosition > 0) { - memcpy(renderBuffer + first_part_size + 1, receiveBuffer, (receiveBufferSize - first_part_size)); - renderBuffer[receiveBufferSize - 1] = 0x00; - } - mutex.unlock(); - } - - if (lvglLock.lock()) { - lv_textarea_set_text(logTextarea, (const char*)renderBuffer); - lvglLock.unlock(); - } - } - - int32_t viewThreadMain() { - while (!isViewThreadInterrupted()) { - auto start_time = tt::kernel::getTicks(); - - updateViews(); - - auto end_time = tt::kernel::getTicks(); - auto time_diff = end_time - start_time; - auto target_delay = tt::kernel::millisToTicks(500U); - if (time_diff < target_delay) { - tt::kernel::delayTicks(target_delay - time_diff); - } - } - - return 0; - } - - int32_t uartThreadMain() { - while (!isUartThreadInterrupted()) { - uint8_t byte; - error_t err = uart_controller_read_byte(uartDev, &byte, tt::kernel::millisToTicks(50)); - - // Thread might've been interrupted in the meanwhile - if (isUartThreadInterrupted()) { - break; - } - - if (err == ERROR_NONE) { - mutex.lock(); - receiveBuffer[receiveBufferPosition++] = byte; - if (receiveBufferPosition == receiveBufferSize) { - receiveBufferPosition = 0; - } - mutex.unlock(); - } - } - - return 0; - } - - static void onSendClickedCallback(lv_event_t* event) { - auto* view = (ConsoleView*)lv_event_get_user_data(event); - view->onSendClicked(); - } - - static void onTerminatorDropdownValueChangedCallback(lv_event_t* event) { - auto* view = (ConsoleView*)lv_event_get_user_data(event); - view->onTerminatorDropDownValueChanged(event); - } - - void onTerminatorDropDownValueChanged(lv_event_t* event) { - auto* dropdown = static_cast(lv_event_get_target(event)); - mutex.lock(); - switch (lv_dropdown_get_selected(dropdown)) { - case 0: - terminatorString = "\n"; - break; - case 1: - terminatorString = "\r\n"; - break; - } - mutex.unlock(); - } - - void onSendClicked() { - mutex.lock(); - std::string input_text = lv_textarea_get_text(inputTextarea); - std::string to_send; - to_send.append(input_text + terminatorString); - Device* localUart = uartDev; - mutex.unlock(); - - if (localUart != nullptr) { - error_t err = uart_controller_write_bytes( - localUart, - reinterpret_cast(to_send.c_str()), - to_send.length(), - tt::kernel::millisToTicks(100) - ); - if (err != ERROR_NONE) { - ESP_LOGE(TAG, "Failed to send \"%s\"", input_text.c_str()); - } - } - - lv_textarea_set_text(inputTextarea, ""); - } - -public: - - void startLogic(Device* dev) { - assert(dev != nullptr); - if (dev == nullptr) return; - - memset(receiveBuffer, 0, receiveBufferSize); - - assert(uartThread == nullptr); - assert(uartDev == nullptr); - - uartDev = dev; - - uartThreadInterrupted = false; - uartThread = std::make_unique( - "SerConsUart", - 4096, - [this] { return uartThreadMain(); } - ); - uartThread->setPriority(tt::Thread::Priority::High); - uartThread->start(); - } - - void startViews(lv_obj_t* parent) { - this->parent = parent; - - lv_obj_set_style_pad_gap(parent, 2, 0); - - logTextarea = lv_textarea_create(parent); - lv_textarea_set_placeholder_text(logTextarea, "Waiting for data..."); - lv_obj_set_flex_grow(logTextarea, 1); - lv_obj_set_width(logTextarea, LV_PCT(100)); - lv_obj_add_state(logTextarea, LV_STATE_DISABLED); - lv_obj_set_style_margin_ver(logTextarea, 0, 0); - - auto* input_wrapper = lv_obj_create(parent); - lv_obj_set_size(input_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(input_wrapper, 0, 0); - lv_obj_set_style_border_width(input_wrapper, 0, 0); - lv_obj_set_width(input_wrapper, LV_PCT(100)); - lv_obj_set_flex_flow(input_wrapper, LV_FLEX_FLOW_ROW); - - inputTextarea = lv_textarea_create(input_wrapper); - lv_textarea_set_one_line(inputTextarea, true); - lv_textarea_set_placeholder_text(inputTextarea, "Text to send"); - lv_obj_set_width(inputTextarea, LV_PCT(100)); - lv_obj_set_flex_grow(inputTextarea, 1); - - auto* terminator_dropdown = lv_dropdown_create(input_wrapper); - lv_dropdown_set_options(terminator_dropdown, "\\n\n\\r\\n"); - lv_obj_set_width(terminator_dropdown, 70); - lv_obj_add_event_cb(terminator_dropdown, onTerminatorDropdownValueChangedCallback, LV_EVENT_VALUE_CHANGED, this); - - auto* button = lv_button_create(input_wrapper); - auto* button_label = lv_label_create(button); - lv_label_set_text(button_label, "Send"); - lv_obj_add_event_cb(button, onSendClickedCallback, LV_EVENT_SHORT_CLICKED, this); - - viewThreadInterrupted = false; - viewThread = std::make_unique( - "SerConsView", - 4096, - [this] { return viewThreadMain(); } - ); - viewThread->setPriority(tt::Thread::Priority::Higher); - viewThread->start(); - } - - void stopLogic() { - auto lock = mutex.asScopedLock(); - lock.lock(); - - uartThreadInterrupted = true; - - // Detach thread, it will auto-delete when leaving the current scope - auto old_uart_thread = std::move(uartThread); - // Unlock so thread can lock - lock.unlock(); - - if (old_uart_thread->getState() != tt::Thread::State::Stopped) { - // Wait for thread to finish - old_uart_thread->join(); - } - } - - void stopViews() { - auto lock = mutex.asScopedLock(); - lock.lock(); - - viewThreadInterrupted = true; - - // Detach thread, it will auto-delete when leaving the current scope - auto old_view_thread = std::move(viewThread); - - // Unlock so thread can lock - lock.unlock(); - - if (old_view_thread->getState() != tt::Thread::State::Stopped) { - // Wait for thread to finish - old_view_thread->join(); - } - } - - void stopUart() { - auto lock = mutex.asScopedLock(); - lock.lock(); - - if (uartDev != nullptr) { - uart_controller_close(uartDev); - uartDev = nullptr; - } - } - - void onStart(lv_obj_t* parent, Device* dev) { - auto lock = mutex.asScopedLock(); - lock.lock(); +/** Opens @a dev's UART, builds the console UI into @a parent, and starts the read/render + * background threads. Call once per session. */ +void consoleViewCreate(lv_obj_t* parent, Context* app, Device* dev); - startLogic(dev); - startViews(parent); - } +/** Rebuilds just the console widgets into @a parent, reusing the still-running background + * threads from an earlier consoleViewCreate() call. Use this (not consoleViewCreate()) when + * this window resurfaces after being buried mid-session - window_manager only ever destroys + * the widget tree, so the read/render threads and the open UART are still alive and don't need + * restarting (doing so would double-start them). */ +void consoleViewRebuildWidgets(lv_obj_t* parent, Context* app); - void onStop() override { - stopViews(); - stopLogic(); - stopUart(); - } -}; +/** Stops both background threads (joining them) and closes the UART. Safe to call even after + * the window's widgets have already been torn down - never touches them. */ +void consoleViewStop(Context* app); diff --git a/Apps/SerialConsole/main/Source/SerialConsole.cpp b/Apps/SerialConsole/main/Source/SerialConsole.cpp index 8eefbc9..cc0faae 100644 --- a/Apps/SerialConsole/main/Source/SerialConsole.cpp +++ b/Apps/SerialConsole/main/Source/SerialConsole.cpp @@ -1,64 +1,98 @@ #include "SerialConsole.h" + +#include #include +#include constexpr auto* TAG = "SerialMonitor"; -void SerialConsole::stopActiveView() { - if (activeView != nullptr) { - activeView->onStop(); - lv_obj_clean(wrapperWidget); - activeView = nullptr; +namespace { + +void stopActiveView(Context* ctx) { + if (ctx->activeView == Context::ActiveView::None) return; + + if (ctx->activeView == Context::ActiveView::Connect) { + connectViewStop(ctx); + } else if (ctx->activeView == Context::ActiveView::Console) { + consoleViewStop(ctx); } + + // wrapperWidget only exists while this window is topmost - skip cleaning it otherwise. + // Final app teardown calls this after window_manager_remove() already deleted it (same + // reasoning as GPIO.cpp's periodic status timer guard). + if (window_manager_get_state(ctx->window) == WINDOW_STATE_GRANTED) { + lv_obj_clean(ctx->wrapperWidget); + } + ctx->activeView = Context::ActiveView::None; +} + +void onDisconnectPressed(lv_event_t* event) { + auto* app = static_cast(lv_event_get_user_data(event)); + // Changing views (calling consoleViewStop) also disconnects the UART + showConnectView(app); } -void SerialConsole::showConsoleView(Device* dev) { +} // namespace + +void showConsoleView(Context* app, Device* dev) { if (dev == nullptr) { ESP_LOGE(TAG, "showConsoleView: null device"); return; } - stopActiveView(); - activeView = &consoleView; - consoleView.onStart(wrapperWidget, dev); - lv_obj_remove_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN); -} - -void SerialConsole::showConnectView() { - stopActiveView(); - activeView = &connectView; - connectView.onStart(wrapperWidget); - lv_obj_add_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN); + stopActiveView(app); + app->activeView = Context::ActiveView::Console; + consoleViewCreate(app->wrapperWidget, app, dev); + lv_obj_remove_flag(app->disconnectButton, LV_OBJ_FLAG_HIDDEN); } -void SerialConsole::onDisconnect() { - // Changing views (calling ConsoleView::stop()) also disconnects the UART - showConnectView(); +void showConnectView(Context* app) { + stopActiveView(app); + app->activeView = Context::ActiveView::Connect; + connectViewCreate(app->wrapperWidget, app); + lv_obj_add_flag(app->disconnectButton, LV_OBJ_FLAG_HIDDEN); } -void SerialConsole::onDisconnectPressed(lv_event_t* event) { - auto* app = static_cast(lv_event_get_user_data(event)); - app->onDisconnect(); -} +void serialConsoleCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); -void SerialConsole::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); auto* toolbar = lvgl_toolbar_create(parent, "Serial Console"); - disconnectButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_POWER, onDisconnectPressed, this); - lv_obj_add_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN); + ctx->disconnectButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_POWER, onDisconnectPressed, ctx); - wrapperWidget = lv_obj_create(parent); - lv_obj_set_width(wrapperWidget, LV_PCT(100)); - lv_obj_set_flex_grow(wrapperWidget, 1); - lv_obj_set_flex_flow(wrapperWidget, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(wrapperWidget, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(wrapperWidget, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(wrapperWidget, 0, LV_STATE_DEFAULT); + ctx->wrapperWidget = lv_obj_create(parent); + lv_obj_set_width(ctx->wrapperWidget, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->wrapperWidget, 1); + lv_obj_set_flex_flow(ctx->wrapperWidget, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(ctx->wrapperWidget, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ctx->wrapperWidget, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(ctx->wrapperWidget, 0, LV_STATE_DEFAULT); - showConnectView(); + // Both branches below rebuild widgets directly (connectViewCreate/consoleViewRebuildWidgets) + // instead of going through showConnectView()/showConsoleView() - those call stopActiveView() + // first, which would try to read/save state out of widgets that window_manager has, by this + // point, already destroyed (this callback only runs because they were destroyed - on first + // creation there's nothing to stop anyway). Only a real view switch (user action) or final + // teardown should ever call stopActiveView(). + if (ctx->activeView == Context::ActiveView::Console) { + // Resurfacing after being buried while a session was active: window_manager only ever + // destroys the widget tree, never our background threads or the open UART, so just + // rebuild the widgets around the still-running session instead of dropping it (see + // ConsoleView.h's comment on consoleViewRebuildWidgets()). + lv_obj_remove_flag(ctx->disconnectButton, LV_OBJ_FLAG_HIDDEN); + consoleViewRebuildWidgets(ctx->wrapperWidget, ctx); + } else { + // First creation, or resurfacing on the (stateless) connect form. + lv_obj_add_flag(ctx->disconnectButton, LV_OBJ_FLAG_HIDDEN); + connectViewCreate(ctx->wrapperWidget, ctx); + ctx->activeView = Context::ActiveView::Connect; + } } -void SerialConsole::onHide(AppHandle context) { - stopActiveView(); +void serialConsoleTeardown(Context* ctx) { + stopActiveView(ctx); + ctx->disconnectButton = nullptr; + ctx->wrapperWidget = nullptr; } diff --git a/Apps/SerialConsole/main/Source/SerialConsole.h b/Apps/SerialConsole/main/Source/SerialConsole.h index cfac1ab..2f37d12 100644 --- a/Apps/SerialConsole/main/Source/SerialConsole.h +++ b/Apps/SerialConsole/main/Source/SerialConsole.h @@ -3,26 +3,31 @@ #include "ConnectView.h" #include "ConsoleView.h" -#include +#include +#include +#include -class SerialConsole final : public App { +struct Context { + AppInstanceId appInstanceId = 0; + WindowId window = 0; lv_obj_t* disconnectButton = nullptr; lv_obj_t* wrapperWidget = nullptr; - ConnectView connectView = ConnectView([this](Device* dev){ - showConsoleView(dev); - }); - ConsoleView consoleView; - View* activeView = nullptr; - - void stopActiveView(); - void showConsoleView(Device* dev); - void showConnectView(); - void onDisconnect(); - static void onDisconnectPressed(lv_event_t* event); - -public: - - void onShow(AppHandle context, lv_obj_t* parent) override; - void onHide(AppHandle context) override; + + enum class ActiveView { None, Connect, Console } activeView = ActiveView::None; + ConnectViewState connectView; + ConsoleViewState consoleView; }; + +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void serialConsoleCreateWidgets(lv_obj_t* parent, void* userData); + +/** Stops whatever view is active and releases widget-tracking state. Call once, after the + * window has been torn down. */ +void serialConsoleTeardown(Context* ctx); + +/** Switches to the console view for @a dev (already opened by ConnectView before calling this). */ +void showConsoleView(Context* app, Device* dev); + +/** Switches back to the connect form (also disconnects the UART, via consoleViewStop). */ +void showConnectView(Context* app); diff --git a/Apps/SerialConsole/main/Source/View.h b/Apps/SerialConsole/main/Source/View.h deleted file mode 100644 index cca1e99..0000000 --- a/Apps/SerialConsole/main/Source/View.h +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once - -class View { -public: - virtual void onStop() = 0; -}; diff --git a/Apps/SerialConsole/main/Source/main.cpp b/Apps/SerialConsole/main/Source/main.cpp index 53c8f96..a5a9867 100644 --- a/Apps/SerialConsole/main/Source/main.cpp +++ b/Apps/SerialConsole/main/Source/main.cpp @@ -1,10 +1,49 @@ #include "SerialConsole.h" -#include + +#include +#include +#include + +#include + +#include extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + // Heap-allocated: ConsoleViewState spawns two background threads (uartThread/viewThread) + // whose lambdas capture this Context's address, and those threads keep running across a + // window burial/resurface cycle (only the widget tree gets destroyed then, see + // ConsoleView.h's consoleViewRebuildWidgets()) - so it can't be a stack frame tied to this + // function's own lifetime in the way a simpler app's Context could be. + auto ctx = std::make_unique(); + ctx->appInstanceId = app_instance_id; + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, serialConsoleCreateWidgets, ctx.get()); + ctx->window = window; + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(app_instance_id); + should_close = true; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + serialConsoleTeardown(ctx.get()); + return 0; } diff --git a/Apps/Snake/CMakeLists.txt b/Apps/Snake/CMakeLists.txt index a9a5772..193f4d4 100644 --- a/Apps/Snake/CMakeLists.txt +++ b/Apps/Snake/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(Snake) tactility_project(Snake) diff --git a/Apps/Snake/main/CMakeLists.txt b/Apps/Snake/main/CMakeLists.txt index 0309010..3d04446 100644 --- a/Apps/Snake/main/CMakeLists.txt +++ b/Apps/Snake/main/CMakeLists.txt @@ -4,8 +4,5 @@ file(GLOB_RECURSE SOURCE_FILES idf_component_register( SRCS ${SOURCE_FILES} - # Library headers must be included directly, - # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include REQUIRES TactilitySDK ) diff --git a/Apps/Snake/main/Source/Snake.cpp b/Apps/Snake/main/Source/Snake.cpp index 22fdcec..b381195 100644 --- a/Apps/Snake/main/Source/Snake.cpp +++ b/Apps/Snake/main/Source/Snake.cpp @@ -5,45 +5,34 @@ #include "Snake.h" #include +#include +#include #include -#include -#include -#include -#include +#include +#include +#include +#include -#include -#include +#include constexpr auto* TAG = "Snake"; +namespace { + // Preferences keys for high scores (one per difficulty) -static constexpr const char* PREF_NAMESPACE = "Snake"; -static constexpr const char* PREF_HIGH_EASY = "high_easy"; -static constexpr const char* PREF_HIGH_MED = "high_med"; -static constexpr const char* PREF_HIGH_HARD = "high_hard"; -static constexpr const char* PREF_HIGH_HELL = "high_hell"; - -// High scores for each difficulty (loaded from preferences) -static int32_t highScoreEasy = 0; -static int32_t highScoreMedium = 0; -static int32_t highScoreHard = 0; -static int32_t highScoreHell = 0; - -static constexpr size_t DIFFICULTY_COUNT = 4; - -// Selection dialog indices (0 = How to Play, 1-4 = difficulties) -static constexpr int32_t SELECTION_HOW_TO_PLAY = 0; -static constexpr int32_t SELECTION_EASY = 1; -static constexpr int32_t SELECTION_MEDIUM = 2; -static constexpr int32_t SELECTION_HARD = 3; -static constexpr int32_t SELECTION_HELL = 4; +constexpr const char* PREF_HIGH_EASY = "high_easy"; +constexpr const char* PREF_HIGH_MED = "high_med"; +constexpr const char* PREF_HIGH_HARD = "high_hard"; +constexpr const char* PREF_HIGH_HELL = "high_hell"; + +constexpr size_t DIFFICULTY_COUNT = 4; // Difficulty options (cell sizes - larger = easier) // Hell uses same size as Hard but with wall collision enabled -static const uint16_t difficultySizes[DIFFICULTY_COUNT] = { SNAKE_CELL_LARGE, SNAKE_CELL_MEDIUM, SNAKE_CELL_SMALL, SNAKE_CELL_SMALL }; +const uint16_t difficultySizes[DIFFICULTY_COUNT] = { SNAKE_CELL_LARGE, SNAKE_CELL_MEDIUM, SNAKE_CELL_SMALL, SNAKE_CELL_SMALL }; -static uint32_t getToolbarHeight(UiDensity uiDensity) { +uint32_t getToolbarHeight(UiDensity uiDensity) { if (uiDensity == LVGL_UI_DENSITY_COMPACT) { return lvgl_get_text_font_height(FONT_SIZE_DEFAULT) * 1.4f; } else { @@ -51,74 +40,86 @@ static uint32_t getToolbarHeight(UiDensity uiDensity) { } } -static uint32_t getActionIconPadding(UiDensity uiDensity) { +uint32_t getActionIconPadding(UiDensity uiDensity) { auto toolbar_height = getToolbarHeight(uiDensity); return (uiDensity != LVGL_UI_DENSITY_COMPACT) ? (uint32_t)(toolbar_height * 0.2f) : 8; } -static void loadHighScores() { - PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE); - if (prefs) { - tt_preferences_opt_int32(prefs, PREF_HIGH_EASY, &highScoreEasy); - tt_preferences_opt_int32(prefs, PREF_HIGH_MED, &highScoreMedium); - tt_preferences_opt_int32(prefs, PREF_HIGH_HARD, &highScoreHard); - tt_preferences_opt_int32(prefs, PREF_HIGH_HELL, &highScoreHell); - tt_preferences_free(prefs); +bool getPreferencesPath(std::string& outPath) { + char root[128]; + if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) { + return false; } + outPath = std::string(root) + "/snake.properties"; + return true; } -static void saveHighScore(int32_t difficulty, int32_t score) { - PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE); - if (prefs) { - switch (difficulty) { - case SELECTION_EASY: - highScoreEasy = score; - tt_preferences_put_int32(prefs, PREF_HIGH_EASY, score); - break; - case SELECTION_MEDIUM: - highScoreMedium = score; - tt_preferences_put_int32(prefs, PREF_HIGH_MED, score); - break; - case SELECTION_HARD: - highScoreHard = score; - tt_preferences_put_int32(prefs, PREF_HIGH_HARD, score); - break; - case SELECTION_HELL: - highScoreHell = score; - tt_preferences_put_int32(prefs, PREF_HIGH_HELL, score); - break; - } - tt_preferences_free(prefs); +void loadHighScores(Context* ctx) { + std::string path; + if (!getPreferencesPath(path)) return; + Preferences* prefs = preferences_open(path.c_str()); + if (!prefs) return; + preferences_opt_int32(prefs, PREF_HIGH_EASY, &ctx->highScoreEasy); + preferences_opt_int32(prefs, PREF_HIGH_MED, &ctx->highScoreMedium); + preferences_opt_int32(prefs, PREF_HIGH_HARD, &ctx->highScoreHard); + preferences_opt_int32(prefs, PREF_HIGH_HELL, &ctx->highScoreHell); + preferences_close(prefs); +} + +void saveHighScore(Context* ctx, int32_t difficulty, int32_t score) { + std::string path; + if (!getPreferencesPath(path)) return; + Preferences* prefs = preferences_open(path.c_str()); + if (!prefs) return; + switch (difficulty) { + case SNAKE_SELECTION_EASY: + ctx->highScoreEasy = score; + preferences_put_int32(prefs, PREF_HIGH_EASY, score); + break; + case SNAKE_SELECTION_MEDIUM: + ctx->highScoreMedium = score; + preferences_put_int32(prefs, PREF_HIGH_MED, score); + break; + case SNAKE_SELECTION_HARD: + ctx->highScoreHard = score; + preferences_put_int32(prefs, PREF_HIGH_HARD, score); + break; + case SNAKE_SELECTION_HELL: + ctx->highScoreHell = score; + preferences_put_int32(prefs, PREF_HIGH_HELL, score); + break; } + preferences_close(prefs); } -static int32_t getHighScore(int32_t difficulty) { +int32_t getHighScore(Context* ctx, int32_t difficulty) { switch (difficulty) { - case SELECTION_EASY: return highScoreEasy; - case SELECTION_MEDIUM: return highScoreMedium; - case SELECTION_HARD: return highScoreHard; - case SELECTION_HELL: return highScoreHell; + case SNAKE_SELECTION_EASY: return ctx->highScoreEasy; + case SNAKE_SELECTION_MEDIUM: return ctx->highScoreMedium; + case SNAKE_SELECTION_HARD: return ctx->highScoreHard; + case SNAKE_SELECTION_HELL: return ctx->highScoreHell; default: return 0; } } -void Snake::showHelpDialog() { - const char* buttons[] = { "OK" }; - helpDialogId = tt_app_alertdialog_start( +void showHelpDialog(Context* ctx) { + const char* argv[] = { "How to Play", "Swipe or use arrow keys to change direction.\n" "Eat food to grow longer.\n" "Don't hit yourself!", - buttons, 1); + "OK", + }; + app_manager_start_for_result("AlertDialog", ctx->appInstanceId, 3, argv, &ctx->helpDialogId); } -void Snake::showSelectionDialog() { - const char* items[] = { "How to Play", "Easy", "Medium", "Hard", "Hell" }; - selectionDialogId = tt_app_selectiondialog_start("Snake", 5, items); +void showSelectionDialog(Context* ctx) { + const char* argv[] = { "Snake", "How to Play", "Easy", "Medium", "Hard", "Hell" }; + app_manager_start_for_result("SelectionDialog", ctx->appInstanceId, 6, argv, &ctx->selectionDialogId); } -void Snake::snakeEventCb(lv_event_t* e) { - Snake* self = (Snake*)lv_event_get_user_data(e); +void snakeEventCb(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); lv_obj_t* target = lv_event_get_target_obj(e); lv_event_code_t code = lv_event_get_code(e); @@ -126,118 +127,116 @@ void Snake::snakeEventCb(lv_event_t* e) { if (snake_get_game_over(target)) { int32_t score = snake_get_score(target); int32_t length = snake_get_length(target); - int32_t prevHighScore = getHighScore(self->currentDifficulty); + int32_t prevHighScore = getHighScore(ctx, ctx->currentDifficulty); bool isNewHighScore = score > prevHighScore; // Save high score if it's a new record if (isNewHighScore) { - saveHighScore(self->currentDifficulty, score); + saveHighScore(ctx, ctx->currentDifficulty, score); } - const char* alertDialogLabels[] = { "OK" }; + const char* alertTitle = isNewHighScore && score > 0 ? "NEW HIGH SCORE!" : "GAME OVER!"; char message[120]; if (isNewHighScore && score > 0) { snprintf(message, sizeof(message), "NEW HIGH SCORE!\n\nSCORE: %" PRId32 "\nLENGTH: %" PRId32, score, length); } else { snprintf(message, sizeof(message), "GAME OVER!\n\nSCORE: %" PRId32 "\nLENGTH: %" PRId32 "\nBEST: %" PRId32, - score, length, getHighScore(self->currentDifficulty)); + score, length, getHighScore(ctx, ctx->currentDifficulty)); } - self->gameOverDialogId = tt_app_alertdialog_start( - isNewHighScore && score > 0 ? "NEW HIGH SCORE!" : "GAME OVER!", - message, alertDialogLabels, 1); + const char* argv[] = { alertTitle, message, "OK" }; + app_manager_start_for_result("AlertDialog", ctx->appInstanceId, 3, argv, &ctx->gameOverDialogId); } else { // Update score display - lv_label_set_text_fmt(self->scoreLabel, "SCORE: %u", snake_get_score(self->gameObject)); + lv_label_set_text_fmt(ctx->scoreLabel, "SCORE: %u", snake_get_score(ctx->gameObject)); } } } -void Snake::newGameBtnEvent(lv_event_t* e) { - Snake* self = (Snake*)lv_event_get_user_data(e); - if (self == nullptr) { +void newGameBtnEvent(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx == nullptr) { return; } - snake_set_new_game(self->gameObject); + snake_set_new_game(ctx->gameObject); // Update score label - if (self->scoreLabel) { - lv_label_set_text_fmt(self->scoreLabel, "SCORE: %u", snake_get_score(self->gameObject)); + if (ctx->scoreLabel) { + lv_label_set_text_fmt(ctx->scoreLabel, "SCORE: %u", snake_get_score(ctx->gameObject)); } } -void Snake::createGame(lv_obj_t* parent, uint16_t cell_size, bool wallCollision, lv_obj_t* tb) { +void createGame(Context* ctx, lv_obj_t* parent, uint16_t cell_size, bool wallCollision, lv_obj_t* tb) { lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); // Create game widget - gameObject = snake_create(parent, cell_size, wallCollision); - if (!gameObject) { + ctx->gameObject = snake_create(parent, cell_size, wallCollision); + if (!ctx->gameObject) { return; } - lv_obj_set_size(gameObject, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_grow(gameObject, 1); + lv_obj_set_size(ctx->gameObject, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_grow(ctx->gameObject, 1); // Create score wrapper in toolbar - scoreWrapper = lv_obj_create(tb); - lv_obj_set_size(scoreWrapper, LV_SIZE_CONTENT, LV_PCT(100)); - lv_obj_set_style_pad_top(scoreWrapper, 4, LV_STATE_DEFAULT); - lv_obj_set_style_pad_bottom(scoreWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_left(scoreWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_right(scoreWrapper, 10, LV_STATE_DEFAULT); - lv_obj_set_style_pad_row(scoreWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_column(scoreWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(scoreWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(scoreWrapper, 0, LV_STATE_DEFAULT); - lv_obj_remove_flag(scoreWrapper, LV_OBJ_FLAG_SCROLLABLE); + ctx->scoreWrapper = lv_obj_create(tb); + lv_obj_set_size(ctx->scoreWrapper, LV_SIZE_CONTENT, LV_PCT(100)); + lv_obj_set_style_pad_top(ctx->scoreWrapper, 4, LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(ctx->scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(ctx->scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(ctx->scoreWrapper, 10, LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(ctx->scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(ctx->scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ctx->scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(ctx->scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_remove_flag(ctx->scoreWrapper, LV_OBJ_FLAG_SCROLLABLE); // Create score label - scoreLabel = lv_label_create(scoreWrapper); - lv_label_set_text_fmt(scoreLabel, "SCORE: %u", snake_get_score(gameObject)); - lv_obj_set_style_text_align(scoreLabel, LV_TEXT_ALIGN_LEFT, LV_STATE_DEFAULT); - lv_obj_align(scoreLabel, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_size(scoreLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_style_text_font(scoreLabel, lv_font_get_default(), 0); - lv_obj_set_style_text_color(scoreLabel, lv_palette_main(LV_PALETTE_GREEN), LV_PART_MAIN); - lv_obj_add_event_cb(gameObject, snakeEventCb, LV_EVENT_VALUE_CHANGED, this); + ctx->scoreLabel = lv_label_create(ctx->scoreWrapper); + lv_label_set_text_fmt(ctx->scoreLabel, "SCORE: %u", snake_get_score(ctx->gameObject)); + lv_obj_set_style_text_align(ctx->scoreLabel, LV_TEXT_ALIGN_LEFT, LV_STATE_DEFAULT); + lv_obj_align(ctx->scoreLabel, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_size(ctx->scoreLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_text_font(ctx->scoreLabel, lv_font_get_default(), 0); + lv_obj_set_style_text_color(ctx->scoreLabel, lv_palette_main(LV_PALETTE_GREEN), LV_PART_MAIN); + lv_obj_add_event_cb(ctx->gameObject, snakeEventCb, LV_EVENT_VALUE_CHANGED, ctx); auto ui_density = lvgl_get_ui_density(); auto toolbar_height = getToolbarHeight(ui_density); auto icon_padding = getActionIconPadding(ui_density); // Create new game button wrapper - newGameWrapper = lv_obj_create(tb); - lv_obj_set_width(newGameWrapper, LV_SIZE_CONTENT); - lv_obj_set_flex_flow(newGameWrapper, LV_FLEX_FLOW_ROW); - lv_obj_set_style_pad_all(newGameWrapper, icon_padding / 2, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(newGameWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(newGameWrapper, 0, LV_STATE_DEFAULT); + ctx->newGameWrapper = lv_obj_create(tb); + lv_obj_set_width(ctx->newGameWrapper, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(ctx->newGameWrapper, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_all(ctx->newGameWrapper, icon_padding / 2, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ctx->newGameWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(ctx->newGameWrapper, 0, LV_STATE_DEFAULT); // Create new game button - lv_obj_t* newGameBtn = lv_btn_create(newGameWrapper); + lv_obj_t* newGameBtn = lv_btn_create(ctx->newGameWrapper); lv_obj_set_size(newGameBtn, toolbar_height - icon_padding, toolbar_height - icon_padding); lv_obj_set_style_pad_all(newGameBtn, 0, LV_STATE_DEFAULT); lv_obj_align(newGameBtn, LV_ALIGN_CENTER, 0, 0); - lv_obj_add_event_cb(newGameBtn, newGameBtnEvent, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(newGameBtn, newGameBtnEvent, LV_EVENT_CLICKED, ctx); lv_obj_t* btnIcon = lv_image_create(newGameBtn); lv_image_set_src(btnIcon, LV_SYMBOL_REFRESH); lv_obj_align(btnIcon, LV_ALIGN_CENTER, 0, 0); } -void Snake::onHide(AppHandle appHandle) { - scoreLabel = nullptr; - scoreWrapper = nullptr; - toolbar = nullptr; - mainWrapper = nullptr; - newGameWrapper = nullptr; - gameObject = nullptr; -} +} // namespace -void Snake::onShow(AppHandle appHandle, lv_obj_t* parent) { - // Check if we should exit (user closed selection dialog) - if (shouldExit) { - shouldExit = false; - tt_app_stop(); +void snakeCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + // Closed the selection dialog without picking anything - close self. Emit our own close + // event rather than calling app_manager_finish()/window_manager APIs directly from inside + // this callback (window_manager's own docs warn against that - it would deadlock); the main + // loop picks this up and does the actual finish. + if (ctx->shouldExit) { + ctx->shouldExit = false; + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &event); return; } @@ -245,78 +244,58 @@ void Snake::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); // Create toolbar - toolbar = lvgl_toolbar_create(parent, "Snake"); - lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + ctx->toolbar = lvgl_toolbar_create(parent, "Snake"); + lv_obj_align(ctx->toolbar, LV_ALIGN_TOP_MID, 0, 0); // Create main wrapper - mainWrapper = lv_obj_create(parent); - lv_obj_set_width(mainWrapper, LV_PCT(100)); - lv_obj_set_flex_grow(mainWrapper, 1); - lv_obj_set_style_pad_all(mainWrapper, 2, LV_PART_MAIN); - lv_obj_set_style_pad_row(mainWrapper, 2, LV_PART_MAIN); - lv_obj_set_style_pad_column(mainWrapper, 2, LV_PART_MAIN); - lv_obj_set_style_border_width(mainWrapper, 0, LV_PART_MAIN); - lv_obj_remove_flag(mainWrapper, LV_OBJ_FLAG_SCROLLABLE); - - // Load high scores on first show - if (!highScoresLoaded) { - loadHighScores(); - highScoresLoaded = true; + ctx->mainWrapper = lv_obj_create(parent); + lv_obj_set_width(ctx->mainWrapper, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->mainWrapper, 1); + lv_obj_set_style_pad_all(ctx->mainWrapper, 2, LV_PART_MAIN); + lv_obj_set_style_pad_row(ctx->mainWrapper, 2, LV_PART_MAIN); + lv_obj_set_style_pad_column(ctx->mainWrapper, 2, LV_PART_MAIN); + lv_obj_set_style_border_width(ctx->mainWrapper, 0, LV_PART_MAIN); + lv_obj_remove_flag(ctx->mainWrapper, LV_OBJ_FLAG_SCROLLABLE); + + // Load high scores on first build + if (!ctx->highScoresLoaded) { + loadHighScores(ctx); + ctx->highScoresLoaded = true; } - // Check if we need to show the help dialog - if (showHelpOnShow) { - showHelpOnShow = false; - showHelpDialog(); - // Check if we have a pending difficulty selection from onResult - } else if (pendingSelection >= SELECTION_EASY && pendingSelection <= SELECTION_HELL) { - // Force layout update before creating game so dimensions are computed + if (ctx->showHelpOnShow) { + // A dialog we opened just closed, telling us to show help next. + ctx->showHelpOnShow = false; + showHelpDialog(ctx); + } else if (ctx->pendingSelection >= SNAKE_SELECTION_EASY && ctx->pendingSelection <= SNAKE_SELECTION_HELL) { + // A dialog we opened just closed, telling us to start a game at this difficulty. + lv_obj_update_layout(parent); + ctx->currentDifficulty = ctx->pendingSelection; + int32_t difficultyIndex = ctx->pendingSelection - SNAKE_SELECTION_EASY; + bool wallCollision = (ctx->pendingSelection == SNAKE_SELECTION_HELL); + createGame(ctx, ctx->mainWrapper, difficultySizes[difficultyIndex], wallCollision, ctx->toolbar); + ctx->pendingSelection = -1; + } else if (ctx->currentDifficulty >= SNAKE_SELECTION_EASY && ctx->currentDifficulty <= SNAKE_SELECTION_HELL) { + // Resurfacing while a game was already active, but not because one of our own dialogs + // closed (e.g. another app was briefly switched to and this window got buried, which + // destroys its whole widget tree). snake_create() owns all game state internally and + // that's gone now too, so there's no cheap way to resume the exact position - start a + // fresh game at the same difficulty instead of dropping back to the selection dialog. lv_obj_update_layout(parent); - // Track which difficulty we're playing for high score saving - currentDifficulty = pendingSelection; - // Start game with selected difficulty (convert selection index to difficulty index) - int32_t difficultyIndex = pendingSelection - SELECTION_EASY; - // Hell mode enables wall collision (hitting walls = game over) - bool wallCollision = (pendingSelection == SELECTION_HELL); - createGame(mainWrapper, difficultySizes[difficultyIndex], wallCollision, toolbar); - pendingSelection = -1; + int32_t difficultyIndex = ctx->currentDifficulty - SNAKE_SELECTION_EASY; + bool wallCollision = (ctx->currentDifficulty == SNAKE_SELECTION_HELL); + createGame(ctx, ctx->mainWrapper, difficultySizes[difficultyIndex], wallCollision, ctx->toolbar); } else { - // Show selection dialog - showSelectionDialog(); + // First creation - show selection dialog + showSelectionDialog(ctx); } } -void Snake::onResult(AppHandle appHandle, void* _Nullable data, AppLaunchId launchId, AppResult result, BundleHandle resultData) { - // Don't manipulate LVGL objects here - they may be invalid - // Just store state for onShow to handle - - if (launchId == selectionDialogId && selectionDialogId != 0) { - selectionDialogId = 0; - - int32_t selection = -1; - if (resultData != nullptr) { - selection = tt_app_selectiondialog_get_result_index(resultData); - } - - if (selection == SELECTION_HOW_TO_PLAY) { - // Mark to show help dialog in onShow - showHelpOnShow = true; - } else if (selection >= SELECTION_EASY && selection <= SELECTION_HELL) { - // Store selection for onShow to handle - pendingSelection = selection; - } else { - // User closed dialog without selecting - mark for exit - shouldExit = true; - } - - } else if (launchId == helpDialogId && helpDialogId != 0) { - helpDialogId = 0; - // Return to selection dialog - pendingSelection = -1; - - } else if (launchId == gameOverDialogId && gameOverDialogId != 0) { - gameOverDialogId = 0; - // Mark to show selection dialog in onShow - pendingSelection = -1; - } +void snakeTeardown(Context* ctx) { + ctx->scoreLabel = nullptr; + ctx->scoreWrapper = nullptr; + ctx->toolbar = nullptr; + ctx->mainWrapper = nullptr; + ctx->newGameWrapper = nullptr; + ctx->gameObject = nullptr; } diff --git a/Apps/Snake/main/Source/Snake.h b/Apps/Snake/main/Source/Snake.h index 5cbe301..7af76a8 100644 --- a/Apps/Snake/main/Source/Snake.h +++ b/Apps/Snake/main/Source/Snake.h @@ -1,21 +1,30 @@ /** * @file Snake.h - * @brief Snake game app class for Tactility + * @brief Snake game app for Tactility */ #pragma once -#include -#include -#include - #include "SnakeUi.h" #include "SnakeLogic.h" #include "SnakeHelpers.h" -class Snake final : public App { +#include +#include +#include + +// Selection dialog indices (0 = How to Play, 1-4 = difficulties) - shared between Snake.cpp +// (which builds the dialog) and main.cpp (which interprets its APP_EVENT_RESULT). +constexpr int32_t SNAKE_SELECTION_HOW_TO_PLAY = 0; +constexpr int32_t SNAKE_SELECTION_EASY = 1; +constexpr int32_t SNAKE_SELECTION_MEDIUM = 2; +constexpr int32_t SNAKE_SELECTION_HARD = 3; +constexpr int32_t SNAKE_SELECTION_HELL = 4; + +struct Context { + AppInstanceId appInstanceId = 0; + WindowId window = 0; -private: - // UI element pointers (invalidated on hide, recreated on show) + // UI element pointers (invalidated on rebuild, recreated in snakeCreateWidgets) lv_obj_t* scoreLabel = nullptr; lv_obj_t* scoreWrapper = nullptr; lv_obj_t* toolbar = nullptr; @@ -23,27 +32,28 @@ class Snake final : public App { lv_obj_t* newGameWrapper = nullptr; lv_obj_t* gameObject = nullptr; - // State tracking (persists across hide/show cycles) - int32_t pendingSelection = -1; // -1 = show selection, 1-3 = start game with difficulty + // State tracking (persists across widget rebuilds) + int32_t pendingSelection = -1; // -1 = show selection, 1-4 = start game with difficulty bool shouldExit = false; - bool showHelpOnShow = false; // Show help dialog when onShow is called + bool showHelpOnShow = false; // Show help dialog next time widgets are (re)built bool highScoresLoaded = false; - int32_t currentDifficulty = -1; // Track which difficulty is being played - - // Dialog launch IDs for tracking which dialog returned (only accessible to member functions) - AppLaunchId selectionDialogId = 0; - AppLaunchId gameOverDialogId = 0; - AppLaunchId helpDialogId = 0; - - static void snakeEventCb(lv_event_t* e); - static void newGameBtnEvent(lv_event_t* e); - void createGame(lv_obj_t* parent, uint16_t cell_size, bool wallCollision, lv_obj_t* tb); - void showSelectionDialog(); - void showHelpDialog(); + int32_t currentDifficulty = -1; // Which difficulty is being played, -1 = none + + // High scores for each difficulty (loaded from preferences on first widget build) + int32_t highScoreEasy = 0; + int32_t highScoreMedium = 0; + int32_t highScoreHard = 0; + int32_t highScoreHell = 0; + + // Dialog launch IDs for tracking which dialog returned + uint32_t selectionDialogId = 0; + uint32_t gameOverDialogId = 0; + uint32_t helpDialogId = 0; +}; -public: +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void snakeCreateWidgets(lv_obj_t* parent, void* userData); - void onShow(AppHandle context, lv_obj_t* parent) override; - void onHide(AppHandle context) override; - void onResult(AppHandle appHandle, void* _Nullable data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override; -}; +/** Nothing to release beyond widget-tracking state - call once, after the window has been torn + * down. */ +void snakeTeardown(Context* ctx); diff --git a/Apps/Snake/main/Source/main.cpp b/Apps/Snake/main/Source/main.cpp index c6ac3c3..87971e1 100644 --- a/Apps/Snake/main/Source/main.cpp +++ b/Apps/Snake/main/Source/main.cpp @@ -1,10 +1,85 @@ #include "Snake.h" -#include + +#include +#include +#include + +#include extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + Context ctx {}; + ctx.appInstanceId = app_instance_id; + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, snakeCreateWidgets, &ctx); + ctx.window = window; + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(app_instance_id); + should_close = true; + break; + + case APP_EVENT_RESULT: { + uint32_t launch_id = event.result.launch_id; + + // Don't manipulate LVGL objects here - they may be invalid (this window may + // still be buried, or mid-rebuild). Just store state for snakeCreateWidgets to + // handle once it runs again. + if (launch_id == ctx.selectionDialogId && ctx.selectionDialogId != 0) { + ctx.selectionDialogId = 0; + int32_t selection = event.result.result; + + if (selection == SNAKE_SELECTION_HOW_TO_PLAY) { + ctx.showHelpOnShow = true; + } else if (selection >= SNAKE_SELECTION_EASY && selection <= SNAKE_SELECTION_HELL) { + ctx.pendingSelection = selection; + } else { + // Closed without selecting + ctx.shouldExit = true; + } + app_manager_stop(launch_id); + + } else if (launch_id == ctx.helpDialogId && ctx.helpDialogId != 0) { + ctx.helpDialogId = 0; + // Return to selection dialog + ctx.pendingSelection = -1; + app_manager_stop(launch_id); + + } else if (launch_id == ctx.gameOverDialogId && ctx.gameOverDialogId != 0) { + ctx.gameOverDialogId = 0; + // Game has genuinely ended - return to selection dialog rather than letting + // snakeCreateWidgets' resurface handling start a fresh game at the same + // difficulty (that path is only for burial by something else entirely). + ctx.pendingSelection = -1; + ctx.currentDifficulty = -1; + app_manager_stop(launch_id); + } + break; + } + + default: + break; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + snakeTeardown(&ctx); + return 0; } diff --git a/Apps/TamaTac/CMakeLists.txt b/Apps/TamaTac/CMakeLists.txt index 0de1582..3257e71 100644 --- a/Apps/TamaTac/CMakeLists.txt +++ b/Apps/TamaTac/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(TamaTac) tactility_project(TamaTac) diff --git a/Apps/TamaTac/main/CMakeLists.txt b/Apps/TamaTac/main/CMakeLists.txt index 5bf1998..531d730 100644 --- a/Apps/TamaTac/main/CMakeLists.txt +++ b/Apps/TamaTac/main/CMakeLists.txt @@ -5,6 +5,6 @@ idf_component_register( SRCS ${SOURCE_FILES} ${SFX_ENGINE_FILES} # Library headers must be included directly, # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include ../../../Libraries/SfxEngine/Include + INCLUDE_DIRS ../../../Libraries/SfxEngine/Include REQUIRES TactilitySDK ) diff --git a/Apps/TamaTac/main/Source/Achievements.cpp b/Apps/TamaTac/main/Source/Achievements.cpp index e007057..3c36b70 100644 --- a/Apps/TamaTac/main/Source/Achievements.cpp +++ b/Apps/TamaTac/main/Source/Achievements.cpp @@ -5,12 +5,23 @@ #include "Achievements.h" #include "TamaTac.h" -#include +#include +#include #include +#include -static constexpr const char* PREF_NS = "TamaTacAch"; +namespace { -static const AchievementInfo achievementInfos[] = { +bool getPreferencesPath(std::string& outPath) { + char root[128]; + if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) { + return false; + } + outPath = std::string(root) + "/tamatac_achievements.properties"; + return true; +} + +const AchievementInfo achievementInfos[] = { {"First Feed", "Feed your pet"}, {"First Play", "Play a mini-game"}, {"First Cure", "Cure sickness"}, @@ -25,7 +36,9 @@ static const AchievementInfo achievementInfos[] = { {"Night Owl", "Play at night"}, }; -const AchievementInfo& AchievementsView::getInfo(AchievementId id) { +} // namespace + +const AchievementInfo& achievementsGetInfo(AchievementId id) { int idx = static_cast(id); if (idx < 0 || idx >= static_cast(AchievementId::COUNT)) { idx = 0; @@ -33,33 +46,43 @@ const AchievementInfo& AchievementsView::getInfo(AchievementId id) { return achievementInfos[idx]; } -uint16_t AchievementsView::loadAchievements() { - Preferences prefs(PREF_NS); - return static_cast(prefs.getInt32("bits", 0)); +uint16_t achievementsLoad() { + std::string path; + if (!getPreferencesPath(path)) return 0; + Preferences* prefs = preferences_open(path.c_str()); + if (!prefs) return 0; + int32_t bits = 0; + preferences_opt_int32(prefs, "bits", &bits); + preferences_close(prefs); + return static_cast(bits); } -void AchievementsView::saveAchievements(uint16_t bits) { - Preferences prefs(PREF_NS); - prefs.putInt32("bits", static_cast(bits)); +void achievementsSave(uint16_t bits) { + std::string path; + if (!getPreferencesPath(path)) return; + Preferences* prefs = preferences_open(path.c_str()); + if (!prefs) return; + preferences_put_int32(prefs, "bits", static_cast(bits)); + preferences_close(prefs); } -bool AchievementsView::hasAchievement(uint16_t bits, AchievementId id) { +bool achievementsHas(uint16_t bits, AchievementId id) { return (bits >> static_cast(id)) & 1; } -void AchievementsView::unlock(AchievementId id) { +void achievementsUnlock(AchievementId id) { if (static_cast(id) >= static_cast(AchievementId::COUNT)) { return; } - uint16_t bits = loadAchievements(); + uint16_t bits = achievementsLoad(); uint16_t mask = static_cast(1 << static_cast(id)); if (!(bits & mask)) { bits |= mask; - saveAchievements(bits); + achievementsSave(bits); } } -int AchievementsView::countUnlocked(uint16_t bits) { +int achievementsCountUnlocked(uint16_t bits) { int count = 0; for (int i = 0; i < static_cast(AchievementId::COUNT); i++) { if ((bits >> i) & 1) count++; @@ -67,23 +90,35 @@ int AchievementsView::countUnlocked(uint16_t bits) { return count; } -uint16_t AchievementsView::loadCleanCount() { - Preferences prefs(PREF_NS); - return static_cast(prefs.getInt32("cleanCnt", 0)); +uint16_t achievementsLoadCleanCount() { + std::string path; + if (!getPreferencesPath(path)) return 0; + Preferences* prefs = preferences_open(path.c_str()); + if (!prefs) return 0; + int32_t count = 0; + preferences_opt_int32(prefs, "cleanCnt", &count); + preferences_close(prefs); + return static_cast(count); } -void AchievementsView::incrementCleanCount() { - Preferences prefs(PREF_NS); - int32_t count = prefs.getInt32("cleanCnt", 0) + 1; - prefs.putInt32("cleanCnt", count); +void achievementsIncrementCleanCount() { + std::string path; + if (!getPreferencesPath(path)) return; + Preferences* prefs = preferences_open(path.c_str()); + if (!prefs) return; + int32_t count = 0; + preferences_opt_int32(prefs, "cleanCnt", &count); + count += 1; + preferences_put_int32(prefs, "cleanCnt", count); + preferences_close(prefs); + if (count >= 10) { - unlock(AchievementId::CleanFreak); + achievementsUnlock(AchievementId::CleanFreak); } } -void AchievementsView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { - parent = parentWidget; - app = appInstance; +void achievementsViewCreateWidgets(lv_obj_t* parentWidget, Context* ctx) { + AchievementsViewState* state = &ctx->achievementsView; lv_coord_t screenWidth = lv_display_get_horizontal_resolution(nullptr); lv_coord_t screenHeight = lv_display_get_vertical_resolution(nullptr); @@ -93,20 +128,20 @@ void AchievementsView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { int padAll = isSmall ? 4 : (isXLarge ? 16 : 8); int padRow = isSmall ? 2 : (isXLarge ? 8 : 4); - mainWrapper = lv_obj_create(parent); - lv_obj_set_size(mainWrapper, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_pad_all(mainWrapper, padAll, 0); - lv_obj_set_style_pad_row(mainWrapper, padRow, 0); - lv_obj_set_style_bg_opa(mainWrapper, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(mainWrapper, 0, 0); - lv_obj_set_flex_flow(mainWrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(mainWrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + state->mainWrapper = lv_obj_create(parentWidget); + lv_obj_set_size(state->mainWrapper, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_all(state->mainWrapper, padAll, 0); + lv_obj_set_style_pad_row(state->mainWrapper, padRow, 0); + lv_obj_set_style_bg_opa(state->mainWrapper, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(state->mainWrapper, 0, 0); + lv_obj_set_flex_flow(state->mainWrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(state->mainWrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - uint16_t bits = loadAchievements(); - int unlocked = countUnlocked(bits); + uint16_t bits = achievementsLoad(); + int unlocked = achievementsCountUnlocked(bits); // Title with count - lv_obj_t* title = lv_label_create(mainWrapper); + lv_obj_t* title = lv_label_create(state->mainWrapper); char titleText[48]; snprintf(titleText, sizeof(titleText), "Achievements %d/%d", unlocked, static_cast(AchievementId::COUNT)); lv_label_set_text(title, titleText); @@ -115,10 +150,10 @@ void AchievementsView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { // Achievement list for (int i = 0; i < static_cast(AchievementId::COUNT); i++) { AchievementId id = static_cast(i); - bool has = hasAchievement(bits, id); - const AchievementInfo& info = getInfo(id); + bool has = achievementsHas(bits, id); + const AchievementInfo& info = achievementsGetInfo(id); - lv_obj_t* row = lv_obj_create(mainWrapper); + lv_obj_t* row = lv_obj_create(state->mainWrapper); lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_pad_all(row, isSmall ? 2 : (isXLarge ? 8 : 4), 0); lv_obj_set_style_bg_color(row, has ? lv_color_hex(0x2a4a2e) : lv_color_hex(0x2a2a4e), 0); @@ -136,8 +171,6 @@ void AchievementsView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { } } -void AchievementsView::onStop() { - mainWrapper = nullptr; - parent = nullptr; - app = nullptr; +void achievementsViewStop(Context* ctx) { + ctx->achievementsView.mainWrapper = nullptr; } diff --git a/Apps/TamaTac/main/Source/Achievements.h b/Apps/TamaTac/main/Source/Achievements.h index cb02439..a4a369d 100644 --- a/Apps/TamaTac/main/Source/Achievements.h +++ b/Apps/TamaTac/main/Source/Achievements.h @@ -7,7 +7,7 @@ #include #include -class TamaTac; +struct Context; // Achievement IDs (bit positions in uint16_t bitfield) enum class AchievementId : uint8_t { @@ -31,29 +31,20 @@ struct AchievementInfo { const char* description; }; -class AchievementsView { -private: - TamaTac* app = nullptr; - lv_obj_t* parent = nullptr; +struct AchievementsViewState { lv_obj_t* mainWrapper = nullptr; - -public: - AchievementsView() = default; - ~AchievementsView() = default; - AchievementsView(const AchievementsView&) = delete; - AchievementsView& operator=(const AchievementsView&) = delete; - - void onStart(lv_obj_t* parentWidget, TamaTac* appInstance); - void onStop(); - - // Bitfield operations - static uint16_t loadAchievements(); - static void saveAchievements(uint16_t bits); - static bool hasAchievement(uint16_t bits, AchievementId id); - static void unlock(AchievementId id); - static int countUnlocked(uint16_t bits); - static uint16_t loadCleanCount(); - static void incrementCleanCount(); - - static const AchievementInfo& getInfo(AchievementId id); }; + +void achievementsViewCreateWidgets(lv_obj_t* parentWidget, Context* ctx); +void achievementsViewStop(Context* ctx); + +// Bitfield operations +uint16_t achievementsLoad(); +void achievementsSave(uint16_t bits); +bool achievementsHas(uint16_t bits, AchievementId id); +void achievementsUnlock(AchievementId id); +int achievementsCountUnlocked(uint16_t bits); +uint16_t achievementsLoadCleanCount(); +void achievementsIncrementCleanCount(); + +const AchievementInfo& achievementsGetInfo(AchievementId id); diff --git a/Apps/TamaTac/main/Source/CemeteryView.cpp b/Apps/TamaTac/main/Source/CemeteryView.cpp index a12b4e7..c80a2e4 100644 --- a/Apps/TamaTac/main/Source/CemeteryView.cpp +++ b/Apps/TamaTac/main/Source/CemeteryView.cpp @@ -5,69 +5,112 @@ #include "CemeteryView.h" #include "TamaTac.h" -#include +#include +#include #include +#include -static constexpr const char* PREF_NS = "TamaTacCem"; +namespace { -void CemeteryView::loadRecords(PetRecord records[MAX_RECORDS]) { - Preferences prefs(PREF_NS); +bool getPreferencesPath(std::string& outPath) { + char root[128]; + if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) { + return false; + } + outPath = std::string(root) + "/tamatac_cemetery.properties"; + return true; +} + +} // namespace + +void cemeteryViewLoadRecords(PetRecord records[CEMETERY_MAX_RECORDS]) { + std::string path; + if (!getPreferencesPath(path)) return; + Preferences* prefs = preferences_open(path.c_str()); + if (!prefs) return; - for (int i = 0; i < MAX_RECORDS; i++) { + for (int i = 0; i < CEMETERY_MAX_RECORDS; i++) { char key[16]; snprintf(key, sizeof(key), "valid%d", i); - records[i].valid = prefs.getBool(key, false); + bool valid = false; + preferences_opt_bool(prefs, key, &valid); + records[i].valid = valid; if (records[i].valid) { + int32_t value; + snprintf(key, sizeof(key), "pers%d", i); - records[i].personality = static_cast(prefs.getInt32(key, 0)); + value = 0; + preferences_opt_int32(prefs, key, &value); + records[i].personality = static_cast(value); snprintf(key, sizeof(key), "stage%d", i); - records[i].stageReached = static_cast(prefs.getInt32(key, 0)); + value = 0; + preferences_opt_int32(prefs, key, &value); + records[i].stageReached = static_cast(value); snprintf(key, sizeof(key), "age%d", i); - records[i].ageHours = static_cast(prefs.getInt32(key, 0)); + value = 0; + preferences_opt_int32(prefs, key, &value); + records[i].ageHours = static_cast(value); } } + + preferences_close(prefs); } -void CemeteryView::recordDeath(Personality personality, LifeStage stage, uint16_t ageHours) { - Preferences prefs(PREF_NS); +void cemeteryViewRecordDeath(Personality personality, LifeStage stage, uint16_t ageHours) { + std::string path; + if (!getPreferencesPath(path)) return; + Preferences* prefs = preferences_open(path.c_str()); + if (!prefs) return; + + auto getBool = [&](const char* key, bool defaultValue) { + bool value = defaultValue; + preferences_opt_bool(prefs, key, &value); + return value; + }; + auto getInt32 = [&](const char* key, int32_t defaultValue) { + int32_t value = defaultValue; + preferences_opt_int32(prefs, key, &value); + return value; + }; // Shift existing records down (newest at index 0) - for (int i = MAX_RECORDS - 1; i > 0; i--) { + for (int i = CEMETERY_MAX_RECORDS - 1; i > 0; i--) { char srcKey[16], dstKey[16]; snprintf(srcKey, sizeof(srcKey), "valid%d", i - 1); snprintf(dstKey, sizeof(dstKey), "valid%d", i); - bool srcValid = prefs.getBool(srcKey, false); - prefs.putBool(dstKey, srcValid); + bool srcValid = getBool(srcKey, false); + preferences_put_bool(prefs, dstKey, srcValid); if (srcValid) { snprintf(srcKey, sizeof(srcKey), "pers%d", i - 1); snprintf(dstKey, sizeof(dstKey), "pers%d", i); - prefs.putInt32(dstKey, prefs.getInt32(srcKey, 0)); + preferences_put_int32(prefs, dstKey, getInt32(srcKey, 0)); snprintf(srcKey, sizeof(srcKey), "stage%d", i - 1); snprintf(dstKey, sizeof(dstKey), "stage%d", i); - prefs.putInt32(dstKey, prefs.getInt32(srcKey, 0)); + preferences_put_int32(prefs, dstKey, getInt32(srcKey, 0)); snprintf(srcKey, sizeof(srcKey), "age%d", i - 1); snprintf(dstKey, sizeof(dstKey), "age%d", i); - prefs.putInt32(dstKey, prefs.getInt32(srcKey, 0)); + preferences_put_int32(prefs, dstKey, getInt32(srcKey, 0)); } } // Write new record at index 0 - prefs.putBool("valid0", true); - prefs.putInt32("pers0", static_cast(personality)); - prefs.putInt32("stage0", static_cast(stage)); - prefs.putInt32("age0", static_cast(ageHours)); + preferences_put_bool(prefs, "valid0", true); + preferences_put_int32(prefs, "pers0", static_cast(personality)); + preferences_put_int32(prefs, "stage0", static_cast(stage)); + preferences_put_int32(prefs, "age0", static_cast(ageHours)); + + preferences_close(prefs); } -void CemeteryView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { - parent = parentWidget; - app = appInstance; +void cemeteryViewCreateWidgets(lv_obj_t* parentWidget, Context* ctx) { + CemeteryViewState* state = &ctx->cemeteryView; lv_coord_t screenWidth = lv_display_get_horizontal_resolution(nullptr); lv_coord_t screenHeight = lv_display_get_vertical_resolution(nullptr); @@ -77,30 +120,30 @@ void CemeteryView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { int padAll = isSmall ? 4 : (isXLarge ? 16 : 8); int padRow = isSmall ? 4 : (isXLarge ? 12 : 6); - mainWrapper = lv_obj_create(parent); - lv_obj_set_size(mainWrapper, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_pad_all(mainWrapper, padAll, 0); - lv_obj_set_style_pad_row(mainWrapper, padRow, 0); - lv_obj_set_style_bg_opa(mainWrapper, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(mainWrapper, 0, 0); - lv_obj_set_flex_flow(mainWrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(mainWrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + state->mainWrapper = lv_obj_create(parentWidget); + lv_obj_set_size(state->mainWrapper, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_all(state->mainWrapper, padAll, 0); + lv_obj_set_style_pad_row(state->mainWrapper, padRow, 0); + lv_obj_set_style_bg_opa(state->mainWrapper, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(state->mainWrapper, 0, 0); + lv_obj_set_flex_flow(state->mainWrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(state->mainWrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); // Title - lv_obj_t* title = lv_label_create(mainWrapper); + lv_obj_t* title = lv_label_create(state->mainWrapper); lv_label_set_text(title, "Pet Cemetery"); lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0); // Load records - PetRecord records[MAX_RECORDS]; - loadRecords(records); + PetRecord records[CEMETERY_MAX_RECORDS]; + cemeteryViewLoadRecords(records); bool anyRecords = false; - for (int i = 0; i < MAX_RECORDS; i++) { + for (int i = 0; i < CEMETERY_MAX_RECORDS; i++) { if (!records[i].valid) continue; anyRecords = true; - lv_obj_t* row = lv_obj_create(mainWrapper); + lv_obj_t* row = lv_obj_create(state->mainWrapper); lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_pad_all(row, isSmall ? 4 : (isXLarge ? 12 : 6), 0); lv_obj_set_style_bg_color(row, lv_color_hex(0x2a2a4e), 0); @@ -122,14 +165,12 @@ void CemeteryView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { } if (!anyRecords) { - lv_obj_t* emptyLabel = lv_label_create(mainWrapper); + lv_obj_t* emptyLabel = lv_label_create(state->mainWrapper); lv_label_set_text(emptyLabel, "No records yet."); lv_obj_set_style_text_color(emptyLabel, lv_palette_lighten(LV_PALETTE_GREY, 3), 0); } } -void CemeteryView::onStop() { - mainWrapper = nullptr; - parent = nullptr; - app = nullptr; +void cemeteryViewStop(Context* ctx) { + ctx->cemeteryView.mainWrapper = nullptr; } diff --git a/Apps/TamaTac/main/Source/CemeteryView.h b/Apps/TamaTac/main/Source/CemeteryView.h index 829ac4c..9791e31 100644 --- a/Apps/TamaTac/main/Source/CemeteryView.h +++ b/Apps/TamaTac/main/Source/CemeteryView.h @@ -8,7 +8,7 @@ #include #include "PetStats.h" -class TamaTac; +struct Context; // Record of a deceased pet struct PetRecord { @@ -18,23 +18,17 @@ struct PetRecord { bool valid = false; }; -class CemeteryView { -public: - static constexpr int MAX_RECORDS = 5; +constexpr int CEMETERY_MAX_RECORDS = 5; -private: - TamaTac* app = nullptr; - lv_obj_t* parent = nullptr; +struct CemeteryViewState { lv_obj_t* mainWrapper = nullptr; +}; +void cemeteryViewCreateWidgets(lv_obj_t* parentWidget, Context* ctx); +void cemeteryViewStop(Context* ctx); -public: - void onStart(lv_obj_t* parentWidget, TamaTac* appInstance); - void onStop(); - - // Record a pet death (called from TamaTac when pet dies) - static void recordDeath(Personality personality, LifeStage stage, uint16_t ageHours); +// Record a pet death (called from TamaTac when pet dies) +void cemeteryViewRecordDeath(Personality personality, LifeStage stage, uint16_t ageHours); - // Load all records - static void loadRecords(PetRecord records[MAX_RECORDS]); -}; +// Load all records +void cemeteryViewLoadRecords(PetRecord records[CEMETERY_MAX_RECORDS]); diff --git a/Apps/TamaTac/main/Source/MainView.cpp b/Apps/TamaTac/main/Source/MainView.cpp index 9833c2c..2080728 100644 --- a/Apps/TamaTac/main/Source/MainView.cpp +++ b/Apps/TamaTac/main/Source/MainView.cpp @@ -5,15 +5,321 @@ #include "MainView.h" #include "TamaTac.h" +#include #include #include extern lv_color_t TamaTac_canvasBuffer[72 * 72]; extern lv_color_t TamaTac_iconBuffers[12][16 * 16]; -void MainView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { - parent = parentWidget; - app = appInstance; +namespace { + +SpriteId getSpriteForCurrentState(const PetStats& stats) { + if (stats.isDead) { + return SPRITE_GHOST; + } + + // Egg always shows egg — no action/mood sprite overrides + if (stats.stage == LifeStage::Egg) { + return SPRITE_EGG_IDLE; + } + + switch (stats.currentAnim) { + case AnimState::Eating: return SPRITE_EATING; + case AnimState::Playing: return SPRITE_PLAYING; + case AnimState::Sleeping: return SPRITE_SLEEPING; + case AnimState::Sick: return SPRITE_SICK; + case AnimState::Sad: return SPRITE_SAD; + default: break; + } + + if (stats.isSick) { + return SPRITE_SICK; + } + + if (stats.isAsleep) { + return SPRITE_SLEEPING; + } + + if (stats.happiness >= 70) { + return SPRITE_HAPPY; + } else if (stats.happiness < 30) { + return SPRITE_SAD; + } + + switch (stats.stage) { + case LifeStage::Baby: return SPRITE_BABY_IDLE; + case LifeStage::Teen: return SPRITE_TEEN_IDLE; + case LifeStage::Adult: return SPRITE_ADULT_IDLE; + case LifeStage::Elder: return SPRITE_ELDER_IDLE; + case LifeStage::Ghost: return SPRITE_GHOST; + default: break; + } + + return SPRITE_EGG_IDLE; +} + +void drawOverlays(MainViewState* state, SpriteId spriteId, const PetStats* stats) { + if (state->petCanvas == nullptr) return; + + int canvasSize = SPRITE_WIDTH * state->spriteScale; + uint32_t now = tt::kernel::getMillis(); + lv_color_t white = lv_color_hex(0xFFFFFF); + lv_color_t gray = lv_color_hex(0xAAAAAA); + + // Floating Z's during sleep + if (spriteId == SPRITE_SLEEPING) { + // Two Z's at different positions, bobbing up and down + int phase = (now / 500) % 4; // 4-step bob cycle + int bobOffset = (phase < 2) ? phase : (4 - phase); // 0,1,2,1 pattern + + // Large Z (top-right area) + int zx = canvasSize - 8 * state->spriteScale / 3; + int zy = 2 + bobOffset; + if (zx >= 0 && zx + 3 < canvasSize && zy + 3 < canvasSize) { + // Z shape: top bar, diagonal, bottom bar + lv_canvas_set_px(state->petCanvas, zx, zy, white, LV_OPA_COVER); + lv_canvas_set_px(state->petCanvas, zx + 1, zy, white, LV_OPA_COVER); + lv_canvas_set_px(state->petCanvas, zx + 2, zy, white, LV_OPA_COVER); + lv_canvas_set_px(state->petCanvas, zx + 1, zy + 1, white, LV_OPA_COVER); + lv_canvas_set_px(state->petCanvas, zx, zy + 2, white, LV_OPA_COVER); + lv_canvas_set_px(state->petCanvas, zx + 1, zy + 2, white, LV_OPA_COVER); + lv_canvas_set_px(state->petCanvas, zx + 2, zy + 2, white, LV_OPA_COVER); + } + + // Small z (offset from large Z, staggered bob) + int zx2 = zx - 5; + int bobOffset2 = ((phase + 2) % 4 < 2) ? 0 : 1; + int zy2 = zy + 4 + bobOffset2; + if (zx2 >= 0 && zx2 + 2 < canvasSize && zy2 >= 0 && zy2 + 2 < canvasSize) { + lv_canvas_set_px(state->petCanvas, zx2, zy2, gray, LV_OPA_COVER); + lv_canvas_set_px(state->petCanvas, zx2 + 1, zy2, gray, LV_OPA_COVER); + lv_canvas_set_px(state->petCanvas, zx2, zy2 + 1, gray, LV_OPA_COVER); + lv_canvas_set_px(state->petCanvas, zx2 + 1, zy2 + 1, gray, LV_OPA_COVER); + } + } + + // Mood indicator: small colored dot in bottom-right corner + if (stats != nullptr && !stats->isDead) { + lv_color_t moodColor; + bool showMood = true; + + if (stats->isSick) { + moodColor = lv_color_hex(0xFF0000); // Red for sick + } else if (stats->hunger < 30 || stats->happiness < 30 || stats->energy < 30) { + moodColor = lv_color_hex(0xFF8800); // Orange for warning + } else if (stats->hunger >= 70 && stats->happiness >= 70 && stats->health >= 70 && stats->energy >= 70) { + moodColor = lv_color_hex(0x00FF00); // Green for happy + } else { + showMood = false; // Neutral — no indicator + } + + if (showMood) { + // 2x2 dot in bottom-right corner + int mx = canvasSize - 3; + int my = canvasSize - 3; + for (int dy = 0; dy < 2; dy++) { + for (int dx = 0; dx < 2; dx++) { + lv_canvas_set_px(state->petCanvas, mx + dx, my + dy, moodColor, LV_OPA_COVER); + } + } + } + } +} + +void drawSprite(MainViewState* state, SpriteId spriteId, const PetStats* stats) { + if (state->petCanvas == nullptr) return; + + uint32_t now = tt::kernel::getMillis(); + int canvasSize = SPRITE_WIDTH * state->spriteScale; + if (canvasSize > state->petCanvasSize) return; + + // Evolution flash: fill entire canvas with white + if (state->evolutionFlashUntil > 0 && now < state->evolutionFlashUntil) { + lv_canvas_fill_bg(state->petCanvas, lv_color_hex(0xFFFFFF), LV_OPA_COVER); + return; + } else if (state->evolutionFlashUntil > 0) { + state->evolutionFlashUntil = 0; + } + + // Fill background + lv_color_t bgColor = (state->currentDayPhase == DayPhase::Night) ? lv_color_hex(0x1a1a2e) : lv_color_hex(0x333333); + lv_canvas_fill_bg(state->petCanvas, bgColor, LV_OPA_COVER); + + // Night stars: draw twinkly dots in the background + if (state->currentDayPhase == DayPhase::Night) { + // Update star seed every 2 seconds for twinkling effect + uint32_t starPhase = now / 2000; + uint32_t seed = starPhase * 7919; // Simple deterministic hash + for (int i = 0; i < 6; i++) { + seed = seed * 1103515245 + 12345; // LCG + int sx = (seed >> 16) % canvasSize; + seed = seed * 1103515245 + 12345; + int sy = (seed >> 16) % (canvasSize / 3); // Stars only in top third + // Alternate bright/dim stars + lv_color_t starColor = (i % 2 == 0) ? lv_color_hex(0xFFFFFF) : lv_color_hex(0x888899); + lv_canvas_set_px(state->petCanvas, sx, sy, starColor, LV_OPA_COVER); + } + } + + // Determine current animation frame + const AnimatedSprite& anim = getAnimSprite(spriteId); + int frameIdx = 0; + if (anim.frameCount > 1 && anim.frameDelayMs > 0) { + uint32_t elapsed = now - state->animStartTime; + if (anim.loop) { + frameIdx = (elapsed / anim.frameDelayMs) % anim.frameCount; + } else { + frameIdx = elapsed / anim.frameDelayMs; + if (frameIdx >= anim.frameCount) frameIdx = anim.frameCount - 1; + } + } + + // Render RGB565 sprite with transparency and scaling + const uint16_t* pixelData = anim.frames[frameIdx].data; + for (int y = 0; y < SPRITE_HEIGHT; y++) { + for (int x = 0; x < SPRITE_WIDTH; x++) { + uint16_t pixel = pixelData[y * SPRITE_WIDTH + x]; + if (pixel == SPRITE_TRANSPARENT) continue; + + uint8_t r = (pixel >> 11) << 3; + uint8_t g = ((pixel >> 5) & 0x3F) << 2; + uint8_t b = (pixel & 0x1F) << 3; + lv_color_t color = lv_color_make(r, g, b); + + for (int dy = 0; dy < state->spriteScale; dy++) { + for (int dx = 0; dx < state->spriteScale; dx++) { + lv_canvas_set_px(state->petCanvas, + x * state->spriteScale + dx, + y * state->spriteScale + dy, + color, LV_OPA_COVER); + } + } + } + } + + // Draw overlays (Z's, mood indicator) + drawOverlays(state, spriteId, stats); +} + +void drawIconWithBg(lv_obj_t* canvas, IconId iconId, lv_color_t bgColor, lv_color_t fgColor) { + if (canvas == nullptr) return; + + const Icon& icon = getIcon(iconId); + const uint8_t* data = icon.data; + + if (icon.width > 8 || icon.height > 8) return; + + lv_canvas_fill_bg(canvas, bgColor, LV_OPA_COVER); + + const int scale = 2; + const int width = icon.width; + const int height = icon.height; + + for (int y = 0; y < height; y++) { + uint8_t row = data[y]; + for (int x = 0; x < width; x++) { + bool pixelOn = (row >> (7 - x)) & 1; + + if (pixelOn) { + for (int dy = 0; dy < scale; dy++) { + for (int dx = 0; dx < scale; dx++) { + lv_canvas_set_px(canvas, x * scale + dx, y * scale + dy, fgColor, LV_OPA_COVER); + } + } + } + } + } +} + +void drawIcon(lv_obj_t* canvas, IconId iconId) { + drawIconWithBg(canvas, iconId, lv_color_hex(0x333333), lv_color_hex(0xFFFFFF)); +} + +void onAnimTimer(lv_timer_t* timer) { + auto* ctx = static_cast(lv_timer_get_user_data(timer)); + MainViewState* state = &ctx->mainView; + if (state->petCanvas == nullptr) return; + + // Widgets only exist while this window is topmost - skip otherwise (window_manager deletes + // a buried window's widgets, but this independent lv_timer_t keeps firing regardless; same + // reasoning as GPIO.cpp's periodic status timer). + if (window_manager_get_state(ctx->window) != WINDOW_STATE_GRANTED) return; + + // Pick up deferred reset quickly (200ms vs 5s refresh timer) + if (ctx->pendingResetUI) { + ctx->pendingResetUI = false; + mainViewUpdateUI(ctx); + return; + } + + const PetStats& stats = ctx->petLogic.getStats(); + drawSprite(state, state->currentSpriteId, &stats); +} + +void onRefreshTimer(lv_timer_t* timer) { + auto* ctx = static_cast(lv_timer_get_user_data(timer)); + MainViewState* state = &ctx->mainView; + + if (window_manager_get_state(ctx->window) != WINDOW_STATE_GRANTED) return; + + mainViewUpdateUI(ctx); + + // Display random event messages + RandomEvent event = ctx->petLogic.getLastEvent(); + if (event != RandomEvent::None && state->statusLabel) { + const char* msg = nullptr; + switch (event) { + case RandomEvent::FoundTreat: msg = "Found a treat!"; break; + case RandomEvent::MadeFriend: msg = "Made a friend!"; break; + case RandomEvent::CaughtCold: msg = "Caught a cold!"; break; + case RandomEvent::GotMuddy: msg = "Got muddy!"; break; + case RandomEvent::HadNap: msg = "Had a nap!"; break; + case RandomEvent::SunnyDay: msg = "Sunny day!"; break; + default: break; + } + if (msg) { + lv_label_set_text(state->statusLabel, msg); + } + ctx->petLogic.clearLastEvent(); + } +} + +// Event handlers +void onFeedClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx == nullptr) return; + tamaTacHandleFeedAction(ctx); +} + +void onPlayClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx == nullptr) return; + tamaTacHandlePlayAction(ctx); +} + +void onMedicineClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx == nullptr) return; + tamaTacHandleMedicineAction(ctx); +} + +void onSleepClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx == nullptr) return; + tamaTacHandleSleepAction(ctx); +} + +void onPetTapped(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx == nullptr) return; + tamaTacHandlePetTap(ctx); +} + +} // namespace + +void mainViewCreateWidgets(lv_obj_t* parentWidget, Context* ctx) { + MainViewState* state = &ctx->mainView; // Detect screen size for responsive layout lv_coord_t screenWidth = lv_display_get_horizontal_resolution(nullptr); @@ -27,44 +333,44 @@ void MainView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { int btnWidth, btnHeight, padMain, padRow, poopHeight; if (isSmall) { - petAreaSize = 56; petCanvasSize = 48; spriteScale = 2; // 24*2=48 + petAreaSize = 56; state->petCanvasSize = 48; state->spriteScale = 2; // 24*2=48 statBarWidth = 85; statBarHeight = 5; btnWidth = 54; btnHeight = 32; padMain = 1; padRow = 1; poopHeight = 14; } else if (isXLarge) { - petAreaSize = 136; petCanvasSize = 72; spriteScale = 3; // 24*3=72 + petAreaSize = 136; state->petCanvasSize = 72; state->spriteScale = 3; // 24*3=72 statBarWidth = 300; statBarHeight = 10; btnWidth = 120; btnHeight = 56; padMain = 8; padRow = 6; poopHeight = 24; } else if (isLarge) { - petAreaSize = 100; petCanvasSize = 72; spriteScale = 3; + petAreaSize = 100; state->petCanvasSize = 72; state->spriteScale = 3; statBarWidth = 200; statBarHeight = 8; btnWidth = 90; btnHeight = 50; padMain = 4; padRow = 4; poopHeight = 22; } else { // Medium (default) - petAreaSize = 76; petCanvasSize = 72; spriteScale = 3; + petAreaSize = 76; state->petCanvasSize = 72; state->spriteScale = 3; statBarWidth = 135; statBarHeight = 6; btnWidth = 70; btnHeight = 42; padMain = 1; padRow = 1; poopHeight = 16; } // Initialize animation state - animStartTime = tt::kernel::getMillis(); - currentSpriteId = SPRITE_EGG_IDLE; + state->animStartTime = tt::kernel::getMillis(); + state->currentSpriteId = SPRITE_EGG_IDLE; // Main content wrapper - mainWrapper = lv_obj_create(parent); - lv_obj_set_size(mainWrapper, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_pad_all(mainWrapper, padMain, 0); - lv_obj_set_style_bg_opa(mainWrapper, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(mainWrapper, 0, 0); - lv_obj_set_flex_flow(mainWrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(mainWrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_remove_flag(mainWrapper, LV_OBJ_FLAG_SCROLLABLE); + state->mainWrapper = lv_obj_create(parentWidget); + lv_obj_set_size(state->mainWrapper, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_all(state->mainWrapper, padMain, 0); + lv_obj_set_style_bg_opa(state->mainWrapper, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(state->mainWrapper, 0, 0); + lv_obj_set_flex_flow(state->mainWrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(state->mainWrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_remove_flag(state->mainWrapper, LV_OBJ_FLAG_SCROLLABLE); // Pet and stats container (horizontal layout) - lv_obj_t* petStatsRow = lv_obj_create(mainWrapper); + lv_obj_t* petStatsRow = lv_obj_create(state->mainWrapper); lv_obj_set_size(petStatsRow, LV_PCT(98), LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(petStatsRow, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(petStatsRow, 0, 0); @@ -84,38 +390,38 @@ void MainView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { lv_obj_set_flex_align(petColumn, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); // Pet display area - petArea = lv_obj_create(petColumn); - lv_obj_set_size(petArea, petAreaSize, petAreaSize); - lv_obj_set_style_bg_color(petArea, lv_color_hex(0x333333), 0); - lv_obj_set_style_border_color(petArea, lv_color_hex(0x666666), 0); - lv_obj_set_style_border_width(petArea, isSmall ? 1 : (isXLarge ? 3 : 2), 0); - lv_obj_set_style_pad_all(petArea, 0, 0); + state->petArea = lv_obj_create(petColumn); + lv_obj_set_size(state->petArea, petAreaSize, petAreaSize); + lv_obj_set_style_bg_color(state->petArea, lv_color_hex(0x333333), 0); + lv_obj_set_style_border_color(state->petArea, lv_color_hex(0x666666), 0); + lv_obj_set_style_border_width(state->petArea, isSmall ? 1 : (isXLarge ? 3 : 2), 0); + lv_obj_set_style_pad_all(state->petArea, 0, 0); // Make pet area tappable for pet interaction - lv_obj_add_flag(petArea, LV_OBJ_FLAG_CLICKABLE); - lv_obj_add_event_cb(petArea, onPetTapped, LV_EVENT_CLICKED, app); + lv_obj_add_flag(state->petArea, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(state->petArea, onPetTapped, LV_EVENT_CLICKED, ctx); // Create canvas for sprite rendering - petCanvas = lv_canvas_create(petArea); - lv_canvas_set_buffer(petCanvas, TamaTac_canvasBuffer, petCanvasSize, petCanvasSize, LV_COLOR_FORMAT_NATIVE); - lv_obj_center(petCanvas); + state->petCanvas = lv_canvas_create(state->petArea); + lv_canvas_set_buffer(state->petCanvas, TamaTac_canvasBuffer, state->petCanvasSize, state->petCanvasSize, LV_COLOR_FORMAT_NATIVE); + lv_obj_center(state->petCanvas); // Poop container - poopContainer = lv_obj_create(petColumn); - lv_obj_set_size(poopContainer, petAreaSize, poopHeight); - lv_obj_set_style_bg_opa(poopContainer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(poopContainer, 0, 0); - lv_obj_set_style_pad_all(poopContainer, 1, 0); - lv_obj_set_style_pad_column(poopContainer, isSmall ? 1 : (isXLarge ? 4 : 2), 0); - lv_obj_set_flex_flow(poopContainer, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(poopContainer, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_remove_flag(poopContainer, LV_OBJ_FLAG_SCROLLABLE); + state->poopContainer = lv_obj_create(petColumn); + lv_obj_set_size(state->poopContainer, petAreaSize, poopHeight); + lv_obj_set_style_bg_opa(state->poopContainer, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(state->poopContainer, 0, 0); + lv_obj_set_style_pad_all(state->poopContainer, 1, 0); + lv_obj_set_style_pad_column(state->poopContainer, isSmall ? 1 : (isXLarge ? 4 : 2), 0); + lv_obj_set_flex_flow(state->poopContainer, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(state->poopContainer, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_remove_flag(state->poopContainer, LV_OBJ_FLAG_SCROLLABLE); // Create 3 poop icon canvases for (int i = 0; i < 3; i++) { - poopIcons[i] = lv_canvas_create(poopContainer); - lv_canvas_set_buffer(poopIcons[i], TamaTac_iconBuffers[i], 16, 16, LV_COLOR_FORMAT_NATIVE); - lv_obj_add_flag(poopIcons[i], LV_OBJ_FLAG_HIDDEN); + state->poopIcons[i] = lv_canvas_create(state->poopContainer); + lv_canvas_set_buffer(state->poopIcons[i], TamaTac_iconBuffers[i], 16, 16, LV_COLOR_FORMAT_NATIVE); + lv_obj_add_flag(state->poopIcons[i], LV_OBJ_FLAG_HIDDEN); } // Stat bars container @@ -156,20 +462,20 @@ void MainView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { return iconCanvas; }; - statIcons[0] = createStatRow(statsContainer, 3, ICON_HUNGER, &hungerBar, lv_color_hex(0xFF9900)); - statIcons[1] = createStatRow(statsContainer, 4, ICON_HAPPINESS, &happinessBar, lv_color_hex(0xFFCC00)); - statIcons[2] = createStatRow(statsContainer, 5, ICON_HEALTH, &healthBar, lv_color_hex(0x00FF00)); - statIcons[3] = createStatRow(statsContainer, 6, ICON_ENERGY, &energyBar, lv_color_hex(0x00CCFF)); + state->statIcons[0] = createStatRow(statsContainer, 3, ICON_HUNGER, &state->hungerBar, lv_color_hex(0xFF9900)); + state->statIcons[1] = createStatRow(statsContainer, 4, ICON_HAPPINESS, &state->happinessBar, lv_color_hex(0xFFCC00)); + state->statIcons[2] = createStatRow(statsContainer, 5, ICON_HEALTH, &state->healthBar, lv_color_hex(0x00FF00)); + state->statIcons[3] = createStatRow(statsContainer, 6, ICON_ENERGY, &state->energyBar, lv_color_hex(0x00CCFF)); // Status label - statusLabel = lv_label_create(mainWrapper); - lv_label_set_text(statusLabel, "Your pet is ready!"); - lv_obj_set_style_text_align(statusLabel, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_style_pad_top(statusLabel, 0, 0); - lv_obj_set_style_pad_bottom(statusLabel, 0, 0); + state->statusLabel = lv_label_create(state->mainWrapper); + lv_label_set_text(state->statusLabel, "Your pet is ready!"); + lv_obj_set_style_text_align(state->statusLabel, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_pad_top(state->statusLabel, 0, 0); + lv_obj_set_style_pad_bottom(state->statusLabel, 0, 0); // Button container - lv_obj_t* btnContainer = lv_obj_create(mainWrapper); + lv_obj_t* btnContainer = lv_obj_create(state->mainWrapper); lv_obj_set_width(btnContainer, LV_PCT(100)); lv_obj_set_height(btnContainer, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(btnContainer, LV_OPA_TRANSP, 0); @@ -205,421 +511,118 @@ void MainView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { lv_obj_set_style_text_font(label, lv_font_get_default(), 0); } - lv_obj_add_event_cb(*btn, callback, LV_EVENT_CLICKED, app); + lv_obj_add_event_cb(*btn, callback, LV_EVENT_CLICKED, ctx); }; - createActionBtn(&feedBtn, "Feed", ICON_FEED, 7, onFeedClicked); - createActionBtn(&playBtn, "Play", ICON_PLAY, 8, onPlayClicked); - createActionBtn(&medicineBtn, "Med", ICON_MEDICINE, 9, onMedicineClicked); - createActionBtn(&sleepBtn, "Sleep", ICON_SLEEP, 10, onSleepClicked); + createActionBtn(&state->feedBtn, "Feed", ICON_FEED, 7, onFeedClicked); + createActionBtn(&state->playBtn, "Play", ICON_PLAY, 8, onPlayClicked); + createActionBtn(&state->medicineBtn, "Med", ICON_MEDICINE, 9, onMedicineClicked); + createActionBtn(&state->sleepBtn, "Sleep", ICON_SLEEP, 10, onSleepClicked); // Refresh timer for periodic UI updates (day/night, random events, etc.) - refreshTimer = lv_timer_create(onRefreshTimer, 5000, this); + state->refreshTimer = lv_timer_create(onRefreshTimer, 5000, ctx); // Animation timer (~5fps for smooth frame cycling) - animTimer = lv_timer_create(onAnimTimer, 200, this); + state->animTimer = lv_timer_create(onAnimTimer, 200, ctx); } -void MainView::onStop() { - if (refreshTimer) { - lv_timer_del(refreshTimer); - refreshTimer = nullptr; +void mainViewStop(Context* ctx) { + MainViewState* state = &ctx->mainView; + if (state->refreshTimer) { + lv_timer_del(state->refreshTimer); + state->refreshTimer = nullptr; } - if (animTimer) { - lv_timer_del(animTimer); - animTimer = nullptr; + if (state->animTimer) { + lv_timer_del(state->animTimer); + state->animTimer = nullptr; } - mainWrapper = nullptr; - statusLabel = nullptr; - petArea = nullptr; - petCanvas = nullptr; - poopContainer = nullptr; + state->mainWrapper = nullptr; + state->statusLabel = nullptr; + state->petArea = nullptr; + state->petCanvas = nullptr; + state->poopContainer = nullptr; for (int i = 0; i < 3; i++) { - poopIcons[i] = nullptr; + state->poopIcons[i] = nullptr; } for (int i = 0; i < 4; i++) { - statIcons[i] = nullptr; + state->statIcons[i] = nullptr; } - hungerBar = nullptr; - happinessBar = nullptr; - healthBar = nullptr; - energyBar = nullptr; - feedBtn = nullptr; - playBtn = nullptr; - medicineBtn = nullptr; - sleepBtn = nullptr; - parent = nullptr; - app = nullptr; + state->hungerBar = nullptr; + state->happinessBar = nullptr; + state->healthBar = nullptr; + state->energyBar = nullptr; + state->feedBtn = nullptr; + state->playBtn = nullptr; + state->medicineBtn = nullptr; + state->sleepBtn = nullptr; } -void MainView::updateUI(PetLogic* petLogic, LifeStage& lastKnownStage) { - updateStatBars(petLogic); - updatePetDisplay(petLogic, lastKnownStage); +void mainViewUpdateUI(Context* ctx) { + mainViewUpdateStatBars(ctx); + mainViewUpdatePetDisplay(ctx); } -void MainView::updateStatBars(PetLogic* petLogic) { - if (petLogic == nullptr) return; +void mainViewUpdateStatBars(Context* ctx) { + MainViewState* state = &ctx->mainView; + const PetStats& stats = ctx->petLogic.getStats(); - const PetStats& stats = petLogic->getStats(); - - if (hungerBar) lv_bar_set_value(hungerBar, stats.hunger, LV_ANIM_OFF); - if (happinessBar) lv_bar_set_value(happinessBar, stats.happiness, LV_ANIM_OFF); - if (healthBar) lv_bar_set_value(healthBar, stats.health, LV_ANIM_OFF); - if (energyBar) lv_bar_set_value(energyBar, stats.energy, LV_ANIM_OFF); + if (state->hungerBar) lv_bar_set_value(state->hungerBar, stats.hunger, LV_ANIM_OFF); + if (state->happinessBar) lv_bar_set_value(state->happinessBar, stats.happiness, LV_ANIM_OFF); + if (state->healthBar) lv_bar_set_value(state->healthBar, stats.health, LV_ANIM_OFF); + if (state->energyBar) lv_bar_set_value(state->energyBar, stats.energy, LV_ANIM_OFF); } -void MainView::updatePetDisplay(PetLogic* petLogic, LifeStage& lastKnownStage) { - if (petLogic == nullptr) return; - - const PetStats& stats = petLogic->getStats(); +void mainViewUpdatePetDisplay(Context* ctx) { + MainViewState* state = &ctx->mainView; + const PetStats& stats = ctx->petLogic.getStats(); // Check for evolution (Ghost is death, not evolution) - if (stats.stage != lastKnownStage && statusLabel != nullptr) { - lastKnownStage = stats.stage; + if (stats.stage != ctx->lastKnownStage && state->statusLabel != nullptr) { + ctx->lastKnownStage = stats.stage; if (stats.stage == LifeStage::Ghost) { - lv_label_set_text(statusLabel, LV_SYMBOL_WARNING " Your pet has died..."); + lv_label_set_text(state->statusLabel, LV_SYMBOL_WARNING " Your pet has died..."); } else { char msg[64]; snprintf(msg, sizeof(msg), LV_SYMBOL_UP " Evolved to %s!", lifeStageToString(stats.stage)); - lv_label_set_text(statusLabel, msg); + lv_label_set_text(state->statusLabel, msg); // Trigger evolution flash effect (400ms white flash) - evolutionFlashUntil = tt::kernel::getMillis() + 400; + state->evolutionFlashUntil = tt::kernel::getMillis() + 400; } } // Update day/night visual - DayPhase phase = petLogic->getDayPhase(); - currentDayPhase = phase; - if (petArea) { + DayPhase phase = ctx->petLogic.getDayPhase(); + state->currentDayPhase = phase; + if (state->petArea) { lv_color_t bg = (phase == DayPhase::Night) ? lv_color_hex(0x1a1a2e) : lv_color_hex(0x333333); - lv_obj_set_style_bg_color(petArea, bg, 0); + lv_obj_set_style_bg_color(state->petArea, bg, 0); } // Update current sprite (animation timer will handle actual drawing) SpriteId newSprite = getSpriteForCurrentState(stats); - if (newSprite != currentSpriteId) { - currentSpriteId = newSprite; - animStartTime = tt::kernel::getMillis(); + if (newSprite != state->currentSpriteId) { + state->currentSpriteId = newSprite; + state->animStartTime = tt::kernel::getMillis(); } - drawSprite(currentSpriteId, &stats); + drawSprite(state, state->currentSpriteId, &stats); // Update poop display for (int i = 0; i < 3; i++) { - if (poopIcons[i]) { + if (state->poopIcons[i]) { if (i < stats.poopCount) { - drawIcon(poopIcons[i], ICON_POOP); - lv_obj_clear_flag(poopIcons[i], LV_OBJ_FLAG_HIDDEN); + drawIcon(state->poopIcons[i], ICON_POOP); + lv_obj_clear_flag(state->poopIcons[i], LV_OBJ_FLAG_HIDDEN); } else { - lv_obj_add_flag(poopIcons[i], LV_OBJ_FLAG_HIDDEN); - } - } - } -} - -void MainView::setStatusText(const char* text) { - if (statusLabel) { - lv_label_set_text(statusLabel, text); - } -} - -SpriteId MainView::getSpriteForCurrentState(const PetStats& stats) const { - if (stats.isDead) { - return SPRITE_GHOST; - } - - // Egg always shows egg — no action/mood sprite overrides - if (stats.stage == LifeStage::Egg) { - return SPRITE_EGG_IDLE; - } - - switch (stats.currentAnim) { - case AnimState::Eating: return SPRITE_EATING; - case AnimState::Playing: return SPRITE_PLAYING; - case AnimState::Sleeping: return SPRITE_SLEEPING; - case AnimState::Sick: return SPRITE_SICK; - case AnimState::Sad: return SPRITE_SAD; - default: break; - } - - if (stats.isSick) { - return SPRITE_SICK; - } - - if (stats.isAsleep) { - return SPRITE_SLEEPING; - } - - if (stats.happiness >= 70) { - return SPRITE_HAPPY; - } else if (stats.happiness < 30) { - return SPRITE_SAD; - } - - switch (stats.stage) { - case LifeStage::Baby: return SPRITE_BABY_IDLE; - case LifeStage::Teen: return SPRITE_TEEN_IDLE; - case LifeStage::Adult: return SPRITE_ADULT_IDLE; - case LifeStage::Elder: return SPRITE_ELDER_IDLE; - case LifeStage::Ghost: return SPRITE_GHOST; - default: break; - } - - return SPRITE_EGG_IDLE; -} - -void MainView::drawSprite(SpriteId spriteId, const PetStats* stats) { - if (petCanvas == nullptr) return; - - uint32_t now = tt::kernel::getMillis(); - int canvasSize = SPRITE_WIDTH * spriteScale; - if (canvasSize > petCanvasSize) return; - - // Evolution flash: fill entire canvas with white - if (evolutionFlashUntil > 0 && now < evolutionFlashUntil) { - lv_canvas_fill_bg(petCanvas, lv_color_hex(0xFFFFFF), LV_OPA_COVER); - return; - } else if (evolutionFlashUntil > 0) { - evolutionFlashUntil = 0; - } - - // Fill background - lv_color_t bgColor = (currentDayPhase == DayPhase::Night) ? lv_color_hex(0x1a1a2e) : lv_color_hex(0x333333); - lv_canvas_fill_bg(petCanvas, bgColor, LV_OPA_COVER); - - // Night stars: draw twinkly dots in the background - if (currentDayPhase == DayPhase::Night) { - // Update star seed every 2 seconds for twinkling effect - uint32_t starPhase = now / 2000; - uint32_t seed = starPhase * 7919; // Simple deterministic hash - for (int i = 0; i < 6; i++) { - seed = seed * 1103515245 + 12345; // LCG - int sx = (seed >> 16) % canvasSize; - seed = seed * 1103515245 + 12345; - int sy = (seed >> 16) % (canvasSize / 3); // Stars only in top third - // Alternate bright/dim stars - lv_color_t starColor = (i % 2 == 0) ? lv_color_hex(0xFFFFFF) : lv_color_hex(0x888899); - lv_canvas_set_px(petCanvas, sx, sy, starColor, LV_OPA_COVER); - } - } - - // Determine current animation frame - const AnimatedSprite& anim = getAnimSprite(spriteId); - int frameIdx = 0; - if (anim.frameCount > 1 && anim.frameDelayMs > 0) { - uint32_t elapsed = now - animStartTime; - if (anim.loop) { - frameIdx = (elapsed / anim.frameDelayMs) % anim.frameCount; - } else { - frameIdx = elapsed / anim.frameDelayMs; - if (frameIdx >= anim.frameCount) frameIdx = anim.frameCount - 1; - } - } - - // Render RGB565 sprite with transparency and scaling - const uint16_t* pixelData = anim.frames[frameIdx].data; - for (int y = 0; y < SPRITE_HEIGHT; y++) { - for (int x = 0; x < SPRITE_WIDTH; x++) { - uint16_t pixel = pixelData[y * SPRITE_WIDTH + x]; - if (pixel == SPRITE_TRANSPARENT) continue; - - uint8_t r = (pixel >> 11) << 3; - uint8_t g = ((pixel >> 5) & 0x3F) << 2; - uint8_t b = (pixel & 0x1F) << 3; - lv_color_t color = lv_color_make(r, g, b); - - for (int dy = 0; dy < spriteScale; dy++) { - for (int dx = 0; dx < spriteScale; dx++) { - lv_canvas_set_px(petCanvas, - x * spriteScale + dx, - y * spriteScale + dy, - color, LV_OPA_COVER); - } - } - } - } - - // Draw overlays (Z's, mood indicator) - drawOverlays(spriteId, stats); -} - -void MainView::drawOverlays(SpriteId spriteId, const PetStats* stats) { - if (petCanvas == nullptr) return; - - int canvasSize = SPRITE_WIDTH * spriteScale; - uint32_t now = tt::kernel::getMillis(); - lv_color_t white = lv_color_hex(0xFFFFFF); - lv_color_t gray = lv_color_hex(0xAAAAAA); - - // Floating Z's during sleep - if (spriteId == SPRITE_SLEEPING) { - // Two Z's at different positions, bobbing up and down - int phase = (now / 500) % 4; // 4-step bob cycle - int bobOffset = (phase < 2) ? phase : (4 - phase); // 0,1,2,1 pattern - - // Large Z (top-right area) - int zx = canvasSize - 8 * spriteScale / 3; - int zy = 2 + bobOffset; - if (zx >= 0 && zx + 3 < canvasSize && zy + 3 < canvasSize) { - // Z shape: top bar, diagonal, bottom bar - lv_canvas_set_px(petCanvas, zx, zy, white, LV_OPA_COVER); - lv_canvas_set_px(petCanvas, zx + 1, zy, white, LV_OPA_COVER); - lv_canvas_set_px(petCanvas, zx + 2, zy, white, LV_OPA_COVER); - lv_canvas_set_px(petCanvas, zx + 1, zy + 1, white, LV_OPA_COVER); - lv_canvas_set_px(petCanvas, zx, zy + 2, white, LV_OPA_COVER); - lv_canvas_set_px(petCanvas, zx + 1, zy + 2, white, LV_OPA_COVER); - lv_canvas_set_px(petCanvas, zx + 2, zy + 2, white, LV_OPA_COVER); - } - - // Small z (offset from large Z, staggered bob) - int zx2 = zx - 5; - int bobOffset2 = ((phase + 2) % 4 < 2) ? 0 : 1; - int zy2 = zy + 4 + bobOffset2; - if (zx2 >= 0 && zx2 + 2 < canvasSize && zy2 >= 0 && zy2 + 2 < canvasSize) { - lv_canvas_set_px(petCanvas, zx2, zy2, gray, LV_OPA_COVER); - lv_canvas_set_px(petCanvas, zx2 + 1, zy2, gray, LV_OPA_COVER); - lv_canvas_set_px(petCanvas, zx2, zy2 + 1, gray, LV_OPA_COVER); - lv_canvas_set_px(petCanvas, zx2 + 1, zy2 + 1, gray, LV_OPA_COVER); - } - } - - // Mood indicator: small colored dot in bottom-right corner - if (stats != nullptr && !stats->isDead) { - lv_color_t moodColor; - bool showMood = true; - - if (stats->isSick) { - moodColor = lv_color_hex(0xFF0000); // Red for sick - } else if (stats->hunger < 30 || stats->happiness < 30 || stats->energy < 30) { - moodColor = lv_color_hex(0xFF8800); // Orange for warning - } else if (stats->hunger >= 70 && stats->happiness >= 70 && stats->health >= 70 && stats->energy >= 70) { - moodColor = lv_color_hex(0x00FF00); // Green for happy - } else { - showMood = false; // Neutral — no indicator - } - - if (showMood) { - // 2x2 dot in bottom-right corner - int mx = canvasSize - 3; - int my = canvasSize - 3; - for (int dy = 0; dy < 2; dy++) { - for (int dx = 0; dx < 2; dx++) { - lv_canvas_set_px(petCanvas, mx + dx, my + dy, moodColor, LV_OPA_COVER); - } - } - } - } -} - -void MainView::drawIcon(lv_obj_t* canvas, IconId iconId) { - drawIconWithBg(canvas, iconId, lv_color_hex(0x333333), lv_color_hex(0xFFFFFF)); -} - -void MainView::drawIconWithBg(lv_obj_t* canvas, IconId iconId, lv_color_t bgColor, lv_color_t fgColor) { - if (canvas == nullptr) return; - - const Icon& icon = getIcon(iconId); - const uint8_t* data = icon.data; - - if (icon.width > 8 || icon.height > 8) return; - - lv_canvas_fill_bg(canvas, bgColor, LV_OPA_COVER); - - const int scale = 2; - const int width = icon.width; - const int height = icon.height; - - for (int y = 0; y < height; y++) { - uint8_t row = data[y]; - for (int x = 0; x < width; x++) { - bool pixelOn = (row >> (7 - x)) & 1; - - if (pixelOn) { - for (int dy = 0; dy < scale; dy++) { - for (int dx = 0; dx < scale; dx++) { - lv_canvas_set_px(canvas, x * scale + dx, y * scale + dy, fgColor, LV_OPA_COVER); - } - } + lv_obj_add_flag(state->poopIcons[i], LV_OBJ_FLAG_HIDDEN); } } } } -void MainView::onAnimTimer(lv_timer_t* timer) { - MainView* view = static_cast(lv_timer_get_user_data(timer)); - if (view == nullptr || view->petCanvas == nullptr) return; - - // Pick up deferred reset quickly (200ms vs 5s refresh timer) - if (TamaTac::pendingResetUI) { - TamaTac::pendingResetUI = false; - PetLogic* petLogic = TamaTac::petLogic; - if (petLogic) { - view->updateUI(petLogic, TamaTac::lastKnownStage); - } - return; - } - - const PetStats* stats = TamaTac::petLogic ? &TamaTac::petLogic->getStats() : nullptr; - view->drawSprite(view->currentSpriteId, stats); -} - -void MainView::onRefreshTimer(lv_timer_t* timer) { - MainView* view = static_cast(lv_timer_get_user_data(timer)); - if (view == nullptr || view->app == nullptr) return; - - PetLogic* petLogic = TamaTac::petLogic; - if (petLogic == nullptr) return; - - view->updateUI(petLogic, TamaTac::lastKnownStage); - - // Display random event messages - RandomEvent event = petLogic->getLastEvent(); - if (event != RandomEvent::None && view->statusLabel) { - const char* msg = nullptr; - switch (event) { - case RandomEvent::FoundTreat: msg = "Found a treat!"; break; - case RandomEvent::MadeFriend: msg = "Made a friend!"; break; - case RandomEvent::CaughtCold: msg = "Caught a cold!"; break; - case RandomEvent::GotMuddy: msg = "Got muddy!"; break; - case RandomEvent::HadNap: msg = "Had a nap!"; break; - case RandomEvent::SunnyDay: msg = "Sunny day!"; break; - default: break; - } - if (msg) { - lv_label_set_text(view->statusLabel, msg); - } - petLogic->clearLastEvent(); +void mainViewSetStatusText(Context* ctx, const char* text) { + if (ctx->mainView.statusLabel) { + lv_label_set_text(ctx->mainView.statusLabel, text); } } - -// Event handlers -void MainView::onFeedClicked(lv_event_t* e) { - TamaTac* app = static_cast(lv_event_get_user_data(e)); - if (app == nullptr) return; - app->handleFeedAction(); -} - -void MainView::onPlayClicked(lv_event_t* e) { - TamaTac* app = static_cast(lv_event_get_user_data(e)); - if (app == nullptr) return; - app->handlePlayAction(); -} - -void MainView::onMedicineClicked(lv_event_t* e) { - TamaTac* app = static_cast(lv_event_get_user_data(e)); - if (app == nullptr) return; - app->handleMedicineAction(); -} - -void MainView::onSleepClicked(lv_event_t* e) { - TamaTac* app = static_cast(lv_event_get_user_data(e)); - if (app == nullptr) return; - app->handleSleepAction(); -} - -void MainView::onPetTapped(lv_event_t* e) { - TamaTac* app = static_cast(lv_event_get_user_data(e)); - if (app == nullptr) return; - app->handlePetTap(); -} diff --git a/Apps/TamaTac/main/Source/MainView.h b/Apps/TamaTac/main/Source/MainView.h index 2fa5b3c..6b44f4c 100644 --- a/Apps/TamaTac/main/Source/MainView.h +++ b/Apps/TamaTac/main/Source/MainView.h @@ -8,13 +8,9 @@ #include "PetLogic.h" #include "Sprites.h" -class TamaTac; - -class MainView { -private: - TamaTac* app = nullptr; - lv_obj_t* parent = nullptr; +struct Context; +struct MainViewState { // UI elements lv_obj_t* mainWrapper = nullptr; lv_obj_t* statusLabel = nullptr; @@ -48,29 +44,12 @@ class MainView { // Scaling int spriteScale = 3; int petCanvasSize = 72; +}; -public: - void onStart(lv_obj_t* parentWidget, TamaTac* appInstance); - void onStop(); - - void updateUI(PetLogic* petLogic, LifeStage& lastKnownStage); - void updateStatBars(PetLogic* petLogic); - void updatePetDisplay(PetLogic* petLogic, LifeStage& lastKnownStage); - void setStatusText(const char* text); - -private: - void drawSprite(SpriteId spriteId, const PetStats* stats = nullptr); - void drawOverlays(SpriteId spriteId, const PetStats* stats); - void drawIcon(lv_obj_t* canvas, IconId iconId); - void drawIconWithBg(lv_obj_t* canvas, IconId iconId, lv_color_t bgColor, lv_color_t fgColor); - SpriteId getSpriteForCurrentState(const PetStats& stats) const; +void mainViewCreateWidgets(lv_obj_t* parentWidget, Context* ctx); +void mainViewStop(Context* ctx); - // Static event handlers - static void onFeedClicked(lv_event_t* e); - static void onPlayClicked(lv_event_t* e); - static void onMedicineClicked(lv_event_t* e); - static void onSleepClicked(lv_event_t* e); - static void onPetTapped(lv_event_t* e); - static void onRefreshTimer(lv_timer_t* timer); - static void onAnimTimer(lv_timer_t* timer); -}; +void mainViewUpdateUI(Context* ctx); +void mainViewUpdateStatBars(Context* ctx); +void mainViewUpdatePetDisplay(Context* ctx); +void mainViewSetStatusText(Context* ctx, const char* text); diff --git a/Apps/TamaTac/main/Source/MenuView.cpp b/Apps/TamaTac/main/Source/MenuView.cpp index 86b6ad1..e482386 100644 --- a/Apps/TamaTac/main/Source/MenuView.cpp +++ b/Apps/TamaTac/main/Source/MenuView.cpp @@ -6,82 +6,75 @@ #include "MenuView.h" #include "TamaTac.h" -void MenuView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { - parent = parentWidget; - app = appInstance; +namespace { - // Detect screen size for responsive layout - // Use display resolution for reliable sizing (parent may not be laid out yet on first load) - lv_coord_t screenWidth = lv_display_get_horizontal_resolution(nullptr); - lv_coord_t screenHeight = lv_display_get_vertical_resolution(nullptr); - bool isSmall = (screenWidth < 280 || screenHeight < 180); - bool isXLarge = (screenWidth >= 600); +void onStatsClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx) showStatsView(ctx); +} - // Main wrapper - mainWrapper = lv_obj_create(parent); - lv_obj_set_size(mainWrapper, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_pad_all(mainWrapper, isSmall ? 4 : (isXLarge ? 16 : 8), 0); - lv_obj_set_style_bg_opa(mainWrapper, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(mainWrapper, 0, 0); - lv_obj_set_flex_flow(mainWrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(mainWrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_remove_flag(mainWrapper, LV_OBJ_FLAG_SCROLLABLE); +void onSettingsClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx) showSettingsView(ctx); +} - // Menu list - menuList = lv_list_create(mainWrapper); - lv_obj_set_size(menuList, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(menuList, isSmall ? 2 : (isXLarge ? 8 : 4), 0); - lv_obj_set_style_bg_color(menuList, lv_color_hex(0x1a1a2e), 0); - lv_obj_set_style_border_width(menuList, isXLarge ? 2 : 1, 0); - lv_obj_set_style_border_color(menuList, lv_color_hex(0x3a3a5e), 0); - lv_obj_set_style_radius(menuList, isSmall ? 4 : (isXLarge ? 12 : 8), 0); +void onCemeteryClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx) showCemeteryView(ctx); +} - addStyledListBtn(LV_SYMBOL_LIST, "Stats", onStatsClicked); - addStyledListBtn(LV_SYMBOL_SETTINGS, "Settings", onSettingsClicked); - addStyledListBtn(LV_SYMBOL_EYE_OPEN, "Cemetery", onCemeteryClicked); - addStyledListBtn(LV_SYMBOL_OK, "Achievements", onAchievementsClicked); +void onAchievementsClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx) showAchievementsView(ctx); } -lv_obj_t* MenuView::addStyledListBtn(const char* icon, const char* text, lv_event_cb_t cb) { +lv_obj_t* addStyledListBtn(Context* ctx, lv_obj_t* menuList, const char* icon, const char* text, lv_event_cb_t cb) { lv_obj_t* btn = lv_list_add_btn(menuList, icon, text); - lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, ctx); lv_obj_set_style_bg_color(btn, lv_color_hex(0x2a2a4e), LV_STATE_DEFAULT); lv_obj_set_style_bg_color(btn, lv_color_hex(0x4a4a7e), LV_STATE_PRESSED); lv_obj_set_style_text_color(btn, lv_color_hex(0xFFFFFF), 0); return btn; } -void MenuView::onStop() { - mainWrapper = nullptr; - menuList = nullptr; - parent = nullptr; - app = nullptr; -} +} // namespace -void MenuView::onStatsClicked(lv_event_t* e) { - MenuView* view = static_cast(lv_event_get_user_data(e)); - if (view && view->app) { - view->app->showStatsView(); - } -} +void menuViewCreateWidgets(lv_obj_t* parentWidget, Context* ctx) { + MenuViewState* state = &ctx->menuView; -void MenuView::onSettingsClicked(lv_event_t* e) { - MenuView* view = static_cast(lv_event_get_user_data(e)); - if (view && view->app) { - view->app->showSettingsView(); - } -} + // Detect screen size for responsive layout + // Use display resolution for reliable sizing (parent may not be laid out yet on first load) + lv_coord_t screenWidth = lv_display_get_horizontal_resolution(nullptr); + lv_coord_t screenHeight = lv_display_get_vertical_resolution(nullptr); + bool isSmall = (screenWidth < 280 || screenHeight < 180); + bool isXLarge = (screenWidth >= 600); + + // Main wrapper + state->mainWrapper = lv_obj_create(parentWidget); + lv_obj_set_size(state->mainWrapper, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_all(state->mainWrapper, isSmall ? 4 : (isXLarge ? 16 : 8), 0); + lv_obj_set_style_bg_opa(state->mainWrapper, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(state->mainWrapper, 0, 0); + lv_obj_set_flex_flow(state->mainWrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(state->mainWrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_remove_flag(state->mainWrapper, LV_OBJ_FLAG_SCROLLABLE); + + // Menu list + state->menuList = lv_list_create(state->mainWrapper); + lv_obj_set_size(state->menuList, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(state->menuList, isSmall ? 2 : (isXLarge ? 8 : 4), 0); + lv_obj_set_style_bg_color(state->menuList, lv_color_hex(0x1a1a2e), 0); + lv_obj_set_style_border_width(state->menuList, isXLarge ? 2 : 1, 0); + lv_obj_set_style_border_color(state->menuList, lv_color_hex(0x3a3a5e), 0); + lv_obj_set_style_radius(state->menuList, isSmall ? 4 : (isXLarge ? 12 : 8), 0); -void MenuView::onCemeteryClicked(lv_event_t* e) { - MenuView* view = static_cast(lv_event_get_user_data(e)); - if (view && view->app) { - view->app->showCemeteryView(); - } + addStyledListBtn(ctx, state->menuList, LV_SYMBOL_LIST, "Stats", onStatsClicked); + addStyledListBtn(ctx, state->menuList, LV_SYMBOL_SETTINGS, "Settings", onSettingsClicked); + addStyledListBtn(ctx, state->menuList, LV_SYMBOL_EYE_OPEN, "Cemetery", onCemeteryClicked); + addStyledListBtn(ctx, state->menuList, LV_SYMBOL_OK, "Achievements", onAchievementsClicked); } -void MenuView::onAchievementsClicked(lv_event_t* e) { - MenuView* view = static_cast(lv_event_get_user_data(e)); - if (view && view->app) { - view->app->showAchievementsView(); - } +void menuViewStop(Context* ctx) { + ctx->menuView.mainWrapper = nullptr; + ctx->menuView.menuList = nullptr; } diff --git a/Apps/TamaTac/main/Source/MenuView.h b/Apps/TamaTac/main/Source/MenuView.h index 89c15fd..14c34e5 100644 --- a/Apps/TamaTac/main/Source/MenuView.h +++ b/Apps/TamaTac/main/Source/MenuView.h @@ -6,33 +6,12 @@ #include -class TamaTac; +struct Context; -class MenuView { -private: - TamaTac* app = nullptr; - lv_obj_t* parent = nullptr; - - // UI elements +struct MenuViewState { lv_obj_t* mainWrapper = nullptr; lv_obj_t* menuList = nullptr; - -public: - MenuView() = default; - ~MenuView() = default; - - MenuView(const MenuView&) = delete; - MenuView& operator=(const MenuView&) = delete; - - void onStart(lv_obj_t* parentWidget, TamaTac* appInstance); - void onStop(); - - lv_obj_t* addStyledListBtn(const char* icon, const char* text, lv_event_cb_t cb); - -private: - // Static event handlers - static void onStatsClicked(lv_event_t* e); - static void onSettingsClicked(lv_event_t* e); - static void onCemeteryClicked(lv_event_t* e); - static void onAchievementsClicked(lv_event_t* e); }; + +void menuViewCreateWidgets(lv_obj_t* parentWidget, Context* ctx); +void menuViewStop(Context* ctx); diff --git a/Apps/TamaTac/main/Source/PatternGame.cpp b/Apps/TamaTac/main/Source/PatternGame.cpp index f82a055..2dbcf9a 100644 --- a/Apps/TamaTac/main/Source/PatternGame.cpp +++ b/Apps/TamaTac/main/Source/PatternGame.cpp @@ -6,297 +6,310 @@ #include "PatternGame.h" #include "TamaTac.h" #include "SfxEngine.h" +#include #include #include -void PatternGame::onStart(lv_obj_t* parent, TamaTac* appInstance) { - app = appInstance; +namespace { - // Screen size detection - lv_coord_t screenWidth = lv_display_get_horizontal_resolution(nullptr); - lv_coord_t screenHeight = lv_display_get_vertical_resolution(nullptr); - bool isSmall = (screenWidth < 280 || screenHeight < 180); - bool isXLarge = (screenWidth >= 600); - bool isLarge = !isXLarge && (screenWidth >= 400 && screenHeight >= 300); - - // Scaled dimensions - int btnSize = isSmall ? 50 : (isXLarge ? 120 : (isLarge ? 90 : 70)); - int gap = isSmall ? 4 : (isXLarge ? 12 : (isLarge ? 8 : 6)); - int pad = isSmall ? 4 : (isXLarge ? 16 : 8); - int radius = isSmall ? 6 : (isXLarge ? 16 : 10); - - // Main wrapper - lv_obj_t* wrapper = lv_obj_create(parent); - lv_obj_set_size(wrapper, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_pad_all(wrapper, pad, 0); - lv_obj_set_style_bg_opa(wrapper, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(wrapper, 0, 0); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_row(wrapper, gap, 0); - lv_obj_remove_flag(wrapper, LV_OBJ_FLAG_SCROLLABLE); - - // Status label - statusLabel = lv_label_create(wrapper); - lv_label_set_text(statusLabel, "Get ready..."); - lv_obj_set_style_text_align(statusLabel, LV_TEXT_ALIGN_CENTER, 0); - - // 2x2 button grid - lv_obj_t* grid = lv_obj_create(wrapper); - int gridSize = btnSize * 2 + gap * 3; - lv_obj_set_size(grid, gridSize, gridSize); - lv_obj_set_style_pad_all(grid, gap, 0); - lv_obj_set_style_pad_row(grid, gap, 0); - lv_obj_set_style_pad_column(grid, gap, 0); - lv_obj_set_style_bg_color(grid, lv_color_hex(0x1a1a2e), 0); - lv_obj_set_style_border_width(grid, 0, 0); - lv_obj_set_style_radius(grid, radius, 0); - lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP); - lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE); +// Button colors +constexpr uint32_t BRIGHT_COLORS[4] = {0xFF4444, 0x4488FF, 0x44DD44, 0xFFDD44}; +constexpr uint32_t DIM_COLORS[4] = {0x661818, 0x182860, 0x186018, 0x605818}; - // Create 4 game buttons - for (int i = 0; i < 4; i++) { - buttons[i] = lv_btn_create(grid); - lv_obj_set_size(buttons[i], btnSize, btnSize); - lv_obj_set_style_bg_color(buttons[i], lv_color_hex(DIM_COLORS[i]), 0); - lv_obj_set_style_bg_color(buttons[i], lv_color_hex(BRIGHT_COLORS[i]), LV_STATE_PRESSED); - lv_obj_set_style_radius(buttons[i], radius, 0); - lv_obj_set_style_border_width(buttons[i], 0, 0); - lv_obj_set_style_shadow_width(buttons[i], 0, 0); - lv_obj_add_event_cb(buttons[i], onButtonClicked, LV_EVENT_CLICKED, this); +void highlightButton(PatternGameState* state, int index) { + if (index >= 0 && index < 4 && state->buttons[index]) { + lv_obj_set_style_bg_color(state->buttons[index], lv_color_hex(BRIGHT_COLORS[index]), 0); } - - // Initialize game - round = 0; - patternLength = START_LENGTH; - acceptingInput = false; - generatePattern(); - startRound(); -} - -void PatternGame::onStop() { - clearTimers(); - statusLabel = nullptr; - for (int i = 0; i < 4; i++) buttons[i] = nullptr; - app = nullptr; } -void PatternGame::clearTimers() { - if (sequenceTimer) { - lv_timer_del(sequenceTimer); - sequenceTimer = nullptr; - } - if (delayTimer) { - lv_timer_del(delayTimer); - delayTimer = nullptr; +void dimButton(PatternGameState* state, int index) { + if (index >= 0 && index < 4 && state->buttons[index]) { + lv_obj_set_style_bg_color(state->buttons[index], lv_color_hex(DIM_COLORS[index]), 0); } } -void PatternGame::generatePattern() { - for (int i = 0; i < MAX_PATTERN; i++) { - pattern[i] = rand() % 4; +void dimAllButtons(PatternGameState* state) { + for (int i = 0; i < 4; i++) { + dimButton(state, i); } } -void PatternGame::startRound() { - acceptingInput = false; - dimAllButtons(); - - char msg[32]; - snprintf(msg, sizeof(msg), "Round %d - Watch!", round + 1); - if (statusLabel) lv_label_set_text(statusLabel, msg); - - // Delay before showing pattern - pendingAction = DelayAction::StartSequence; - scheduleDelay(800); -} - -void PatternGame::beginSequenceDisplay() { - showIndex = 0; - showPhase = false; - sequenceTimer = lv_timer_create(onSequenceTick, 350, this); -} - -void PatternGame::startInputPhase() { - acceptingInput = true; - inputIndex = 0; - if (statusLabel) lv_label_set_text(statusLabel, "Your turn!"); +void clearTimers(PatternGameState* state) { + if (state->sequenceTimer) { + lv_timer_del(state->sequenceTimer); + state->sequenceTimer = nullptr; + } + if (state->delayTimer) { + lv_timer_del(state->delayTimer); + state->delayTimer = nullptr; + } } -void PatternGame::scheduleDelay(uint32_t ms) { - if (delayTimer) { - lv_timer_del(delayTimer); - delayTimer = nullptr; +void generatePattern(PatternGameState* state) { + for (int i = 0; i < 8; i++) { + state->pattern[i] = rand() % 4; } - delayTimer = lv_timer_create(onDelayDone, ms, this); - lv_timer_set_repeat_count(delayTimer, 1); } -//============================================================================== -// Timer Callbacks -//============================================================================== +void scheduleDelay(Context* ctx, uint32_t ms); +void startRound(Context* ctx); +void beginSequenceDisplay(Context* ctx); +void startInputPhase(PatternGameState* state); +void returnToMain(Context* ctx, bool won); -void PatternGame::onSequenceTick(lv_timer_t* timer) { - auto* self = static_cast(lv_timer_get_user_data(timer)); +void onSequenceTick(lv_timer_t* timer) { + auto* ctx = static_cast(lv_timer_get_user_data(timer)); + PatternGameState* state = &ctx->patternGame; - if (self->showPhase) { - // Was highlighting → dim it - self->dimButton(self->pattern[self->showIndex]); - self->showIndex++; - self->showPhase = false; + // Widgets only exist while this window is topmost - skip otherwise (window_manager deletes + // a buried window's widgets, but this independent lv_timer_t keeps firing regardless; same + // reasoning as GPIO.cpp's periodic status timer). + if (window_manager_get_state(ctx->window) != WINDOW_STATE_GRANTED) return; + + if (state->showPhase) { + // Was highlighting -> dim it + dimButton(state, state->pattern[state->showIndex]); + state->showIndex++; + state->showPhase = false; // Check if all shown - if (self->showIndex >= self->patternLength) { - lv_timer_del(self->sequenceTimer); - self->sequenceTimer = nullptr; - self->startInputPhase(); + if (state->showIndex >= state->patternLength) { + lv_timer_del(state->sequenceTimer); + state->sequenceTimer = nullptr; + startInputPhase(state); return; } // Short gap before next highlight - lv_timer_set_period(self->sequenceTimer, 200); + lv_timer_set_period(state->sequenceTimer, 200); } else { - // Gap done → highlight next button - self->highlightButton(self->pattern[self->showIndex]); - self->showPhase = true; + // Gap done -> highlight next button + highlightButton(state, state->pattern[state->showIndex]); + state->showPhase = true; // Play blip for each flash - SfxEngine* se = TamaTac::getSfxEngine(); - if (se) se->play(SfxId::Blip); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Blip); // Longer highlight duration - lv_timer_set_period(self->sequenceTimer, 400); + lv_timer_set_period(state->sequenceTimer, 400); } } -void PatternGame::onDelayDone(lv_timer_t* timer) { - auto* self = static_cast(lv_timer_get_user_data(timer)); - self->delayTimer = nullptr; +void onDelayDone(lv_timer_t* timer) { + auto* ctx = static_cast(lv_timer_get_user_data(timer)); + PatternGameState* state = &ctx->patternGame; + state->delayTimer = nullptr; + + if (window_manager_get_state(ctx->window) != WINDOW_STATE_GRANTED) return; - switch (self->pendingAction) { - case DelayAction::StartSequence: - self->beginSequenceDisplay(); + switch (state->pendingAction) { + case PatternGameState::DelayAction::StartSequence: + beginSequenceDisplay(ctx); break; - case DelayAction::NextRound: - self->startRound(); + case PatternGameState::DelayAction::NextRound: + startRound(ctx); break; - case DelayAction::EndGame: - self->returnToMain(self->gameWon); + case PatternGameState::DelayAction::EndGame: + returnToMain(ctx, state->gameWon); break; } } -//============================================================================== -// Input Handling -//============================================================================== +void scheduleDelay(Context* ctx, uint32_t ms) { + PatternGameState* state = &ctx->patternGame; + if (state->delayTimer) { + lv_timer_del(state->delayTimer); + state->delayTimer = nullptr; + } + state->delayTimer = lv_timer_create(onDelayDone, ms, ctx); + lv_timer_set_repeat_count(state->delayTimer, 1); +} -void PatternGame::onButtonClicked(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (!self->acceptingInput) return; +void startRound(Context* ctx) { + PatternGameState* state = &ctx->patternGame; + state->acceptingInput = false; + dimAllButtons(state); - lv_obj_t* target = lv_event_get_target_obj(e); - for (int i = 0; i < 4; i++) { - if (target == self->buttons[i]) { - self->handleInput(i); - return; - } - } + char msg[32]; + snprintf(msg, sizeof(msg), "Round %d - Watch!", state->round + 1); + if (state->statusLabel) lv_label_set_text(state->statusLabel, msg); + + // Delay before showing pattern + state->pendingAction = PatternGameState::DelayAction::StartSequence; + scheduleDelay(ctx, 800); } -void PatternGame::handleInput(int buttonIndex) { - SfxEngine* se = TamaTac::getSfxEngine(); +void beginSequenceDisplay(Context* ctx) { + PatternGameState* state = &ctx->patternGame; + state->showIndex = 0; + state->showPhase = false; + state->sequenceTimer = lv_timer_create(onSequenceTick, 350, ctx); +} - if (buttonIndex == pattern[inputIndex]) { - // Correct! - if (se) se->play(SfxId::Blip); - inputIndex++; +void startInputPhase(PatternGameState* state) { + state->acceptingInput = true; + state->inputIndex = 0; + if (state->statusLabel) lv_label_set_text(state->statusLabel, "Your turn!"); +} - if (inputIndex >= patternLength) { - // Completed the full pattern - acceptingInput = false; - roundWin(); - } - } else { - // Wrong! - acceptingInput = false; - gameLose(); - } +void returnToMain(Context* ctx, bool won) { + tamaTacOnPatternGameComplete(ctx, ctx->patternGame.round, won); } -void PatternGame::roundWin() { - round++; +void gameWin(Context* ctx) { + PatternGameState* state = &ctx->patternGame; + if (state->statusLabel) lv_label_set_text(state->statusLabel, LV_SYMBOL_OK " You win!"); + dimAllButtons(state); - SfxEngine* se = TamaTac::getSfxEngine(); - if (se) se->play(SfxId::Confirm); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Success); - if (round >= MAX_ROUNDS) { - gameWin(); - return; - } + state->gameWon = true; + state->pendingAction = PatternGameState::DelayAction::EndGame; + scheduleDelay(ctx, 1500); +} - // Show success message, then start next round +void gameLose(Context* ctx) { + PatternGameState* state = &ctx->patternGame; char msg[48]; - snprintf(msg, sizeof(msg), "Correct! Round %d next...", round + 1); - if (statusLabel) lv_label_set_text(statusLabel, msg); + snprintf(msg, sizeof(msg), LV_SYMBOL_CLOSE " Wrong! Rounds: %d/%d", state->round, PATTERN_GAME_MAX_ROUNDS); + if (state->statusLabel) lv_label_set_text(state->statusLabel, msg); + dimAllButtons(state); - patternLength++; - if (patternLength > MAX_PATTERN) patternLength = MAX_PATTERN; + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Error); - pendingAction = DelayAction::NextRound; - scheduleDelay(1200); + state->gameWon = false; + state->pendingAction = PatternGameState::DelayAction::EndGame; + scheduleDelay(ctx, 1500); } -void PatternGame::gameWin() { - if (statusLabel) lv_label_set_text(statusLabel, LV_SYMBOL_OK " You win!"); - dimAllButtons(); +void roundWin(Context* ctx) { + PatternGameState* state = &ctx->patternGame; + state->round++; - SfxEngine* se = TamaTac::getSfxEngine(); - if (se) se->play(SfxId::Success); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Confirm); - gameWon = true; - pendingAction = DelayAction::EndGame; - scheduleDelay(1500); -} + if (state->round >= PATTERN_GAME_MAX_ROUNDS) { + gameWin(ctx); + return; + } -void PatternGame::gameLose() { + // Show success message, then start next round char msg[48]; - snprintf(msg, sizeof(msg), LV_SYMBOL_CLOSE " Wrong! Rounds: %d/%d", round, MAX_ROUNDS); - if (statusLabel) lv_label_set_text(statusLabel, msg); - dimAllButtons(); + snprintf(msg, sizeof(msg), "Correct! Round %d next...", state->round + 1); + if (state->statusLabel) lv_label_set_text(state->statusLabel, msg); - SfxEngine* se = TamaTac::getSfxEngine(); - if (se) se->play(SfxId::Error); + state->patternLength++; + if (state->patternLength > 8) state->patternLength = 8; - gameWon = false; - pendingAction = DelayAction::EndGame; - scheduleDelay(1500); + state->pendingAction = PatternGameState::DelayAction::NextRound; + scheduleDelay(ctx, 1200); } -void PatternGame::returnToMain(bool won) { - if (app) { - app->onPatternGameComplete(round, won); - } -} +void handleInput(Context* ctx, int buttonIndex) { + PatternGameState* state = &ctx->patternGame; -//============================================================================== -// Visual Helpers -//============================================================================== + if (buttonIndex == state->pattern[state->inputIndex]) { + // Correct! + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Blip); + state->inputIndex++; -void PatternGame::highlightButton(int index) { - if (index >= 0 && index < 4 && buttons[index]) { - lv_obj_set_style_bg_color(buttons[index], lv_color_hex(BRIGHT_COLORS[index]), 0); + if (state->inputIndex >= state->patternLength) { + // Completed the full pattern + state->acceptingInput = false; + roundWin(ctx); + } + } else { + // Wrong! + state->acceptingInput = false; + gameLose(ctx); } } -void PatternGame::dimButton(int index) { - if (index >= 0 && index < 4 && buttons[index]) { - lv_obj_set_style_bg_color(buttons[index], lv_color_hex(DIM_COLORS[index]), 0); +void onButtonClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + PatternGameState* state = &ctx->patternGame; + if (!state->acceptingInput) return; + + lv_obj_t* target = lv_event_get_target_obj(e); + for (int i = 0; i < 4; i++) { + if (target == state->buttons[i]) { + handleInput(ctx, i); + return; + } } } -void PatternGame::dimAllButtons() { +} // namespace + +void patternGameCreateWidgets(lv_obj_t* parent, Context* ctx) { + PatternGameState* state = &ctx->patternGame; + + // Screen size detection + lv_coord_t screenWidth = lv_display_get_horizontal_resolution(nullptr); + lv_coord_t screenHeight = lv_display_get_vertical_resolution(nullptr); + bool isSmall = (screenWidth < 280 || screenHeight < 180); + bool isXLarge = (screenWidth >= 600); + bool isLarge = !isXLarge && (screenWidth >= 400 && screenHeight >= 300); + + // Scaled dimensions + int btnSize = isSmall ? 50 : (isXLarge ? 120 : (isLarge ? 90 : 70)); + int gap = isSmall ? 4 : (isXLarge ? 12 : (isLarge ? 8 : 6)); + int pad = isSmall ? 4 : (isXLarge ? 16 : 8); + int radius = isSmall ? 6 : (isXLarge ? 16 : 10); + + // Main wrapper + lv_obj_t* wrapper = lv_obj_create(parent); + lv_obj_set_size(wrapper, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_all(wrapper, pad, 0); + lv_obj_set_style_bg_opa(wrapper, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(wrapper, 0, 0); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(wrapper, gap, 0); + lv_obj_remove_flag(wrapper, LV_OBJ_FLAG_SCROLLABLE); + + // Status label + state->statusLabel = lv_label_create(wrapper); + lv_label_set_text(state->statusLabel, "Get ready..."); + lv_obj_set_style_text_align(state->statusLabel, LV_TEXT_ALIGN_CENTER, 0); + + // 2x2 button grid + lv_obj_t* grid = lv_obj_create(wrapper); + int gridSize = btnSize * 2 + gap * 3; + lv_obj_set_size(grid, gridSize, gridSize); + lv_obj_set_style_pad_all(grid, gap, 0); + lv_obj_set_style_pad_row(grid, gap, 0); + lv_obj_set_style_pad_column(grid, gap, 0); + lv_obj_set_style_bg_color(grid, lv_color_hex(0x1a1a2e), 0); + lv_obj_set_style_border_width(grid, 0, 0); + lv_obj_set_style_radius(grid, radius, 0); + lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE); + + // Create 4 game buttons for (int i = 0; i < 4; i++) { - dimButton(i); + state->buttons[i] = lv_btn_create(grid); + lv_obj_set_size(state->buttons[i], btnSize, btnSize); + lv_obj_set_style_bg_color(state->buttons[i], lv_color_hex(DIM_COLORS[i]), 0); + lv_obj_set_style_bg_color(state->buttons[i], lv_color_hex(BRIGHT_COLORS[i]), LV_STATE_PRESSED); + lv_obj_set_style_radius(state->buttons[i], radius, 0); + lv_obj_set_style_border_width(state->buttons[i], 0, 0); + lv_obj_set_style_shadow_width(state->buttons[i], 0, 0); + lv_obj_add_event_cb(state->buttons[i], onButtonClicked, LV_EVENT_CLICKED, ctx); } + + // Initialize game + state->round = 0; + state->patternLength = 3; + state->acceptingInput = false; + generatePattern(state); + startRound(ctx); +} + +void patternGameStop(Context* ctx) { + PatternGameState* state = &ctx->patternGame; + clearTimers(state); + state->statusLabel = nullptr; + for (int i = 0; i < 4; i++) state->buttons[i] = nullptr; } diff --git a/Apps/TamaTac/main/Source/PatternGame.h b/Apps/TamaTac/main/Source/PatternGame.h index 23ecf97..dae20ce 100644 --- a/Apps/TamaTac/main/Source/PatternGame.h +++ b/Apps/TamaTac/main/Source/PatternGame.h @@ -7,68 +7,31 @@ #include #include -class TamaTac; - -class PatternGame { -private: - TamaTac* app = nullptr; +struct Context; +struct PatternGameState { // UI elements lv_obj_t* statusLabel = nullptr; lv_obj_t* buttons[4] = {}; lv_timer_t* sequenceTimer = nullptr; lv_timer_t* delayTimer = nullptr; - // Game constants - static constexpr int MAX_PATTERN = 8; - static constexpr int START_LENGTH = 3; - - // Button colors - static constexpr uint32_t BRIGHT_COLORS[4] = {0xFF4444, 0x4488FF, 0x44DD44, 0xFFDD44}; - static constexpr uint32_t DIM_COLORS[4] = {0x661818, 0x182860, 0x186018, 0x605818}; - // Game state - uint8_t pattern[MAX_PATTERN] = {}; - int patternLength = START_LENGTH; + uint8_t pattern[8] = {}; + int patternLength = 3; int showIndex = 0; bool showPhase = false; int inputIndex = 0; int round = 0; bool acceptingInput = false; - // Static callbacks - static void onButtonClicked(lv_event_t* e); - static void onSequenceTick(lv_timer_t* timer); - static void onDelayDone(lv_timer_t* timer); - - // Game logic - void generatePattern(); - void startRound(); - void beginSequenceDisplay(); - void startInputPhase(); - void handleInput(int buttonIndex); - void roundWin(); - void gameWin(); - void gameLose(); - void returnToMain(bool won); - - // Visual helpers - void highlightButton(int index); - void dimButton(int index); - void dimAllButtons(); - - // Timer helpers - void clearTimers(); - void scheduleDelay(uint32_t ms); - // Delay action tracking enum class DelayAction { StartSequence, NextRound, EndGame }; DelayAction pendingAction = DelayAction::StartSequence; bool gameWon = false; +}; -public: - static constexpr int MAX_ROUNDS = 3; +constexpr int PATTERN_GAME_MAX_ROUNDS = 3; - void onStart(lv_obj_t* parent, TamaTac* appInstance); - void onStop(); -}; +void patternGameCreateWidgets(lv_obj_t* parent, Context* ctx); +void patternGameStop(Context* ctx); diff --git a/Apps/TamaTac/main/Source/PetLogic.cpp b/Apps/TamaTac/main/Source/PetLogic.cpp index 9549a26..f3bf0d2 100644 --- a/Apps/TamaTac/main/Source/PetLogic.cpp +++ b/Apps/TamaTac/main/Source/PetLogic.cpp @@ -4,9 +4,24 @@ */ #include "PetLogic.h" -#include +#include +#include #include #include +#include + +namespace { + +bool getPreferencesPath(std::string& outPath) { + char root[128]; + if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) { + return false; + } + outPath = std::string(root) + "/tamatac_pet.properties"; + return true; +} + +} // namespace // Static member initialization (2 = normal 1x speed) uint8_t PetLogic::decayMultiplier = 2; @@ -392,81 +407,101 @@ uint8_t PetLogic::clampStat(int16_t value) { } void PetLogic::saveState() { - Preferences prefs("TamaTac"); + std::string path; + if (!getPreferencesPath(path)) return; + Preferences* prefs = preferences_open(path.c_str()); + if (!prefs) return; - prefs.putInt32("hunger", stats.hunger); - prefs.putInt32("happiness", stats.happiness); - prefs.putInt32("health", stats.health); - prefs.putInt32("energy", stats.energy); + preferences_put_int32(prefs, "hunger", stats.hunger); + preferences_put_int32(prefs, "happiness", stats.happiness); + preferences_put_int32(prefs, "health", stats.health); + preferences_put_int32(prefs, "energy", stats.energy); - prefs.putInt32("cleanliness", stats.cleanliness); - prefs.putInt32("poopCount", stats.poopCount); + preferences_put_int32(prefs, "cleanliness", stats.cleanliness); + preferences_put_int32(prefs, "poopCount", stats.poopCount); - prefs.putInt32("ageSeconds", stats.ageSeconds); - prefs.putInt32("ageHours", stats.ageHours); - prefs.putInt32("lifespan", stats.lifespan); + preferences_put_int32(prefs, "ageSeconds", static_cast(stats.ageSeconds)); + preferences_put_int32(prefs, "ageHours", stats.ageHours); + preferences_put_int32(prefs, "lifespan", static_cast(stats.lifespan)); - prefs.putBool("isSick", stats.isSick); - prefs.putBool("isAsleep", stats.isAsleep); - prefs.putBool("isDead", stats.isDead); + preferences_put_bool(prefs, "isSick", stats.isSick); + preferences_put_bool(prefs, "isAsleep", stats.isAsleep); + preferences_put_bool(prefs, "isDead", stats.isDead); - prefs.putInt32("stage", static_cast(stats.stage)); - prefs.putInt32("currentAnim", static_cast(stats.currentAnim)); - prefs.putInt32("personality", static_cast(stats.personality)); + preferences_put_int32(prefs, "stage", static_cast(stats.stage)); + preferences_put_int32(prefs, "currentAnim", static_cast(stats.currentAnim)); + preferences_put_int32(prefs, "personality", static_cast(stats.personality)); // Note: uint32_t millis stored as int32_t (Preferences API limitation). // Two's complement round-trips correctly; no unsigned API available. - prefs.putInt32("lastSaveTime", static_cast(tt::kernel::getMillis())); + preferences_put_int32(prefs, "lastSaveTime", static_cast(tt::kernel::getMillis())); + + preferences_close(prefs); } bool PetLogic::loadState() { - Preferences prefs("TamaTac"); + std::string path; + if (!getPreferencesPath(path)) return false; + Preferences* prefs = preferences_open(path.c_str()); + if (!prefs) return false; + + auto getInt32 = [&](const char* key, int32_t defaultValue) { + int32_t value = defaultValue; + preferences_opt_int32(prefs, key, &value); + return value; + }; + auto getBool = [&](const char* key, bool defaultValue) { + bool value = defaultValue; + preferences_opt_bool(prefs, key, &value); + return value; + }; // Check if save data exists - int32_t savedHunger = prefs.getInt32("hunger", -1); + int32_t savedHunger = getInt32("hunger", -1); if (savedHunger == -1) { + preferences_close(prefs); return false; } // Clamp loaded values to valid ranges (guard against corrupted preferences) stats.hunger = clampStat(savedHunger); - stats.happiness = clampStat(prefs.getInt32("happiness", 70)); - stats.health = clampStat(prefs.getInt32("health", 100)); - stats.energy = clampStat(prefs.getInt32("energy", 90)); + stats.happiness = clampStat(getInt32("happiness", 70)); + stats.health = clampStat(getInt32("health", 100)); + stats.energy = clampStat(getInt32("energy", 90)); - stats.cleanliness = clampStat(prefs.getInt32("cleanliness", 100)); - int32_t loadedPoopCount = prefs.getInt32("poopCount", 0); + stats.cleanliness = clampStat(getInt32("cleanliness", 100)); + int32_t loadedPoopCount = getInt32("poopCount", 0); stats.poopCount = (loadedPoopCount < 0) ? 0 : (loadedPoopCount > 3) ? 3 : static_cast(loadedPoopCount); // uint32_t fields stored as int32_t (Preferences API limitation); cast back explicitly - stats.ageSeconds = static_cast(prefs.getInt32("ageSeconds", 0)); - stats.ageHours = static_cast(prefs.getInt32("ageHours", 0)); - stats.lifespan = static_cast(prefs.getInt32("lifespan", 0)); + stats.ageSeconds = static_cast(getInt32("ageSeconds", 0)); + stats.ageHours = static_cast(getInt32("ageHours", 0)); + stats.lifespan = static_cast(getInt32("lifespan", 0)); - stats.isSick = prefs.getBool("isSick", false); - stats.isAsleep = prefs.getBool("isAsleep", false); - stats.isDead = prefs.getBool("isDead", false); + stats.isSick = getBool("isSick", false); + stats.isAsleep = getBool("isAsleep", false); + stats.isDead = getBool("isDead", false); - int32_t loadedStage = prefs.getInt32("stage", static_cast(LifeStage::Egg)); + int32_t loadedStage = getInt32("stage", static_cast(LifeStage::Egg)); if (loadedStage < 0 || loadedStage > static_cast(LifeStage::Ghost)) { loadedStage = static_cast(LifeStage::Egg); } stats.stage = static_cast(loadedStage); - int32_t loadedAnim = prefs.getInt32("currentAnim", static_cast(AnimState::Idle)); + int32_t loadedAnim = getInt32("currentAnim", static_cast(AnimState::Idle)); if (loadedAnim < 0 || loadedAnim > static_cast(AnimState::Dead)) { loadedAnim = static_cast(AnimState::Idle); } stats.currentAnim = static_cast(loadedAnim); - int32_t loadedPersonality = prefs.getInt32("personality", 0); + int32_t loadedPersonality = getInt32("personality", 0); if (loadedPersonality < 0 || loadedPersonality > static_cast(Personality::Hardy)) { loadedPersonality = 0; } stats.personality = static_cast(loadedPersonality); // Handle time elapsed since last save - uint32_t lastSaveTime = prefs.getInt32("lastSaveTime", 0); + uint32_t lastSaveTime = static_cast(getInt32("lastSaveTime", 0)); uint32_t currentTime = tt::kernel::getMillis(); if (lastSaveTime > 0 && currentTime > lastSaveTime) { @@ -486,6 +521,7 @@ bool PetLogic::loadState() { stats.lastUpdateTime = currentTime; + preferences_close(prefs); return true; } diff --git a/Apps/TamaTac/main/Source/ReactionGame.cpp b/Apps/TamaTac/main/Source/ReactionGame.cpp index 978bce0..269dfe2 100644 --- a/Apps/TamaTac/main/Source/ReactionGame.cpp +++ b/Apps/TamaTac/main/Source/ReactionGame.cpp @@ -6,218 +6,228 @@ #include "ReactionGame.h" #include "TamaTac.h" #include "SfxEngine.h" +#include #include #include #include -void ReactionGame::onStart(lv_obj_t* parent, TamaTac* appInstance) { - app = appInstance; +namespace { - lv_coord_t screenWidth = lv_display_get_horizontal_resolution(nullptr); - lv_coord_t screenHeight = lv_display_get_vertical_resolution(nullptr); - bool isSmall = (screenWidth < 280 || screenHeight < 180); - bool isXLarge = (screenWidth >= 600); - - int pad = isSmall ? 4 : (isXLarge ? 16 : 8); - int targetSize = isSmall ? 80 : (isXLarge ? 200 : 120); - - // Main wrapper (tappable for early-tap detection) - lv_obj_t* wrapper = lv_obj_create(parent); - lv_obj_set_size(wrapper, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_pad_all(wrapper, pad, 0); - lv_obj_set_style_bg_opa(wrapper, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(wrapper, 0, 0); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_row(wrapper, pad, 0); - lv_obj_remove_flag(wrapper, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(wrapper, LV_OBJ_FLAG_CLICKABLE); - lv_obj_add_event_cb(wrapper, onAreaClicked, LV_EVENT_CLICKED, this); +constexpr uint32_t MIN_DELAY_MS = 1000; +constexpr uint32_t MAX_DELAY_MS = 3500; +constexpr uint32_t GOOD_TIME_MS = 400; +constexpr uint32_t GREAT_TIME_MS = 250; - // Status label - statusLabel = lv_label_create(wrapper); - lv_label_set_text(statusLabel, "Get ready..."); - lv_obj_set_style_text_align(statusLabel, LV_TEXT_ALIGN_CENTER, 0); - - // Target button (hidden initially) - targetBtn = lv_btn_create(wrapper); - lv_obj_set_size(targetBtn, targetSize, targetSize); - lv_obj_set_style_bg_color(targetBtn, lv_color_hex(0x44DD44), 0); - lv_obj_set_style_radius(targetBtn, targetSize / 2, 0); - lv_obj_set_style_border_width(targetBtn, 0, 0); - lv_obj_set_style_shadow_width(targetBtn, 0, 0); - lv_obj_add_flag(targetBtn, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_event_cb(targetBtn, onTargetClicked, LV_EVENT_CLICKED, this); - - lv_obj_t* tapLabel = lv_label_create(targetBtn); - lv_label_set_text(tapLabel, "TAP!"); - lv_obj_center(tapLabel); - - // Initialize game - srand(static_cast(tt::kernel::getMillis())); - round = 0; - score = 0; - phase = Phase::WaitForTarget; - startRound(); -} - -void ReactionGame::onStop() { - clearTimers(); - statusLabel = nullptr; - targetBtn = nullptr; - app = nullptr; -} - -void ReactionGame::clearTimers() { - if (delayTimer) { - lv_timer_del(delayTimer); - delayTimer = nullptr; +void clearTimers(ReactionGameState* state) { + if (state->delayTimer) { + lv_timer_del(state->delayTimer); + state->delayTimer = nullptr; } } -void ReactionGame::scheduleTimer(uint32_t ms) { - clearTimers(); - delayTimer = lv_timer_create(onTimerTick, ms, this); - lv_timer_set_repeat_count(delayTimer, 1); -} +void scheduleTimer(Context* ctx, uint32_t ms); -void ReactionGame::startRound() { - phase = Phase::WaitForTarget; - if (targetBtn) lv_obj_add_flag(targetBtn, LV_OBJ_FLAG_HIDDEN); +void startRound(Context* ctx) { + ReactionGameState* state = &ctx->reactionGame; + state->phase = ReactionGameState::Phase::WaitForTarget; + if (state->targetBtn) lv_obj_add_flag(state->targetBtn, LV_OBJ_FLAG_HIDDEN); char msg[48]; - snprintf(msg, sizeof(msg), "Round %d/%d - Wait...", round + 1, MAX_ROUNDS); - if (statusLabel) lv_label_set_text(statusLabel, msg); + snprintf(msg, sizeof(msg), "Round %d/%d - Wait...", state->round + 1, REACTION_GAME_MAX_ROUNDS); + if (state->statusLabel) lv_label_set_text(state->statusLabel, msg); // Random delay before target appears uint32_t delay = MIN_DELAY_MS + (rand() % (MAX_DELAY_MS - MIN_DELAY_MS)); - scheduleTimer(delay); + scheduleTimer(ctx, delay); } -void ReactionGame::showTarget() { - phase = Phase::TargetShown; - targetShowTime = tt::kernel::getMillis(); +void showTarget(Context* ctx) { + ReactionGameState* state = &ctx->reactionGame; + state->phase = ReactionGameState::Phase::TargetShown; + state->targetShowTime = tt::kernel::getMillis(); - if (targetBtn) lv_obj_clear_flag(targetBtn, LV_OBJ_FLAG_HIDDEN); - if (statusLabel) lv_label_set_text(statusLabel, "TAP NOW!"); + if (state->targetBtn) lv_obj_clear_flag(state->targetBtn, LV_OBJ_FLAG_HIDDEN); + if (state->statusLabel) lv_label_set_text(state->statusLabel, "TAP NOW!"); - SfxEngine* se = TamaTac::getSfxEngine(); - if (se) se->play(SfxId::Blip); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Blip); // Timeout if player doesn't tap within 3 seconds - scheduleTimer(3000); + scheduleTimer(ctx, 3000); } -void ReactionGame::handleTap() { - uint32_t reactionTime = tt::kernel::getMillis() - targetShowTime; - if (targetBtn) lv_obj_add_flag(targetBtn, LV_OBJ_FLAG_HIDDEN); +void returnToMain(Context* ctx) { + ReactionGameState* state = &ctx->reactionGame; + tamaTacOnReactionGameComplete(ctx, state->score, state->score >= REACTION_GAME_MAX_ROUNDS); +} + +void showFinalResult(Context* ctx) { + ReactionGameState* state = &ctx->reactionGame; + char msg[64]; + if (state->score >= REACTION_GAME_MAX_ROUNDS) { + snprintf(msg, sizeof(msg), LV_SYMBOL_OK " Perfect! %d/%d", state->score, REACTION_GAME_MAX_ROUNDS); + } else if (state->score > 0) { + snprintf(msg, sizeof(msg), "Score: %d/%d", state->score, REACTION_GAME_MAX_ROUNDS); + } else { + snprintf(msg, sizeof(msg), LV_SYMBOL_CLOSE " Score: 0/%d", REACTION_GAME_MAX_ROUNDS); + } + if (state->statusLabel) lv_label_set_text(state->statusLabel, msg); + + if (ctx->sfxEngine) ctx->sfxEngine->play(state->score > 0 ? SfxId::Success : SfxId::Error); + + state->phase = ReactionGameState::Phase::Done; + scheduleTimer(ctx, 1500); +} + +void handleTap(Context* ctx) { + ReactionGameState* state = &ctx->reactionGame; + uint32_t reactionTime = tt::kernel::getMillis() - state->targetShowTime; + if (state->targetBtn) lv_obj_add_flag(state->targetBtn, LV_OBJ_FLAG_HIDDEN); const char* rating; if (reactionTime <= GREAT_TIME_MS) { - score++; + state->score++; rating = "GREAT"; } else if (reactionTime <= GOOD_TIME_MS) { - score++; + state->score++; rating = "Good"; } else { rating = "Slow"; } - SfxEngine* se = TamaTac::getSfxEngine(); - if (se) se->play(reactionTime <= GOOD_TIME_MS ? SfxId::Confirm : SfxId::Blip); + if (ctx->sfxEngine) ctx->sfxEngine->play(reactionTime <= GOOD_TIME_MS ? SfxId::Confirm : SfxId::Blip); char msg[64]; snprintf(msg, sizeof(msg), "%s! %ldms", rating, (long)reactionTime); - if (statusLabel) lv_label_set_text(statusLabel, msg); - - round++; - phase = Phase::RoundResult; - scheduleTimer(1500); -} + if (state->statusLabel) lv_label_set_text(state->statusLabel, msg); -void ReactionGame::handleEarlyTap() { - clearTimers(); - if (statusLabel) lv_label_set_text(statusLabel, "Too early!"); - - SfxEngine* se = TamaTac::getSfxEngine(); - if (se) se->play(SfxId::Error); - - round++; - phase = Phase::RoundResult; - scheduleTimer(1500); + state->round++; + state->phase = ReactionGameState::Phase::RoundResult; + scheduleTimer(ctx, 1500); } -void ReactionGame::showFinalResult() { - char msg[64]; - if (score >= MAX_ROUNDS) { - snprintf(msg, sizeof(msg), LV_SYMBOL_OK " Perfect! %d/%d", score, MAX_ROUNDS); - } else if (score > 0) { - snprintf(msg, sizeof(msg), "Score: %d/%d", score, MAX_ROUNDS); - } else { - snprintf(msg, sizeof(msg), LV_SYMBOL_CLOSE " Score: 0/%d", MAX_ROUNDS); - } - if (statusLabel) lv_label_set_text(statusLabel, msg); +void handleEarlyTap(Context* ctx) { + ReactionGameState* state = &ctx->reactionGame; + clearTimers(state); + if (state->statusLabel) lv_label_set_text(state->statusLabel, "Too early!"); - SfxEngine* se = TamaTac::getSfxEngine(); - if (se) se->play(score > 0 ? SfxId::Success : SfxId::Error); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Error); - phase = Phase::Done; - scheduleTimer(1500); + state->round++; + state->phase = ReactionGameState::Phase::RoundResult; + scheduleTimer(ctx, 1500); } -void ReactionGame::returnToMain() { - if (app) { - app->onReactionGameComplete(score, score >= MAX_ROUNDS); - } -} - -//============================================================================== -// Timer Callback -//============================================================================== +void onTimerTick(lv_timer_t* timer) { + auto* ctx = static_cast(lv_timer_get_user_data(timer)); + ReactionGameState* state = &ctx->reactionGame; + state->delayTimer = nullptr; -void ReactionGame::onTimerTick(lv_timer_t* timer) { - auto* self = static_cast(lv_timer_get_user_data(timer)); - self->delayTimer = nullptr; + // Widgets only exist while this window is topmost - skip otherwise (window_manager deletes + // a buried window's widgets, but this independent lv_timer_t keeps firing regardless; same + // reasoning as GPIO.cpp's periodic status timer). + if (window_manager_get_state(ctx->window) != WINDOW_STATE_GRANTED) return; - switch (self->phase) { - case Phase::WaitForTarget: - self->showTarget(); + switch (state->phase) { + case ReactionGameState::Phase::WaitForTarget: + showTarget(ctx); break; - case Phase::TargetShown: - // Timeout — player didn't tap in time - if (self->targetBtn) lv_obj_add_flag(self->targetBtn, LV_OBJ_FLAG_HIDDEN); - if (self->statusLabel) lv_label_set_text(self->statusLabel, "Too slow!"); - self->round++; - self->phase = Phase::RoundResult; - self->scheduleTimer(1500); + case ReactionGameState::Phase::TargetShown: + // Timeout - player didn't tap in time + if (state->targetBtn) lv_obj_add_flag(state->targetBtn, LV_OBJ_FLAG_HIDDEN); + if (state->statusLabel) lv_label_set_text(state->statusLabel, "Too slow!"); + state->round++; + state->phase = ReactionGameState::Phase::RoundResult; + scheduleTimer(ctx, 1500); break; - case Phase::RoundResult: - if (self->round >= MAX_ROUNDS) { - self->showFinalResult(); + case ReactionGameState::Phase::RoundResult: + if (state->round >= REACTION_GAME_MAX_ROUNDS) { + showFinalResult(ctx); } else { - self->startRound(); + startRound(ctx); } break; - case Phase::Done: - self->returnToMain(); + case ReactionGameState::Phase::Done: + returnToMain(ctx); break; } } -//============================================================================== -// Input Handling -//============================================================================== +void scheduleTimer(Context* ctx, uint32_t ms) { + clearTimers(&ctx->reactionGame); + ctx->reactionGame.delayTimer = lv_timer_create(onTimerTick, ms, ctx); + lv_timer_set_repeat_count(ctx->reactionGame.delayTimer, 1); +} -void ReactionGame::onTargetClicked(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (self->phase == Phase::TargetShown) { - self->handleTap(); +void onTargetClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx->reactionGame.phase == ReactionGameState::Phase::TargetShown) { + handleTap(ctx); } } -void ReactionGame::onAreaClicked(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - if (self->phase == Phase::WaitForTarget) { - self->handleEarlyTap(); +void onAreaClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx->reactionGame.phase == ReactionGameState::Phase::WaitForTarget) { + handleEarlyTap(ctx); } } + +} // namespace + +void reactionGameCreateWidgets(lv_obj_t* parent, Context* ctx) { + ReactionGameState* state = &ctx->reactionGame; + + lv_coord_t screenWidth = lv_display_get_horizontal_resolution(nullptr); + lv_coord_t screenHeight = lv_display_get_vertical_resolution(nullptr); + bool isSmall = (screenWidth < 280 || screenHeight < 180); + bool isXLarge = (screenWidth >= 600); + + int pad = isSmall ? 4 : (isXLarge ? 16 : 8); + int targetSize = isSmall ? 80 : (isXLarge ? 200 : 120); + + // Main wrapper (tappable for early-tap detection) + lv_obj_t* wrapper = lv_obj_create(parent); + lv_obj_set_size(wrapper, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_all(wrapper, pad, 0); + lv_obj_set_style_bg_opa(wrapper, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(wrapper, 0, 0); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(wrapper, pad, 0); + lv_obj_remove_flag(wrapper, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(wrapper, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(wrapper, onAreaClicked, LV_EVENT_CLICKED, ctx); + + // Status label + state->statusLabel = lv_label_create(wrapper); + lv_label_set_text(state->statusLabel, "Get ready..."); + lv_obj_set_style_text_align(state->statusLabel, LV_TEXT_ALIGN_CENTER, 0); + + // Target button (hidden initially) + state->targetBtn = lv_btn_create(wrapper); + lv_obj_set_size(state->targetBtn, targetSize, targetSize); + lv_obj_set_style_bg_color(state->targetBtn, lv_color_hex(0x44DD44), 0); + lv_obj_set_style_radius(state->targetBtn, targetSize / 2, 0); + lv_obj_set_style_border_width(state->targetBtn, 0, 0); + lv_obj_set_style_shadow_width(state->targetBtn, 0, 0); + lv_obj_add_flag(state->targetBtn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_event_cb(state->targetBtn, onTargetClicked, LV_EVENT_CLICKED, ctx); + + lv_obj_t* tapLabel = lv_label_create(state->targetBtn); + lv_label_set_text(tapLabel, "TAP!"); + lv_obj_center(tapLabel); + + // Initialize game + srand(static_cast(tt::kernel::getMillis())); + state->round = 0; + state->score = 0; + state->phase = ReactionGameState::Phase::WaitForTarget; + startRound(ctx); +} + +void reactionGameStop(Context* ctx) { + ReactionGameState* state = &ctx->reactionGame; + clearTimers(state); + state->statusLabel = nullptr; + state->targetBtn = nullptr; +} diff --git a/Apps/TamaTac/main/Source/ReactionGame.h b/Apps/TamaTac/main/Source/ReactionGame.h index 0114526..1ae1c11 100644 --- a/Apps/TamaTac/main/Source/ReactionGame.h +++ b/Apps/TamaTac/main/Source/ReactionGame.h @@ -7,23 +7,14 @@ #include #include -class TamaTac; - -class ReactionGame { -private: - TamaTac* app = nullptr; +struct Context; +struct ReactionGameState { // UI elements lv_obj_t* statusLabel = nullptr; lv_obj_t* targetBtn = nullptr; lv_timer_t* delayTimer = nullptr; - // Game constants (MAX_ROUNDS is public for external access) - static constexpr uint32_t MIN_DELAY_MS = 1000; - static constexpr uint32_t MAX_DELAY_MS = 3500; - static constexpr uint32_t GOOD_TIME_MS = 400; - static constexpr uint32_t GREAT_TIME_MS = 250; - // State machine enum class Phase { WaitForTarget, TargetShown, RoundResult, Done }; Phase phase = Phase::WaitForTarget; @@ -32,27 +23,9 @@ class ReactionGame { int round = 0; int score = 0; // 0-3 based on reaction quality uint32_t targetShowTime = 0; // When target appeared (millis) +}; - // Static callbacks - static void onTargetClicked(lv_event_t* e); - static void onAreaClicked(lv_event_t* e); - static void onTimerTick(lv_timer_t* timer); - - // Game logic - void startRound(); - void showTarget(); - void handleTap(); - void handleEarlyTap(); - void showFinalResult(); - void returnToMain(); - - // Timer helpers - void clearTimers(); - void scheduleTimer(uint32_t ms); - -public: - static constexpr int MAX_ROUNDS = 3; +constexpr int REACTION_GAME_MAX_ROUNDS = 3; - void onStart(lv_obj_t* parent, TamaTac* appInstance); - void onStop(); -}; +void reactionGameCreateWidgets(lv_obj_t* parent, Context* ctx); +void reactionGameStop(Context* ctx); diff --git a/Apps/TamaTac/main/Source/SettingsView.cpp b/Apps/TamaTac/main/Source/SettingsView.cpp index 2a511e4..b640c5c 100644 --- a/Apps/TamaTac/main/Source/SettingsView.cpp +++ b/Apps/TamaTac/main/Source/SettingsView.cpp @@ -5,9 +5,22 @@ #include "SettingsView.h" #include "TamaTac.h" -#include +#include +#include +#include -lv_obj_t* SettingsView::createSettingRow(lv_obj_t* parentContainer, const char* labelText, bool isSmall, bool isXLarge) { +namespace { + +bool getPreferencesPath(std::string& outPath) { + char root[128]; + if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) { + return false; + } + outPath = std::string(root) + "/tamatac_settings.properties"; + return true; +} + +lv_obj_t* createSettingRow(lv_obj_t* parentContainer, const char* labelText, bool isSmall, bool isXLarge) { lv_obj_t* row = lv_obj_create(parentContainer); lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); @@ -29,9 +42,43 @@ lv_obj_t* SettingsView::createSettingRow(lv_obj_t* parentContainer, const char* return row; } -void SettingsView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { - parent = parentWidget; - app = appInstance; +void onSoundToggled(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + SettingsViewState* state = &ctx->settingsView; + if (state->soundSwitch == nullptr || state->decayDropdown == nullptr) return; + + bool isChecked = lv_obj_has_state(state->soundSwitch, LV_STATE_CHECKED); + + tamaTacSetSoundEnabled(ctx, isChecked); + + // Read decay speed from UI widget instead of redundant preferences load + DecaySpeed decaySpeed = static_cast(lv_dropdown_get_selected(state->decayDropdown)); + settingsViewSaveSettings(isChecked, decaySpeed); +} + +void onDecayChanged(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + SettingsViewState* state = &ctx->settingsView; + if (state->decayDropdown == nullptr || state->soundSwitch == nullptr) return; + + uint16_t selected = lv_dropdown_get_selected(state->decayDropdown); + + if (selected > 2) { + selected = 1; + } + DecaySpeed newSpeed = static_cast(selected); + + tamaTacSetDecaySpeed(ctx, newSpeed); + + // Read sound state from UI widget instead of redundant preferences load + bool soundEnabled = lv_obj_has_state(state->soundSwitch, LV_STATE_CHECKED); + settingsViewSaveSettings(soundEnabled, newSpeed); +} + +} // namespace + +void settingsViewCreateWidgets(lv_obj_t* parentWidget, Context* ctx) { + SettingsViewState* state = &ctx->settingsView; // Detect screen size for responsive layout // Use display resolution for reliable sizing (parent may not be laid out yet on first load) @@ -43,99 +90,78 @@ void SettingsView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { // Load current settings bool soundEnabled = true; DecaySpeed decaySpeed = DecaySpeed::Normal; - loadSettings(&soundEnabled, &decaySpeed); + settingsViewLoadSettings(&soundEnabled, &decaySpeed); // Scaled dimensions int padAll = isSmall ? 4 : (isXLarge ? 16 : 8); int padRowVal = isSmall ? 4 : (isXLarge ? 16 : 8); // Main wrapper - mainWrapper = lv_obj_create(parent); - lv_obj_set_size(mainWrapper, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_pad_all(mainWrapper, padAll, 0); - lv_obj_set_style_pad_row(mainWrapper, padRowVal, 0); - lv_obj_set_style_bg_opa(mainWrapper, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(mainWrapper, 0, 0); - lv_obj_set_flex_flow(mainWrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(mainWrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_remove_flag(mainWrapper, LV_OBJ_FLAG_SCROLLABLE); + state->mainWrapper = lv_obj_create(parentWidget); + lv_obj_set_size(state->mainWrapper, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_all(state->mainWrapper, padAll, 0); + lv_obj_set_style_pad_row(state->mainWrapper, padRowVal, 0); + lv_obj_set_style_bg_opa(state->mainWrapper, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(state->mainWrapper, 0, 0); + lv_obj_set_flex_flow(state->mainWrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(state->mainWrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_remove_flag(state->mainWrapper, LV_OBJ_FLAG_SCROLLABLE); // Sound setting row - lv_obj_t* soundRow = createSettingRow(mainWrapper, "Sound", isSmall, isXLarge); + lv_obj_t* soundRow = createSettingRow(state->mainWrapper, "Sound", isSmall, isXLarge); - soundSwitch = lv_switch_create(soundRow); - lv_obj_set_style_bg_color(soundSwitch, lv_color_hex(0x1a1a2e), LV_PART_MAIN); - lv_obj_set_style_bg_color(soundSwitch, lv_color_hex(0x00AA00), LV_PART_INDICATOR); + state->soundSwitch = lv_switch_create(soundRow); + lv_obj_set_style_bg_color(state->soundSwitch, lv_color_hex(0x1a1a2e), LV_PART_MAIN); + lv_obj_set_style_bg_color(state->soundSwitch, lv_color_hex(0x00AA00), LV_PART_INDICATOR); if (soundEnabled) { - lv_obj_add_state(soundSwitch, LV_STATE_CHECKED); + lv_obj_add_state(state->soundSwitch, LV_STATE_CHECKED); } - lv_obj_add_event_cb(soundSwitch, onSoundToggled, LV_EVENT_VALUE_CHANGED, this); + lv_obj_add_event_cb(state->soundSwitch, onSoundToggled, LV_EVENT_VALUE_CHANGED, ctx); // Decay speed row - lv_obj_t* decayRow = createSettingRow(mainWrapper, "Speed", isSmall, isXLarge); - - decayDropdown = lv_dropdown_create(decayRow); - lv_dropdown_set_options(decayDropdown, "Slow\nNormal\nFast"); - lv_dropdown_set_selected(decayDropdown, static_cast(decaySpeed)); - lv_obj_set_width(decayDropdown, isSmall ? 80 : (isXLarge ? 140 : 100)); - lv_obj_set_style_bg_color(decayDropdown, lv_color_hex(0x1a1a2e), LV_PART_MAIN); - lv_obj_set_style_text_color(decayDropdown, lv_color_hex(0xFFFFFF), LV_PART_MAIN); - lv_obj_add_event_cb(decayDropdown, onDecayChanged, LV_EVENT_VALUE_CHANGED, this); + lv_obj_t* decayRow = createSettingRow(state->mainWrapper, "Speed", isSmall, isXLarge); + + state->decayDropdown = lv_dropdown_create(decayRow); + lv_dropdown_set_options(state->decayDropdown, "Slow\nNormal\nFast"); + lv_dropdown_set_selected(state->decayDropdown, static_cast(decaySpeed)); + lv_obj_set_width(state->decayDropdown, isSmall ? 80 : (isXLarge ? 140 : 100)); + lv_obj_set_style_bg_color(state->decayDropdown, lv_color_hex(0x1a1a2e), LV_PART_MAIN); + lv_obj_set_style_text_color(state->decayDropdown, lv_color_hex(0xFFFFFF), LV_PART_MAIN); + lv_obj_add_event_cb(state->decayDropdown, onDecayChanged, LV_EVENT_VALUE_CHANGED, ctx); } -void SettingsView::onStop() { - mainWrapper = nullptr; - soundSwitch = nullptr; - decayDropdown = nullptr; - parent = nullptr; - app = nullptr; +void settingsViewStop(Context* ctx) { + SettingsViewState* state = &ctx->settingsView; + state->mainWrapper = nullptr; + state->soundSwitch = nullptr; + state->decayDropdown = nullptr; } -void SettingsView::loadSettings(bool* soundEnabled, DecaySpeed* decaySpeed) { - Preferences prefs("TamaTacSet"); - - *soundEnabled = prefs.getBool("soundOn", true); +void settingsViewLoadSettings(bool* soundEnabled, DecaySpeed* decaySpeed) { + *soundEnabled = true; + int32_t speed = static_cast(DecaySpeed::Normal); + + std::string path; + if (getPreferencesPath(path)) { + if (Preferences* prefs = preferences_open(path.c_str())) { + preferences_opt_bool(prefs, "soundOn", soundEnabled); + preferences_opt_int32(prefs, "decaySpd", &speed); + preferences_close(prefs); + } + } - int32_t speed = prefs.getInt32("decaySpd", static_cast(DecaySpeed::Normal)); if (speed < 0 || speed > 2) { speed = static_cast(DecaySpeed::Normal); } *decaySpeed = static_cast(speed); } -void SettingsView::saveSettings(bool soundEnabled, DecaySpeed decaySpeed) { - Preferences prefs("TamaTacSet"); - prefs.putBool("soundOn", soundEnabled); - prefs.putInt32("decaySpd", static_cast(decaySpeed)); -} - -void SettingsView::onSoundToggled(lv_event_t* e) { - SettingsView* view = static_cast(lv_event_get_user_data(e)); - if (view == nullptr || view->soundSwitch == nullptr || view->decayDropdown == nullptr || view->app == nullptr) return; - - bool isChecked = lv_obj_has_state(view->soundSwitch, LV_STATE_CHECKED); - - view->app->setSoundEnabled(isChecked); - - // Read decay speed from UI widget instead of redundant preferences load - DecaySpeed decaySpeed = static_cast(lv_dropdown_get_selected(view->decayDropdown)); - view->saveSettings(isChecked, decaySpeed); -} - -void SettingsView::onDecayChanged(lv_event_t* e) { - SettingsView* view = static_cast(lv_event_get_user_data(e)); - if (view == nullptr || view->decayDropdown == nullptr || view->soundSwitch == nullptr || view->app == nullptr) return; - - uint16_t selected = lv_dropdown_get_selected(view->decayDropdown); - - if (selected > 2) { - selected = 1; - } - DecaySpeed newSpeed = static_cast(selected); - - view->app->setDecaySpeed(newSpeed); - - // Read sound state from UI widget instead of redundant preferences load - bool soundEnabled = lv_obj_has_state(view->soundSwitch, LV_STATE_CHECKED); - view->saveSettings(soundEnabled, newSpeed); +void settingsViewSaveSettings(bool soundEnabled, DecaySpeed decaySpeed) { + std::string path; + if (!getPreferencesPath(path)) return; + Preferences* prefs = preferences_open(path.c_str()); + if (!prefs) return; + preferences_put_bool(prefs, "soundOn", soundEnabled); + preferences_put_int32(prefs, "decaySpd", static_cast(decaySpeed)); + preferences_close(prefs); } diff --git a/Apps/TamaTac/main/Source/SettingsView.h b/Apps/TamaTac/main/Source/SettingsView.h index 5a315c6..c321e0a 100644 --- a/Apps/TamaTac/main/Source/SettingsView.h +++ b/Apps/TamaTac/main/Source/SettingsView.h @@ -6,7 +6,7 @@ #include -class TamaTac; +struct Context; // Decay speed multipliers enum class DecaySpeed { @@ -15,34 +15,15 @@ enum class DecaySpeed { Fast = 2 // 2x decay }; -class SettingsView { -private: - TamaTac* app = nullptr; - lv_obj_t* parent = nullptr; - - // UI elements +struct SettingsViewState { lv_obj_t* mainWrapper = nullptr; lv_obj_t* soundSwitch = nullptr; lv_obj_t* decayDropdown = nullptr; +}; -public: - SettingsView() = default; - ~SettingsView() = default; - - SettingsView(const SettingsView&) = delete; - SettingsView& operator=(const SettingsView&) = delete; - - void onStart(lv_obj_t* parentWidget, TamaTac* appInstance); - void onStop(); - - // Load/save settings - static void loadSettings(bool* soundEnabled, DecaySpeed* decaySpeed); - static void saveSettings(bool soundEnabled, DecaySpeed decaySpeed); - -private: - lv_obj_t* createSettingRow(lv_obj_t* parentContainer, const char* labelText, bool isSmall = false, bool isXLarge = false); +void settingsViewCreateWidgets(lv_obj_t* parentWidget, Context* ctx); +void settingsViewStop(Context* ctx); - // Static event handlers - static void onSoundToggled(lv_event_t* e); - static void onDecayChanged(lv_event_t* e); -}; +// Load/save settings +void settingsViewLoadSettings(bool* soundEnabled, DecaySpeed* decaySpeed); +void settingsViewSaveSettings(bool soundEnabled, DecaySpeed decaySpeed); diff --git a/Apps/TamaTac/main/Source/StatsView.cpp b/Apps/TamaTac/main/Source/StatsView.cpp index cd1a746..44b9bbb 100644 --- a/Apps/TamaTac/main/Source/StatsView.cpp +++ b/Apps/TamaTac/main/Source/StatsView.cpp @@ -7,7 +7,9 @@ #include "TamaTac.h" #include -lv_obj_t* StatsView::createStatRow(lv_obj_t* parentContainer, const char* labelText, lv_color_t color, bool isXLarge) { +namespace { + +lv_obj_t* createStatRow(lv_obj_t* parentContainer, const char* labelText, lv_color_t color, bool isXLarge) { lv_obj_t* row = lv_obj_create(parentContainer); lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); @@ -29,9 +31,10 @@ lv_obj_t* StatsView::createStatRow(lv_obj_t* parentContainer, const char* labelT return value; } -void StatsView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { - parent = parentWidget; - app = appInstance; +} // namespace + +void statsViewCreateWidgets(lv_obj_t* parentWidget, Context* ctx) { + StatsViewState* state = &ctx->statsView; // Detect screen size for responsive layout // Use display resolution for reliable sizing (parent may not be laid out yet on first load) @@ -45,18 +48,18 @@ void StatsView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { int padRowVal = isSmall ? 4 : (isXLarge ? 16 : 8); // Main content wrapper - mainWrapper = lv_obj_create(parent); - lv_obj_set_size(mainWrapper, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_pad_all(mainWrapper, padAll, 0); - lv_obj_set_style_pad_row(mainWrapper, padRowVal, 0); - lv_obj_set_style_bg_opa(mainWrapper, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(mainWrapper, 0, 0); - lv_obj_set_flex_flow(mainWrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(mainWrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_remove_flag(mainWrapper, LV_OBJ_FLAG_SCROLLABLE); + state->mainWrapper = lv_obj_create(parentWidget); + lv_obj_set_size(state->mainWrapper, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_all(state->mainWrapper, padAll, 0); + lv_obj_set_style_pad_row(state->mainWrapper, padRowVal, 0); + lv_obj_set_style_bg_opa(state->mainWrapper, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(state->mainWrapper, 0, 0); + lv_obj_set_flex_flow(state->mainWrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(state->mainWrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_remove_flag(state->mainWrapper, LV_OBJ_FLAG_SCROLLABLE); // Title row (stage/age on left, status on right) - lv_obj_t* titleRow = lv_obj_create(mainWrapper); + lv_obj_t* titleRow = lv_obj_create(state->mainWrapper); lv_obj_set_width(titleRow, LV_PCT(100)); lv_obj_set_height(titleRow, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(titleRow, LV_OPA_TRANSP, 0); @@ -65,84 +68,81 @@ void StatsView::onStart(lv_obj_t* parentWidget, TamaTac* appInstance) { lv_obj_set_flex_flow(titleRow, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(titleRow, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - titleLabel = lv_label_create(titleRow); - lv_label_set_text(titleLabel, "Egg | 0d 0h"); - lv_obj_set_style_text_color(titleLabel, lv_color_hex(0xFFFFFF), 0); + state->titleLabel = lv_label_create(titleRow); + lv_label_set_text(state->titleLabel, "Egg | 0d 0h"); + lv_obj_set_style_text_color(state->titleLabel, lv_color_hex(0xFFFFFF), 0); - statusLabel = lv_label_create(titleRow); - lv_label_set_text(statusLabel, ""); - lv_obj_set_style_text_color(statusLabel, lv_color_hex(0xFFFFFF), 0); + state->statusLabel = lv_label_create(titleRow); + lv_label_set_text(state->statusLabel, ""); + lv_obj_set_style_text_color(state->statusLabel, lv_color_hex(0xFFFFFF), 0); // Personality row - personalityValue = createStatRow(mainWrapper, "Personality", lv_palette_main(LV_PALETTE_PURPLE), isXLarge); + state->personalityValue = createStatRow(state->mainWrapper, "Personality", lv_palette_main(LV_PALETTE_PURPLE), isXLarge); // Create stat rows with colored values - hungerValue = createStatRow(mainWrapper, "Hunger", lv_color_hex(0xFF9900), isXLarge); - happyValue = createStatRow(mainWrapper, "Happy", lv_color_hex(0xFFCC00), isXLarge); - healthValue = createStatRow(mainWrapper, "Health", lv_color_hex(0x00FF00), isXLarge); - energyValue = createStatRow(mainWrapper, "Energy", lv_color_hex(0x00CCFF), isXLarge); - cleanValue = createStatRow(mainWrapper, "Clean", lv_color_hex(0xFFFFFF), isXLarge); + state->hungerValue = createStatRow(state->mainWrapper, "Hunger", lv_color_hex(0xFF9900), isXLarge); + state->happyValue = createStatRow(state->mainWrapper, "Happy", lv_color_hex(0xFFCC00), isXLarge); + state->healthValue = createStatRow(state->mainWrapper, "Health", lv_color_hex(0x00FF00), isXLarge); + state->energyValue = createStatRow(state->mainWrapper, "Energy", lv_color_hex(0x00CCFF), isXLarge); + state->cleanValue = createStatRow(state->mainWrapper, "Clean", lv_color_hex(0xFFFFFF), isXLarge); } -void StatsView::onStop() { - mainWrapper = nullptr; - titleLabel = nullptr; - statusLabel = nullptr; - hungerValue = nullptr; - happyValue = nullptr; - healthValue = nullptr; - energyValue = nullptr; - cleanValue = nullptr; - personalityValue = nullptr; - parent = nullptr; - app = nullptr; +void statsViewStop(Context* ctx) { + StatsViewState* state = &ctx->statsView; + state->mainWrapper = nullptr; + state->titleLabel = nullptr; + state->statusLabel = nullptr; + state->hungerValue = nullptr; + state->happyValue = nullptr; + state->healthValue = nullptr; + state->energyValue = nullptr; + state->cleanValue = nullptr; + state->personalityValue = nullptr; } -void StatsView::updateStats(PetLogic* petLogic) { - if (petLogic == nullptr || titleLabel == nullptr) return; - if (hungerValue == nullptr || happyValue == nullptr || - healthValue == nullptr || energyValue == nullptr || - cleanValue == nullptr || statusLabel == nullptr) return; +void statsViewUpdateStats(Context* ctx) { + StatsViewState* state = &ctx->statsView; + if (state->titleLabel == nullptr) return; + if (state->hungerValue == nullptr || state->happyValue == nullptr || + state->healthValue == nullptr || state->energyValue == nullptr || + state->cleanValue == nullptr || state->statusLabel == nullptr) return; - const PetStats& stats = petLogic->getStats(); + const PetStats& stats = ctx->petLogic.getStats(); // Update title with stage and age int hours = stats.ageHours; int days = hours / 24; hours = hours % 24; - // Update title (stage and age) char titleText[64]; snprintf(titleText, sizeof(titleText), "%s | %dd %dh", lifeStageToString(stats.stage), days, hours); - lv_label_set_text(titleLabel, titleText); + lv_label_set_text(state->titleLabel, titleText); // Update status label (right-aligned) const char* status = ""; if (stats.isDead) status = "[DEAD]"; else if (stats.isSick) status = "[SICK]"; else if (stats.isAsleep) status = "[SLEEPING]"; - lv_label_set_text(statusLabel, status); + lv_label_set_text(state->statusLabel, status); // Update personality - if (personalityValue) { - lv_label_set_text(personalityValue, personalityToString(stats.personality)); - } + lv_label_set_text(state->personalityValue, personalityToString(stats.personality)); // Update individual stat values char valueText[16]; snprintf(valueText, sizeof(valueText), "%d%%", stats.hunger); - lv_label_set_text(hungerValue, valueText); + lv_label_set_text(state->hungerValue, valueText); snprintf(valueText, sizeof(valueText), "%d%%", stats.happiness); - lv_label_set_text(happyValue, valueText); + lv_label_set_text(state->happyValue, valueText); snprintf(valueText, sizeof(valueText), "%d%%", stats.health); - lv_label_set_text(healthValue, valueText); + lv_label_set_text(state->healthValue, valueText); snprintf(valueText, sizeof(valueText), "%d%%", stats.energy); - lv_label_set_text(energyValue, valueText); + lv_label_set_text(state->energyValue, valueText); snprintf(valueText, sizeof(valueText), "%d%%", stats.cleanliness); - lv_label_set_text(cleanValue, valueText); + lv_label_set_text(state->cleanValue, valueText); } diff --git a/Apps/TamaTac/main/Source/StatsView.h b/Apps/TamaTac/main/Source/StatsView.h index 791fb56..e663642 100644 --- a/Apps/TamaTac/main/Source/StatsView.h +++ b/Apps/TamaTac/main/Source/StatsView.h @@ -7,14 +7,9 @@ #include #include "PetLogic.h" -class TamaTac; +struct Context; -class StatsView { -private: - TamaTac* app = nullptr; - lv_obj_t* parent = nullptr; - - // UI elements +struct StatsViewState { lv_obj_t* mainWrapper = nullptr; lv_obj_t* titleLabel = nullptr; lv_obj_t* statusLabel = nullptr; @@ -26,13 +21,9 @@ class StatsView { lv_obj_t* energyValue = nullptr; lv_obj_t* cleanValue = nullptr; lv_obj_t* personalityValue = nullptr; +}; - // Helper to create stat row - lv_obj_t* createStatRow(lv_obj_t* parentContainer, const char* labelText, lv_color_t color, bool isXLarge = false); - -public: - void onStart(lv_obj_t* parentWidget, TamaTac* appInstance); - void onStop(); +void statsViewCreateWidgets(lv_obj_t* parentWidget, Context* ctx); +void statsViewStop(Context* ctx); - void updateStats(PetLogic* petLogic); -}; +void statsViewUpdateStats(Context* ctx); diff --git a/Apps/TamaTac/main/Source/TamaTac.cpp b/Apps/TamaTac/main/Source/TamaTac.cpp index 1a8a57a..a9f26b5 100644 --- a/Apps/TamaTac/main/Source/TamaTac.cpp +++ b/Apps/TamaTac/main/Source/TamaTac.cpp @@ -6,7 +6,8 @@ #include "TamaTac.h" #include "SpriteData.h" #include -#include +#include +#include #include #include #include @@ -14,255 +15,347 @@ #include #include -// Static member initialization -PetLogic* TamaTac::petLogic = nullptr; -TimerHandle_t TamaTac::updateTimer = nullptr; -SemaphoreHandle_t TamaTac::timerMutex = nullptr; -AppHandle TamaTac::currentApp = nullptr; -AppLaunchId TamaTac::resetDialogId = 0; -LifeStage TamaTac::lastKnownStage = LifeStage::Egg; -SfxEngine* TamaTac::sfxEngine = nullptr; -DecaySpeed TamaTac::decaySpeed = DecaySpeed::Normal; -uint32_t TamaTac::lastPetTime = 0; -bool TamaTac::pendingResetUI = false; +namespace { -// Canvas buffer definitions (shared by views via extern) -// 72x72 supports 24x24 sprites at 3x scale (medium/large/xlarge screens) -lv_color_t TamaTac_canvasBuffer[72 * 72]; -lv_color_t TamaTac_iconBuffers[12][16 * 16]; +constexpr auto* TAG = "TamaTac"; -void TamaTac::onCreate(AppHandle app) { - currentApp = app; +void onCleanClicked(lv_event_t* e); +void onMenuClicked(lv_event_t* e); +void onResetClicked(lv_event_t* e); +void onTimerUpdate(TimerHandle_t timer); - // Seed RNG once so rand() produces different sequences each run - srand(tt::kernel::getMillis()); +// Cleans up whatever view is currently active and, if requested, clears the wrapper widget. +// @a cleanWrapperWidget must be false when called after window_manager_remove() has already +// destroyed the widget tree (ctx->wrapperWidget is a dangling pointer at that point) - true +// otherwise (ctx->wrapperWidget was just freshly recreated by the caller). Each view's onStop() +// only deletes its own independent lv_timer_t objects and nulls its own widget pointers - it +// never dereferences them - so calling it is always safe regardless of window state. +void stopActiveView(Context* ctx, bool cleanWrapperWidget) { + switch (ctx->activeView) { + case ViewType::Main: + mainViewStop(ctx); + break; + case ViewType::Menu: + menuViewStop(ctx); + break; + case ViewType::Stats: + statsViewStop(ctx); + break; + case ViewType::Settings: + settingsViewStop(ctx); + break; + case ViewType::PatternGameView: + patternGameStop(ctx); + break; + case ViewType::ReactionGameView: + reactionGameStop(ctx); + break; + case ViewType::CemeteryViewType: + cemeteryViewStop(ctx); + break; + case ViewType::AchievementsViewType: + achievementsViewStop(ctx); + break; + case ViewType::None: + break; + } - if (petLogic == nullptr) { - petLogic = new PetLogic(); + if (cleanWrapperWidget && ctx->wrapperWidget) { + lv_obj_clean(ctx->wrapperWidget); + } - if (!petLogic->loadState()) { - // No save data - pet starts fresh - } + ctx->activeView = ViewType::None; +} - lastKnownStage = petLogic->getStats().stage; - } +void onCleanClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx == nullptr) return; - if (timerMutex == nullptr) { - timerMutex = xSemaphoreCreateMutex(); + if (ctx->timerMutex) xSemaphoreTake(ctx->timerMutex, portMAX_DELAY); + + if (ctx->activeView == ViewType::Main) { + int poopCount = ctx->petLogic.getStats().poopCount; + if (poopCount > 0) { + ctx->petLogic.performAction(PetAction::Clean); + ctx->petLogic.saveState(); + mainViewUpdateUI(ctx); + + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Clean); + achievementsIncrementCleanCount(); + + mainViewSetStatusText(ctx, "All clean!"); + } else { + mainViewSetStatusText(ctx, "Nothing to clean!"); + } } + + if (ctx->timerMutex) xSemaphoreGive(ctx->timerMutex); } -void TamaTac::onShow(AppHandle context, lv_obj_t* parent) { - // Initialize SfxEngine - if (sfxEngine == nullptr) { - sfxEngine = new SfxEngine(); - sfxEngine->start(); - - // Load settings - bool soundEnabled; - SettingsView::loadSettings(&soundEnabled, &decaySpeed); - sfxEngine->setEnabled(soundEnabled); - PetLogic::setDecaySpeed(static_cast(decaySpeed)); - } +void onMenuClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx == nullptr) return; - if (updateTimer == nullptr) { - updateTimer = xTimerCreate( - "PetUpdate", - pdMS_TO_TICKS(30000), - pdTRUE, - nullptr, - onTimerUpdate - ); - - if (updateTimer != nullptr) { - xTimerStart(updateTimer, 0); - } + if (ctx->activeView == ViewType::Main) { + showMenuView(ctx); + } else { + showMainView(ctx); } +} - currentApp = context; +void onResetClicked([[maybe_unused]] lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx == nullptr) return; - lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(parent, 0, 0); - lv_obj_set_style_pad_row(parent, 0, 0); + const char* argv[] = { + "Reset Pet?", + "This will start over with a new pet. Your current pet will be lost forever!", + "Reset", + "Cancel", + }; + app_manager_start_for_result("AlertDialog", ctx->appInstanceId, 4, argv, &ctx->resetDialogId); +} - toolbar = lvgl_toolbar_create(parent, "TamaTac"); +void onTimerUpdate(TimerHandle_t timer) { + auto* ctx = static_cast(pvTimerGetTimerID(timer)); - menuButton = lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onMenuClicked, this); - lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_TRASH, onCleanClicked, this); - lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_REFRESH, onResetClicked, this); + // Hold mutex for entire callback so tamaTacTeardown() can block until we finish + if (ctx->timerMutex == nullptr || xSemaphoreTake(ctx->timerMutex, 0) != pdTRUE) return; - wrapperWidget = lv_obj_create(parent); - lv_obj_set_width(wrapperWidget, LV_PCT(100)); - lv_obj_set_flex_grow(wrapperWidget, 1); - lv_obj_set_style_pad_all(wrapperWidget, 0, 0); - lv_obj_set_style_border_width(wrapperWidget, 0, 0); - lv_obj_set_style_bg_opa(wrapperWidget, LV_OPA_TRANSP, 0); - lv_obj_remove_flag(wrapperWidget, LV_OBJ_FLAG_SCROLLABLE); + bool wasAlive = !ctx->petLogic.isDead(); + // Capture stage before update() - checkHealth() sets stage to Ghost on death + LifeStage stageBeforeDeath = ctx->petLogic.getStats().stage; - showMainView(); -} + uint32_t now = tt::kernel::getMillis(); + ctx->petLogic.update(now); + ctx->petLogic.saveState(); -void TamaTac::onHide(AppHandle context) { - if (updateTimer != nullptr) { - xTimerStop(updateTimer, portMAX_DELAY); - xTimerDelete(updateTimer, portMAX_DELAY); - updateTimer = nullptr; + // Check achievements + const PetStats& stats = ctx->petLogic.getStats(); + + // Evolution achievements + switch (stats.stage) { + case LifeStage::Baby: achievementsUnlock(AchievementId::ReachBaby); break; + case LifeStage::Teen: achievementsUnlock(AchievementId::ReachTeen); break; + case LifeStage::Adult: achievementsUnlock(AchievementId::ReachAdult); break; + case LifeStage::Elder: achievementsUnlock(AchievementId::ReachElder); break; + default: break; } - if (sfxEngine) { - sfxEngine->stop(); - delete sfxEngine; - sfxEngine = nullptr; + // Survival achievement + if (stats.ageHours >= 24 && !stats.isDead) { + achievementsUnlock(AchievementId::Survivor24h); } - stopActiveView(); + // Full stats achievement + if (stats.hunger >= 90 && stats.happiness >= 90 && stats.health >= 90 && stats.energy >= 90) { + achievementsUnlock(AchievementId::FullStats); + } + + // Record death in cemetery (use pre-death stage, not Ghost) + if (wasAlive && ctx->petLogic.isDead()) { + cemeteryViewRecordDeath(stats.personality, stageBeforeDeath, stats.ageHours); + } - wrapperWidget = nullptr; - toolbar = nullptr; - menuButton = nullptr; + xSemaphoreGive(ctx->timerMutex); } -void TamaTac::onDestroy(AppHandle app) { - // Acquire mutex to guarantee any in-flight timer callback has completed. - // The callback holds this mutex for its entire duration, so taking it - // here blocks until the callback is done — no arbitrary delay needed. - if (timerMutex != nullptr) { - xSemaphoreTake(timerMutex, portMAX_DELAY); - } +} // namespace - if (petLogic) { - petLogic->saveState(); - delete petLogic; - petLogic = nullptr; - } +// Canvas buffer definitions (shared by views via extern) +// 72x72 supports 24x24 sprites at 3x scale (medium/large/xlarge screens) +lv_color_t TamaTac_canvasBuffer[72 * 72]; +lv_color_t TamaTac_iconBuffers[12][16 * 16]; - if (timerMutex != nullptr) { - xSemaphoreGive(timerMutex); - vSemaphoreDelete(timerMutex); - timerMutex = nullptr; +void tamaTacInit(Context* ctx) { + // Seed RNG once so rand() produces different sequences each run + srand(tt::kernel::getMillis()); + + if (!ctx->petLogic.loadState()) { + // No save data - pet starts fresh } + ctx->lastKnownStage = ctx->petLogic.getStats().stage; - currentApp = nullptr; -} + ctx->timerMutex = xSemaphoreCreateMutex(); -void TamaTac::onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData) { - if (launchId == resetDialogId) { - int32_t buttonIndex = tt_app_alertdialog_get_result_index(resultData); - if (buttonIndex == 0) { - // User clicked "Reset" (first button) - if (timerMutex) xSemaphoreTake(timerMutex, portMAX_DELAY); - - if (petLogic) { - petLogic->reset(); - petLogic->saveState(); - lastKnownStage = LifeStage::Egg; - pendingResetUI = true; // Defer UI update to LVGL task - } - - if (timerMutex) xSemaphoreGive(timerMutex); - } - resetDialogId = 0; + ctx->sfxEngine = new SfxEngine(); + ctx->sfxEngine->start(); + + bool soundEnabled; + settingsViewLoadSettings(&soundEnabled, &ctx->decaySpeed); + ctx->sfxEngine->setEnabled(soundEnabled); + PetLogic::setDecaySpeed(static_cast(ctx->decaySpeed)); + + ctx->updateTimer = xTimerCreate( + "PetUpdate", + pdMS_TO_TICKS(30000), + pdTRUE, + ctx, + onTimerUpdate + ); + if (ctx->updateTimer != nullptr) { + xTimerStart(ctx->updateTimer, 0); } } -//============================================================================================== -// View Management -//============================================================================================== +void tamaTacCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(parent, 0, 0); + lv_obj_set_style_pad_row(parent, 0, 0); -void TamaTac::stopActiveView() { - switch (activeView) { + ctx->toolbar = lvgl_toolbar_create(parent, "TamaTac"); + + ctx->menuButton = lvgl_toolbar_add_text_button_action(ctx->toolbar, LV_SYMBOL_LIST, onMenuClicked, ctx); + lvgl_toolbar_add_text_button_action(ctx->toolbar, LV_SYMBOL_TRASH, onCleanClicked, ctx); + lvgl_toolbar_add_text_button_action(ctx->toolbar, LV_SYMBOL_REFRESH, onResetClicked, ctx); + + ctx->wrapperWidget = lv_obj_create(parent); + lv_obj_set_width(ctx->wrapperWidget, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->wrapperWidget, 1); + lv_obj_set_style_pad_all(ctx->wrapperWidget, 0, 0); + lv_obj_set_style_border_width(ctx->wrapperWidget, 0, 0); + lv_obj_set_style_bg_opa(ctx->wrapperWidget, LV_OPA_TRANSP, 0); + lv_obj_remove_flag(ctx->wrapperWidget, LV_OBJ_FLAG_SCROLLABLE); + + // Rebuild whichever view was active. On first creation this is None (-> Main). On a + // resurface after this window was buried (e.g. by the reset AlertDialog, or by an unrelated + // app switching to briefly), it's whatever the user had open - pet state, achievements, and + // game-round counters all live in Context/PetLogic and survive burial fine (only the widget + // tree was destroyed), so rebuilding the same view here just picks up where things left off + // (mini-games are the one exception: their onStart() always resets round/pattern state, so a + // burial mid-game restarts that game rather than resuming it - acceptable, not a crash). + switch (ctx->activeView) { + case ViewType::Menu: showMenuView(ctx); break; + case ViewType::Stats: showStatsView(ctx); break; + case ViewType::Settings: showSettingsView(ctx); break; + case ViewType::PatternGameView: showPatternGame(ctx); break; + case ViewType::ReactionGameView: showReactionGame(ctx); break; + case ViewType::CemeteryViewType: showCemeteryView(ctx); break; + case ViewType::AchievementsViewType: showAchievementsView(ctx); break; case ViewType::Main: - mainView.onStop(); - break; - case ViewType::Menu: - menuView.onStop(); - break; - case ViewType::Stats: - statsView.onStop(); - break; - case ViewType::Settings: - settingsView.onStop(); - break; - case ViewType::PatternGameView: - patternGame.onStop(); - break; - case ViewType::ReactionGameView: - reactionGame.onStop(); - break; - case ViewType::CemeteryViewType: - cemeteryView.onStop(); - break; - case ViewType::AchievementsViewType: - achievementsView.onStop(); - break; case ViewType::None: + default: + showMainView(ctx); break; } +} + +void tamaTacTeardown(Context* ctx) { + if (ctx->updateTimer != nullptr) { + xTimerStop(ctx->updateTimer, portMAX_DELAY); + xTimerDelete(ctx->updateTimer, portMAX_DELAY); + ctx->updateTimer = nullptr; + } - if (wrapperWidget) { - lv_obj_clean(wrapperWidget); + // Acquire mutex to guarantee any in-flight timer callback has completed. The callback holds + // this mutex for its entire duration, so taking it here blocks until the callback is done - + // no arbitrary delay needed. (Belt-and-suspenders: updateTimer is already stopped/deleted + // above, but xTimerDelete only guarantees no *new* callback starts, not that one already + // running has finished.) + if (ctx->timerMutex != nullptr) { + xSemaphoreTake(ctx->timerMutex, portMAX_DELAY); } - activeView = ViewType::None; + ctx->petLogic.saveState(); + + if (ctx->timerMutex != nullptr) { + xSemaphoreGive(ctx->timerMutex); + vSemaphoreDelete(ctx->timerMutex); + ctx->timerMutex = nullptr; + } + + if (ctx->sfxEngine) { + ctx->sfxEngine->stop(); + delete ctx->sfxEngine; + ctx->sfxEngine = nullptr; + } + + // Don't clean ctx->wrapperWidget here: window_manager_remove() already destroyed it (and + // everything under it) by the time this runs. stopActiveView(..., false) skips that step - + // it still runs each view's onStop() to delete any still-alive independent lv_timer_t + // objects, which is safe (see stopActiveView()'s comment). + stopActiveView(ctx, false); + + ctx->wrapperWidget = nullptr; + ctx->toolbar = nullptr; + ctx->menuButton = nullptr; } -void TamaTac::showMainView() { - stopActiveView(); +//============================================================================================== +// View Management +//============================================================================================== + +void showMainView(Context* ctx) { + stopActiveView(ctx, true); - activeView = ViewType::Main; - mainView.onStart(wrapperWidget, this); - mainView.updateUI(petLogic, lastKnownStage); + ctx->activeView = ViewType::Main; + mainViewCreateWidgets(ctx->wrapperWidget, ctx); + mainViewUpdateUI(ctx); } -void TamaTac::showMenuView() { - stopActiveView(); +void showMenuView(Context* ctx) { + stopActiveView(ctx, true); - activeView = ViewType::Menu; - menuView.onStart(wrapperWidget, this); + ctx->activeView = ViewType::Menu; + menuViewCreateWidgets(ctx->wrapperWidget, ctx); } -void TamaTac::showStatsView() { - stopActiveView(); +void showStatsView(Context* ctx) { + stopActiveView(ctx, true); - activeView = ViewType::Stats; - statsView.onStart(wrapperWidget, this); - statsView.updateStats(petLogic); + ctx->activeView = ViewType::Stats; + statsViewCreateWidgets(ctx->wrapperWidget, ctx); + statsViewUpdateStats(ctx); } -void TamaTac::showSettingsView() { - stopActiveView(); +void showSettingsView(Context* ctx) { + stopActiveView(ctx, true); - activeView = ViewType::Settings; - settingsView.onStart(wrapperWidget, this); + ctx->activeView = ViewType::Settings; + settingsViewCreateWidgets(ctx->wrapperWidget, ctx); } -void TamaTac::showCemeteryView() { - stopActiveView(); +void showCemeteryView(Context* ctx) { + stopActiveView(ctx, true); - activeView = ViewType::CemeteryViewType; - cemeteryView.onStart(wrapperWidget, this); + ctx->activeView = ViewType::CemeteryViewType; + cemeteryViewCreateWidgets(ctx->wrapperWidget, ctx); } -void TamaTac::showAchievementsView() { - stopActiveView(); +void showAchievementsView(Context* ctx) { + stopActiveView(ctx, true); + + ctx->activeView = ViewType::AchievementsViewType; + achievementsViewCreateWidgets(ctx->wrapperWidget, ctx); +} - activeView = ViewType::AchievementsViewType; - achievementsView.onStart(wrapperWidget, this); +void showPatternGame(Context* ctx) { + stopActiveView(ctx, true); + ctx->activeView = ViewType::PatternGameView; + patternGameCreateWidgets(ctx->wrapperWidget, ctx); +} + +void showReactionGame(Context* ctx) { + stopActiveView(ctx, true); + ctx->activeView = ViewType::ReactionGameView; + reactionGameCreateWidgets(ctx->wrapperWidget, ctx); } //============================================================================================== // Settings Handlers //============================================================================================== -void TamaTac::setSoundEnabled(bool enabled) { - if (sfxEngine) { - sfxEngine->setEnabled(enabled); +void tamaTacSetSoundEnabled(Context* ctx, bool enabled) { + if (ctx->sfxEngine) { + ctx->sfxEngine->setEnabled(enabled); } } -void TamaTac::setDecaySpeed(DecaySpeed speed) { - decaySpeed = speed; +void tamaTacSetDecaySpeed(Context* ctx, DecaySpeed speed) { + ctx->decaySpeed = speed; PetLogic::setDecaySpeed(static_cast(speed)); } @@ -270,257 +363,134 @@ void TamaTac::setDecaySpeed(DecaySpeed speed) { // Action Handlers (called by MainView) //============================================================================================== -void TamaTac::handleFeedAction() { - if (timerMutex) xSemaphoreTake(timerMutex, portMAX_DELAY); +void tamaTacHandleFeedAction(Context* ctx) { + if (ctx->timerMutex) xSemaphoreTake(ctx->timerMutex, portMAX_DELAY); - if (petLogic) { - petLogic->performAction(PetAction::Feed); - petLogic->saveState(); - mainView.updateUI(petLogic, lastKnownStage); + ctx->petLogic.performAction(PetAction::Feed); + ctx->petLogic.saveState(); + mainViewUpdateUI(ctx); - if (sfxEngine) sfxEngine->play(SfxId::Feed); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Feed); - AchievementsView::unlock(AchievementId::FirstFeed); + achievementsUnlock(AchievementId::FirstFeed); - char msg[64]; - snprintf(msg, sizeof(msg), "Fed! Hunger: %d%%", petLogic->getHunger()); - mainView.setStatusText(msg); - } + char msg[64]; + snprintf(msg, sizeof(msg), "Fed! Hunger: %d%%", ctx->petLogic.getHunger()); + mainViewSetStatusText(ctx, msg); - if (timerMutex) xSemaphoreGive(timerMutex); + if (ctx->timerMutex) xSemaphoreGive(ctx->timerMutex); } -void TamaTac::handlePlayAction() { - if (timerMutex) xSemaphoreTake(timerMutex, portMAX_DELAY); +void tamaTacHandlePlayAction(Context* ctx) { + if (ctx->timerMutex) xSemaphoreTake(ctx->timerMutex, portMAX_DELAY); - bool canPlay = petLogic && !petLogic->isDead(); - DayPhase phase = canPlay ? petLogic->getDayPhase() : DayPhase::Day; + bool canPlay = !ctx->petLogic.isDead(); + DayPhase phase = canPlay ? ctx->petLogic.getDayPhase() : DayPhase::Day; - if (timerMutex) xSemaphoreGive(timerMutex); + if (ctx->timerMutex) xSemaphoreGive(ctx->timerMutex); if (canPlay) { - AchievementsView::unlock(AchievementId::FirstPlay); + achievementsUnlock(AchievementId::FirstPlay); if (phase == DayPhase::Night) { - AchievementsView::unlock(AchievementId::NightOwl); + achievementsUnlock(AchievementId::NightOwl); } if (rand() % 2 == 0) { - showPatternGame(); + showPatternGame(ctx); } else { - showReactionGame(); + showReactionGame(ctx); } } } -void TamaTac::handlePetTap() { - if (activeView != ViewType::Main) return; +void tamaTacHandlePetTap(Context* ctx) { + if (ctx->activeView != ViewType::Main) return; - if (timerMutex) xSemaphoreTake(timerMutex, portMAX_DELAY); + if (ctx->timerMutex) xSemaphoreTake(ctx->timerMutex, portMAX_DELAY); - if (!petLogic || petLogic->isDead()) { - if (timerMutex) xSemaphoreGive(timerMutex); + if (ctx->petLogic.isDead()) { + if (ctx->timerMutex) xSemaphoreGive(ctx->timerMutex); return; } // 3-second cooldown between pets uint32_t now = tt::kernel::getMillis(); - if (now - lastPetTime < 3000) { - if (timerMutex) xSemaphoreGive(timerMutex); + if (now - ctx->lastPetTime < 3000) { + if (ctx->timerMutex) xSemaphoreGive(ctx->timerMutex); return; } - lastPetTime = now; - - petLogic->performAction(PetAction::Pet); - petLogic->saveState(); - mainView.updateUI(petLogic, lastKnownStage); - - if (timerMutex) xSemaphoreGive(timerMutex); - - if (sfxEngine) sfxEngine->play(SfxId::Chirp); -} - -void TamaTac::showPatternGame() { - stopActiveView(); - activeView = ViewType::PatternGameView; - patternGame.onStart(wrapperWidget, this); -} - -void TamaTac::showReactionGame() { - stopActiveView(); - activeView = ViewType::ReactionGameView; - reactionGame.onStart(wrapperWidget, this); -} - -void TamaTac::onReactionGameComplete(int score, bool won) { - if (timerMutex) xSemaphoreTake(timerMutex, portMAX_DELAY); - - if (petLogic) { - int clampedScore = std::max(0, std::min(score, ReactionGame::MAX_ROUNDS)); - petLogic->applyPlayResult(clampedScore, ReactionGame::MAX_ROUNDS); - petLogic->saveState(); - } + ctx->lastPetTime = now; - if (timerMutex) xSemaphoreGive(timerMutex); + ctx->petLogic.performAction(PetAction::Pet); + ctx->petLogic.saveState(); + mainViewUpdateUI(ctx); - if (won) AchievementsView::unlock(AchievementId::PerfectGame); - if (sfxEngine) sfxEngine->play(SfxId::Play); + if (ctx->timerMutex) xSemaphoreGive(ctx->timerMutex); - showMainView(); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Chirp); } -void TamaTac::onPatternGameComplete(int score, bool won) { - if (timerMutex) xSemaphoreTake(timerMutex, portMAX_DELAY); +void tamaTacOnReactionGameComplete(Context* ctx, int score, bool won) { + if (ctx->timerMutex) xSemaphoreTake(ctx->timerMutex, portMAX_DELAY); - if (petLogic) { - int clampedScore = std::max(0, std::min(score, PatternGame::MAX_ROUNDS)); - petLogic->applyPlayResult(clampedScore, PatternGame::MAX_ROUNDS); - petLogic->saveState(); - } - - if (timerMutex) xSemaphoreGive(timerMutex); + int clampedScore = std::max(0, std::min(score, REACTION_GAME_MAX_ROUNDS)); + ctx->petLogic.applyPlayResult(clampedScore, REACTION_GAME_MAX_ROUNDS); + ctx->petLogic.saveState(); - if (won) AchievementsView::unlock(AchievementId::PerfectGame); - if (sfxEngine) sfxEngine->play(SfxId::Play); + if (ctx->timerMutex) xSemaphoreGive(ctx->timerMutex); - showMainView(); -} - -void TamaTac::handleMedicineAction() { - if (timerMutex) xSemaphoreTake(timerMutex, portMAX_DELAY); - - if (petLogic) { - bool wasSick = petLogic->isSick(); - petLogic->performAction(PetAction::Medicine); - petLogic->saveState(); - mainView.updateUI(petLogic, lastKnownStage); - - if (sfxEngine) sfxEngine->play(SfxId::Medicine); - - if (wasSick && !petLogic->isSick()) { - AchievementsView::unlock(AchievementId::FirstCure); - mainView.setStatusText("Cured!"); - } else { - mainView.setStatusText("Medicine given"); - } - } + if (won) achievementsUnlock(AchievementId::PerfectGame); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Play); - if (timerMutex) xSemaphoreGive(timerMutex); + showMainView(ctx); } -void TamaTac::handleSleepAction() { - if (timerMutex) xSemaphoreTake(timerMutex, portMAX_DELAY); +void tamaTacOnPatternGameComplete(Context* ctx, int score, bool won) { + if (ctx->timerMutex) xSemaphoreTake(ctx->timerMutex, portMAX_DELAY); - if (petLogic) { - petLogic->performAction(PetAction::Sleep); - petLogic->saveState(); - mainView.updateUI(petLogic, lastKnownStage); + int clampedScore = std::max(0, std::min(score, PATTERN_GAME_MAX_ROUNDS)); + ctx->petLogic.applyPlayResult(clampedScore, PATTERN_GAME_MAX_ROUNDS); + ctx->petLogic.saveState(); - if (sfxEngine) sfxEngine->play(SfxId::Sleep); + if (ctx->timerMutex) xSemaphoreGive(ctx->timerMutex); - char msg[64]; - snprintf(msg, sizeof(msg), "Sleeping... Energy: %d%%", petLogic->getEnergy()); - mainView.setStatusText(msg); - } + if (won) achievementsUnlock(AchievementId::PerfectGame); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Play); - if (timerMutex) xSemaphoreGive(timerMutex); + showMainView(ctx); } -//============================================================================================== -// Timer Callback -//============================================================================================== - -void TamaTac::onTimerUpdate(TimerHandle_t timer) { - // Hold mutex for entire callback so onDestroy() can block until we finish - if (timerMutex == nullptr || xSemaphoreTake(timerMutex, 0) != pdTRUE) return; +void tamaTacHandleMedicineAction(Context* ctx) { + if (ctx->timerMutex) xSemaphoreTake(ctx->timerMutex, portMAX_DELAY); - if (petLogic == nullptr) { - xSemaphoreGive(timerMutex); - return; - } - - bool wasAlive = !petLogic->isDead(); - // Capture stage before update() — checkHealth() sets stage to Ghost on death - LifeStage stageBeforeDeath = petLogic->getStats().stage; + bool wasSick = ctx->petLogic.isSick(); + ctx->petLogic.performAction(PetAction::Medicine); + ctx->petLogic.saveState(); + mainViewUpdateUI(ctx); - uint32_t now = tt::kernel::getMillis(); - petLogic->update(now); - petLogic->saveState(); - - // Check achievements - const PetStats& stats = petLogic->getStats(); + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Medicine); - // Evolution achievements - switch (stats.stage) { - case LifeStage::Baby: AchievementsView::unlock(AchievementId::ReachBaby); break; - case LifeStage::Teen: AchievementsView::unlock(AchievementId::ReachTeen); break; - case LifeStage::Adult: AchievementsView::unlock(AchievementId::ReachAdult); break; - case LifeStage::Elder: AchievementsView::unlock(AchievementId::ReachElder); break; - default: break; - } - - // Survival achievement - if (stats.ageHours >= 24 && !stats.isDead) { - AchievementsView::unlock(AchievementId::Survivor24h); - } - - // Full stats achievement - if (stats.hunger >= 90 && stats.happiness >= 90 && stats.health >= 90 && stats.energy >= 90) { - AchievementsView::unlock(AchievementId::FullStats); - } - - // Record death in cemetery (use pre-death stage, not Ghost) - if (wasAlive && petLogic->isDead()) { - CemeteryView::recordDeath(stats.personality, stageBeforeDeath, stats.ageHours); + if (wasSick && !ctx->petLogic.isSick()) { + achievementsUnlock(AchievementId::FirstCure); + mainViewSetStatusText(ctx, "Cured!"); + } else { + mainViewSetStatusText(ctx, "Medicine given"); } - xSemaphoreGive(timerMutex); + if (ctx->timerMutex) xSemaphoreGive(ctx->timerMutex); } -//============================================================================================== -// Event Handlers -//============================================================================================== - -void TamaTac::onCleanClicked(lv_event_t* e) { - TamaTac* app = static_cast(lv_event_get_user_data(e)); - if (app == nullptr) return; - - if (timerMutex) xSemaphoreTake(timerMutex, portMAX_DELAY); - - if (petLogic && app->activeView == ViewType::Main) { - int poopCount = petLogic->getStats().poopCount; - if (poopCount > 0) { - petLogic->performAction(PetAction::Clean); - petLogic->saveState(); - app->mainView.updateUI(petLogic, lastKnownStage); - - if (sfxEngine) sfxEngine->play(SfxId::Clean); - AchievementsView::incrementCleanCount(); +void tamaTacHandleSleepAction(Context* ctx) { + if (ctx->timerMutex) xSemaphoreTake(ctx->timerMutex, portMAX_DELAY); - app->mainView.setStatusText("All clean!"); - } else { - app->mainView.setStatusText("Nothing to clean!"); - } - } + ctx->petLogic.performAction(PetAction::Sleep); + ctx->petLogic.saveState(); + mainViewUpdateUI(ctx); - if (timerMutex) xSemaphoreGive(timerMutex); -} + if (ctx->sfxEngine) ctx->sfxEngine->play(SfxId::Sleep); -void TamaTac::onMenuClicked(lv_event_t* e) { - TamaTac* app = static_cast(lv_event_get_user_data(e)); - if (app == nullptr) return; + char msg[64]; + snprintf(msg, sizeof(msg), "Sleeping... Energy: %d%%", ctx->petLogic.getEnergy()); + mainViewSetStatusText(ctx, msg); - if (app->activeView == ViewType::Main) { - app->showMenuView(); - } else { - app->showMainView(); - } -} - -void TamaTac::onResetClicked([[maybe_unused]] lv_event_t* e) { - const char* buttons[] = {"Reset", "Cancel"}; - resetDialogId = tt_app_alertdialog_start( - "Reset Pet?", - "This will start over with a new pet. Your current pet will be lost forever!", - buttons, - 2 - ); + if (ctx->timerMutex) xSemaphoreGive(ctx->timerMutex); } diff --git a/Apps/TamaTac/main/Source/TamaTac.h b/Apps/TamaTac/main/Source/TamaTac.h index 73276be..fc116b3 100644 --- a/Apps/TamaTac/main/Source/TamaTac.h +++ b/Apps/TamaTac/main/Source/TamaTac.h @@ -4,11 +4,13 @@ */ #pragma once -#include -#include +#include +#include #include #include #include +#include + #include "PetLogic.h" #include "Sprites.h" #include "MainView.h" @@ -25,106 +27,87 @@ extern lv_color_t TamaTac_canvasBuffer[72 * 72]; extern lv_color_t TamaTac_iconBuffers[12][16 * 16]; -class TamaTac final : public App { -public: - // Active view type (scoped to TamaTac class) - enum class ViewType { - None, - Main, - Menu, - Stats, - Settings, - PatternGameView, - ReactionGameView, - CemeteryViewType, - AchievementsViewType - }; - -private: +// Active view type +enum class ViewType { + None, + Main, + Menu, + Stats, + Settings, + PatternGameView, + ReactionGameView, + CemeteryViewType, + AchievementsViewType +}; + +struct Context { + AppInstanceId appInstanceId = 0; + WindowId window = 0; + // UI elements lv_obj_t* toolbar = nullptr; lv_obj_t* wrapperWidget = nullptr; lv_obj_t* menuButton = nullptr; // Views - MainView mainView; - StatsView statsView; - MenuView menuView; - SettingsView settingsView; - PatternGame patternGame; - ReactionGame reactionGame; - CemeteryView cemeteryView; - AchievementsView achievementsView; + MainViewState mainView; + StatsViewState statsView; + MenuViewState menuView; + SettingsViewState settingsView; + PatternGameState patternGame; + ReactionGameState reactionGame; + CemeteryViewState cemeteryView; + AchievementsViewState achievementsView; ViewType activeView = ViewType::None; - // Static data (singleton — only one TamaTac instance exists at a time) - static PetLogic* petLogic; - static TimerHandle_t updateTimer; - static SemaphoreHandle_t timerMutex; - static AppHandle currentApp; - static AppLaunchId resetDialogId; - static LifeStage lastKnownStage; - static SfxEngine* sfxEngine; - static DecaySpeed decaySpeed; - static uint32_t lastPetTime; - static bool pendingResetUI; - -public: - void onCreate(AppHandle app) override; - void onShow(AppHandle context, lv_obj_t* parent) override; - void onHide(AppHandle context) override; - void onDestroy(AppHandle app) override; - void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override; - - // Action handlers (called by MainView) - void handleFeedAction(); - void handlePlayAction(); - void handleMedicineAction(); - void handleSleepAction(); - void handlePetTap(); - - // Mini-game callbacks - void onPatternGameComplete(int roundsCompleted, bool won); - void onReactionGameComplete(int score, bool won); - - // Settings handlers (called by SettingsView) - void setSoundEnabled(bool enabled); - void setDecaySpeed(DecaySpeed speed); - - // View navigation (called by MenuView) - void showMainView(); - void showStatsView(); - void showSettingsView(); - void showCemeteryView(); - void showAchievementsView(); - - // Getters (note: getSfxEngine() may return nullptr before onCreate() or after onDestroy()) - static DecaySpeed getDecaySpeed() { return decaySpeed; } - static SfxEngine* getSfxEngine() { return sfxEngine; } - -private: - // View management - void stopActiveView(); - void showMenuView(); - - // Timer callback (called every 30 seconds) - static void onTimerUpdate(TimerHandle_t timer); - - // Event handlers - static void onCleanClicked(lv_event_t* e); - static void onMenuClicked(lv_event_t* e); - static void onResetClicked(lv_event_t* e); - - // View navigation (internal) - void showPatternGame(); - void showReactionGame(); - - friend class MainView; - friend class StatsView; - friend class MenuView; - friend class SettingsView; - friend class PatternGame; - friend class ReactionGame; - friend class CemeteryView; - friend class AchievementsView; + // Pet simulation - session-scoped: survives widget rebuilds (window burial/resurface) + PetLogic petLogic; + LifeStage lastKnownStage = LifeStage::Egg; + DecaySpeed decaySpeed = DecaySpeed::Normal; + uint32_t lastPetTime = 0; + bool pendingResetUI = false; // Set by main.cpp's AlertDialog result handler, consumed by MainView's anim timer + + // Session-scoped resources - created once in tamaTacInit(), released in tamaTacTeardown() + SfxEngine* sfxEngine = nullptr; + TimerHandle_t updateTimer = nullptr; + SemaphoreHandle_t timerMutex = nullptr; + + // Dialog launch id for tracking the reset-confirmation AlertDialog + uint32_t resetDialogId = 0; }; + +/** Sets up state that must exist for the whole app instance lifetime: loads pet state, starts + * the SFX engine, and starts the 30s pet-update timer. Call once, before window_manager_create(). */ +void tamaTacInit(Context* ctx); + +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void tamaTacCreateWidgets(lv_obj_t* parent, void* userData); + +/** Stops the update timer and SFX engine, saves pet state. Call once, after the window has been + * torn down. */ +void tamaTacTeardown(Context* ctx); + +// Action handlers (called by MainView) +void tamaTacHandleFeedAction(Context* ctx); +void tamaTacHandlePlayAction(Context* ctx); +void tamaTacHandleMedicineAction(Context* ctx); +void tamaTacHandleSleepAction(Context* ctx); +void tamaTacHandlePetTap(Context* ctx); + +// Mini-game callbacks +void tamaTacOnPatternGameComplete(Context* ctx, int roundsCompleted, bool won); +void tamaTacOnReactionGameComplete(Context* ctx, int score, bool won); + +// Settings handlers (called by SettingsView) +void tamaTacSetSoundEnabled(Context* ctx, bool enabled); +void tamaTacSetDecaySpeed(Context* ctx, DecaySpeed speed); + +// View navigation (called by MenuView, MainView, and internally) +void showMainView(Context* ctx); +void showMenuView(Context* ctx); +void showStatsView(Context* ctx); +void showSettingsView(Context* ctx); +void showCemeteryView(Context* ctx); +void showAchievementsView(Context* ctx); +void showPatternGame(Context* ctx); +void showReactionGame(Context* ctx); diff --git a/Apps/TamaTac/main/Source/main.cpp b/Apps/TamaTac/main/Source/main.cpp index 374e56f..103ba3e 100644 --- a/Apps/TamaTac/main/Source/main.cpp +++ b/Apps/TamaTac/main/Source/main.cpp @@ -1,10 +1,77 @@ #include "TamaTac.h" -#include + +#include +#include +#include + +#include + +#include extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + // Heap-allocated: tamaTacInit() hands ctx's address to a raw FreeRTOS timer + // (xTimerCreate's pvTimerID) whose callback runs on the FreeRTOS timer service task, fully + // independent of both the LVGL thread and this window's burial state - it keeps firing for + // the whole app session, not just while a window exists. + auto ctx = std::make_unique(); + ctx->appInstanceId = app_instance_id; + + tamaTacInit(ctx.get()); + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, tamaTacCreateWidgets, ctx.get()); + ctx->window = window; + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(app_instance_id); + should_close = true; + break; + + case APP_EVENT_RESULT: { + uint32_t launch_id = event.result.launch_id; + if (launch_id == ctx->resetDialogId && ctx->resetDialogId != 0) { + ctx->resetDialogId = 0; + int32_t buttonIndex = event.result.result; + + if (buttonIndex == 0) { + // User picked "Reset" (first button) + if (ctx->timerMutex) xSemaphoreTake(ctx->timerMutex, portMAX_DELAY); + + ctx->petLogic.reset(); + ctx->petLogic.saveState(); + ctx->lastKnownStage = LifeStage::Egg; + ctx->pendingResetUI = true; // Defer UI update to MainView's anim timer + + if (ctx->timerMutex) xSemaphoreGive(ctx->timerMutex); + } + app_manager_stop(launch_id); + } + break; + } + + default: + break; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + tamaTacTeardown(ctx.get()); + return 0; } diff --git a/Apps/TodoList/CMakeLists.txt b/Apps/TodoList/CMakeLists.txt index d8f246d..689be32 100644 --- a/Apps/TodoList/CMakeLists.txt +++ b/Apps/TodoList/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(TodoList) tactility_project(TodoList) diff --git a/Apps/TodoList/main/CMakeLists.txt b/Apps/TodoList/main/CMakeLists.txt index 0309010..3d04446 100644 --- a/Apps/TodoList/main/CMakeLists.txt +++ b/Apps/TodoList/main/CMakeLists.txt @@ -4,8 +4,5 @@ file(GLOB_RECURSE SOURCE_FILES idf_component_register( SRCS ${SOURCE_FILES} - # Library headers must be included directly, - # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include REQUIRES TactilitySDK ) diff --git a/Apps/TodoList/main/Source/TodoList.cpp b/Apps/TodoList/main/Source/TodoList.cpp index b08ddb4..3764be8 100644 --- a/Apps/TodoList/main/Source/TodoList.cpp +++ b/Apps/TodoList/main/Source/TodoList.cpp @@ -1,5 +1,6 @@ #include "TodoList.h" -#include +#include +#include #include #include #include @@ -11,27 +12,29 @@ #include #include -static constexpr const char* SAVE_FILENAME = "todos.txt"; +namespace { -/* File-scope instance pointer for index-based callbacks */ -static TodoList* g_instance = nullptr; -static AppHandle s_appHandle = nullptr; +constexpr const char* SAVE_FILENAME = "todos.txt"; +/** Must match manifest.properties' app.id */ +constexpr const char* APP_ID = "one.tactility.todolist"; + +// Attached to each list row / delete button so the click handlers know both which Context and +// which item index they belong to (mirrors AlertDialog/SelectionDialog's ButtonContext/ +// ItemContext idiom). Freed via LV_EVENT_DELETE when rebuildList() cleans the list. +struct ItemContext { + Context* ctx; + int index; +}; /* ── Helpers ──────────────────────────────────────────────────────── */ -static bool getSaveFilePath(char* buf, size_t bufSize) { - if (!s_appHandle) return false; - size_t size = bufSize; - tt_app_get_user_data_child_path(s_appHandle, SAVE_FILENAME, buf, &size); - return size > 0; +bool getSaveFilePath(char* buf, size_t bufSize) { + return app_paths_get_user_data_path(APP_ID, SAVE_FILENAME, buf, bufSize) == ERROR_NONE; } -static bool ensureDir() { - if (!s_appHandle) return false; +bool ensureDir() { char dir[256]; - size_t size = sizeof(dir); - tt_app_get_user_data_path(s_appHandle, dir, &size); - if (size == 0) return false; + if (app_paths_get_user_data_directory(APP_ID, dir, sizeof(dir)) != ERROR_NONE) return false; for (char* p = dir + 1; *p; ++p) { if (*p == '/') { *p = '\0'; mkdir(dir, 0755); *p = '/'; } } @@ -39,7 +42,7 @@ static bool ensureDir() { return true; } -static uint32_t getToolbarHeight(UiDensity uiDensity) { +uint32_t getToolbarHeight(UiDensity uiDensity) { if (uiDensity == LVGL_UI_DENSITY_COMPACT) { return lvgl_get_text_font_height(FONT_SIZE_DEFAULT) * 1.4f; } else { @@ -47,14 +50,14 @@ static uint32_t getToolbarHeight(UiDensity uiDensity) { } } -static uint32_t getActionIconPadding(UiDensity uiDensity) { +uint32_t getActionIconPadding(UiDensity uiDensity) { auto toolbar_height = getToolbarHeight(uiDensity); return (uiDensity != LVGL_UI_DENSITY_COMPACT) ? (uint32_t)(toolbar_height * 0.2f) : 8; } /* ── Persistence ──────────────────────────────────────────────────── */ -void TodoList::saveTodos() { +void saveTodos(Context* ctx) { if (!ensureDir()) return; char savePath[256]; if (!getSaveFilePath(savePath, sizeof(savePath))) return; @@ -64,15 +67,15 @@ void TodoList::saveTodos() { file_mutex_lock(&mutex); FILE* f = fopen(savePath, "w"); if (f) { - for (int i = 0; i < count; i++) { - fprintf(f, "%c %s\n", items[i].done ? '+' : '-', items[i].text); + for (int i = 0; i < ctx->count; i++) { + fprintf(f, "%c %s\n", ctx->items[i].done ? '+' : '-', ctx->items[i].text); } fclose(f); } file_mutex_unlock(&mutex); } -void TodoList::loadTodos() { +void loadTodos(Context* ctx) { char savePath[256]; if (!getSaveFilePath(savePath, sizeof(savePath))) return; @@ -80,11 +83,11 @@ void TodoList::loadTodos() { file_mutex_get(&mutex, savePath); file_mutex_lock(&mutex); - count = 0; + ctx->count = 0; FILE* f = fopen(savePath, "r"); if (f) { - char line[MAX_TEXT_LEN + 4]; - while (count < MAX_TODOS && fgets(line, sizeof(line), f)) { + char line[Context::MAX_TEXT_LEN + 4]; + while (ctx->count < Context::MAX_TODOS && fgets(line, sizeof(line), f)) { size_t len = strlen(line); while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) { line[--len] = '\0'; @@ -92,11 +95,11 @@ void TodoList::loadTodos() { if (len < 3 || line[1] != ' ') continue; - TodoItem* item = &items[count]; + Context::TodoItem* item = &ctx->items[ctx->count]; item->done = (line[0] == '+'); - strncpy(item->text, &line[2], MAX_TEXT_LEN - 1); - item->text[MAX_TEXT_LEN - 1] = '\0'; - count++; + strncpy(item->text, &line[2], Context::MAX_TEXT_LEN - 1); + item->text[Context::MAX_TEXT_LEN - 1] = '\0'; + ctx->count++; } fclose(f); } @@ -105,89 +108,56 @@ void TodoList::loadTodos() { /* ── UI Helpers ───────────────────────────────────────────────────── */ -void TodoList::updateCountLabel() { - if (!countLabel) return; +void updateCountLabel(Context* ctx) { + if (!ctx->countLabel) return; int pending = 0; - for (int i = 0; i < count; i++) { - if (!items[i].done) pending++; + for (int i = 0; i < ctx->count; i++) { + if (!ctx->items[i].done) pending++; } if (pending > 0) { - lv_label_set_text_fmt(countLabel, "%d left", pending); - } else if (count > 0) { - lv_label_set_text(countLabel, "All done!"); + lv_label_set_text_fmt(ctx->countLabel, "%d left", pending); + } else if (ctx->count > 0) { + lv_label_set_text(ctx->countLabel, "All done!"); } else { - lv_label_set_text(countLabel, "No tasks"); - } -} - -void TodoList::addItem(const char* text) { - if (!text || !text[0]) return; - if (count >= MAX_TODOS) return; - - while (*text == ' ') text++; - if (!*text) return; - - TodoItem* item = &items[count]; - item->done = false; - strncpy(item->text, text, MAX_TEXT_LEN - 1); - item->text[MAX_TEXT_LEN - 1] = '\0'; - - size_t len = strlen(item->text); - while (len > 0 && item->text[len - 1] == ' ') { - item->text[--len] = '\0'; + lv_label_set_text(ctx->countLabel, "No tasks"); } - - count++; - saveTodos(); - rebuildList(); } -void TodoList::scheduleRebuild() { - if (rebuildPending) return; - rebuildPending = true; - rebuildTimer = lv_timer_create(onDeferredRebuild, 0, this); - if (!rebuildTimer) { - rebuildPending = false; - return; - } - lv_timer_set_repeat_count(rebuildTimer, 1); -} +void onItemClicked(lv_event_t* e); +void onDeleteClicked(lv_event_t* e); -void TodoList::onDeferredRebuild(lv_timer_t* timer) { - TodoList* self = static_cast(lv_timer_get_user_data(timer)); - if (self) { - self->rebuildTimer = nullptr; - self->rebuildPending = false; - self->rebuildList(); - } +void onItemContextDeleted(lv_event_t* e) { + delete static_cast(lv_event_get_user_data(e)); } -void TodoList::rebuildList() { - if (!list) return; +void rebuildList(Context* ctx) { + if (!ctx->list) return; - lv_obj_clean(list); + lv_obj_clean(ctx->list); auto ui_density = lvgl_get_ui_density(); auto toolbar_height = getToolbarHeight(ui_density); auto icon_padding = getActionIconPadding(ui_density); - if (count == 0) { - lv_list_add_text(list, "No tasks yet. Add one below!"); + if (ctx->count == 0) { + lv_list_add_text(ctx->list, "No tasks yet. Add one below!"); } - for (int i = 0; i < count; i++) { - TodoItem* item = &items[i]; + for (int i = 0; i < ctx->count; i++) { + Context::TodoItem* item = &ctx->items[i]; - char display[MAX_TEXT_LEN + 8]; + char display[Context::MAX_TEXT_LEN + 8]; snprintf(display, sizeof(display), "%s %s", item->done ? LV_SYMBOL_OK : LV_SYMBOL_DUMMY, item->text); - lv_obj_t* btn = lv_list_add_button(list, NULL, display); + lv_obj_t* btn = lv_list_add_button(ctx->list, NULL, display); if (item->done) { lv_obj_set_style_text_opa(btn, LV_OPA_50, LV_PART_MAIN); } - lv_obj_add_event_cb(btn, onItemClicked, LV_EVENT_SHORT_CLICKED, (void*)(intptr_t)i); + auto* itemCtx = new ItemContext { ctx, i }; + lv_obj_add_event_cb(btn, onItemClicked, LV_EVENT_SHORT_CLICKED, itemCtx); + lv_obj_add_event_cb(btn, onItemContextDeleted, LV_EVENT_DELETE, itemCtx); lv_obj_t* delBtn = lv_button_create(btn); lv_obj_set_size(delBtn, toolbar_height - icon_padding, toolbar_height - icon_padding); @@ -200,73 +170,123 @@ void TodoList::rebuildList() { lv_label_set_text(delIcon, LV_SYMBOL_CLOSE); lv_obj_center(delIcon); - lv_obj_add_event_cb(delBtn, onDeleteClicked, LV_EVENT_CLICKED, (void*)(intptr_t)i); + auto* delItemCtx = new ItemContext { ctx, i }; + lv_obj_add_event_cb(delBtn, onDeleteClicked, LV_EVENT_CLICKED, delItemCtx); + lv_obj_add_event_cb(delBtn, onItemContextDeleted, LV_EVENT_DELETE, delItemCtx); } - updateCountLabel(); + updateCountLabel(ctx); } -/* ── Callbacks ────────────────────────────────────────────────────── */ +void onDeferredRebuild(lv_timer_t* timer) { + auto* ctx = static_cast(lv_timer_get_user_data(timer)); + ctx->rebuildTimer = nullptr; + ctx->rebuildPending = false; -void TodoList::onItemClicked(lv_event_t* e) { - if (!g_instance) return; - int idx = (int)(intptr_t)lv_event_get_user_data(e); - if (idx < 0 || idx >= g_instance->count) return; + // list only exists while this window is topmost - skip otherwise (window_manager deletes a + // buried window's widgets; same reasoning as GPIO.cpp's periodic status timer). + if (window_manager_get_state(ctx->window) != WINDOW_STATE_GRANTED) return; - g_instance->items[idx].done = !g_instance->items[idx].done; - g_instance->saveTodos(); - g_instance->scheduleRebuild(); + rebuildList(ctx); } -void TodoList::onDeleteClicked(lv_event_t* e) { - if (!g_instance) return; +void scheduleRebuild(Context* ctx) { + if (ctx->rebuildPending) return; + ctx->rebuildPending = true; + ctx->rebuildTimer = lv_timer_create(onDeferredRebuild, 0, ctx); + if (!ctx->rebuildTimer) { + ctx->rebuildPending = false; + return; + } + lv_timer_set_repeat_count(ctx->rebuildTimer, 1); +} + +void addItem(Context* ctx, const char* text) { + if (!text || !text[0]) return; + if (ctx->count >= Context::MAX_TODOS) return; - int idx = (int)(intptr_t)lv_event_get_user_data(e); - if (idx < 0 || idx >= g_instance->count) return; + while (*text == ' ') text++; + if (!*text) return; - for (int i = idx; i < g_instance->count - 1; i++) { - g_instance->items[i] = g_instance->items[i + 1]; + Context::TodoItem* item = &ctx->items[ctx->count]; + item->done = false; + strncpy(item->text, text, Context::MAX_TEXT_LEN - 1); + item->text[Context::MAX_TEXT_LEN - 1] = '\0'; + + size_t len = strlen(item->text); + while (len > 0 && item->text[len - 1] == ' ') { + item->text[--len] = '\0'; } - g_instance->count--; - g_instance->saveTodos(); - g_instance->scheduleRebuild(); + ctx->count++; + saveTodos(ctx); + rebuildList(ctx); } -void TodoList::onAddClicked(lv_event_t* e) { - if (!g_instance || !g_instance->inputTa) return; - const char* text = lv_textarea_get_text(g_instance->inputTa); - g_instance->addItem(text); - lv_textarea_set_text(g_instance->inputTa, ""); +/* ── Callbacks ────────────────────────────────────────────────────── */ + +void onItemClicked(lv_event_t* e) { + auto* itemCtx = static_cast(lv_event_get_user_data(e)); + Context* ctx = itemCtx->ctx; + int idx = itemCtx->index; + if (idx < 0 || idx >= ctx->count) return; + + ctx->items[idx].done = !ctx->items[idx].done; + saveTodos(ctx); + scheduleRebuild(ctx); +} + +void onDeleteClicked(lv_event_t* e) { + auto* itemCtx = static_cast(lv_event_get_user_data(e)); + Context* ctx = itemCtx->ctx; + int idx = itemCtx->index; + if (idx < 0 || idx >= ctx->count) return; + + for (int i = idx; i < ctx->count - 1; i++) { + ctx->items[i] = ctx->items[i + 1]; + } + ctx->count--; + + saveTodos(ctx); + scheduleRebuild(ctx); } -void TodoList::onInputReady(lv_event_t* e) { +void onAddClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (!ctx->inputTa) return; + const char* text = lv_textarea_get_text(ctx->inputTa); + addItem(ctx, text); + lv_textarea_set_text(ctx->inputTa, ""); +} + +void onInputReady(lv_event_t* e) { onAddClicked(e); } -void TodoList::onClearDoneClicked(lv_event_t* e) { - if (!g_instance) return; +void onClearDoneClicked(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); int write = 0; - for (int read = 0; read < g_instance->count; read++) { - if (!g_instance->items[read].done) { + for (int read = 0; read < ctx->count; read++) { + if (!ctx->items[read].done) { if (write != read) { - g_instance->items[write] = g_instance->items[read]; + ctx->items[write] = ctx->items[read]; } write++; } } - g_instance->count = write; - g_instance->saveTodos(); - g_instance->scheduleRebuild(); + ctx->count = write; + saveTodos(ctx); + scheduleRebuild(ctx); } +} // namespace + /* ── Lifecycle ────────────────────────────────────────────────────── */ -void TodoList::onShow(AppHandle app, lv_obj_t* parent) { - g_instance = this; - s_appHandle = app; +void todoListCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); - loadTodos(); + loadTodos(ctx); lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); @@ -287,12 +307,12 @@ void TodoList::onShow(AppHandle app, lv_obj_t* parent) { lv_obj_set_style_bg_opa(countWrapper, 0, LV_STATE_DEFAULT); lv_obj_remove_flag(countWrapper, LV_OBJ_FLAG_SCROLLABLE); - countLabel = lv_label_create(countWrapper); - lv_obj_set_size(countLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_align(countLabel, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_style_text_align(countLabel, LV_TEXT_ALIGN_LEFT, LV_STATE_DEFAULT); - lv_obj_set_style_text_font(countLabel, lv_font_get_default(), 0); - lv_obj_set_style_text_color(countLabel, lv_palette_main(LV_PALETTE_CYAN), LV_PART_MAIN); + ctx->countLabel = lv_label_create(countWrapper); + lv_obj_set_size(ctx->countLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_align(ctx->countLabel, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_text_align(ctx->countLabel, LV_TEXT_ALIGN_LEFT, LV_STATE_DEFAULT); + lv_obj_set_style_text_font(ctx->countLabel, lv_font_get_default(), 0); + lv_obj_set_style_text_color(ctx->countLabel, lv_palette_main(LV_PALETTE_CYAN), LV_PART_MAIN); auto ui_density = lvgl_get_ui_density(); auto toolbar_height = getToolbarHeight(ui_density); @@ -310,7 +330,7 @@ void TodoList::onShow(AppHandle app, lv_obj_t* parent) { lv_obj_set_size(clearBtn, toolbar_height - icon_padding, toolbar_height - icon_padding); lv_obj_set_style_pad_all(clearBtn, 0, LV_STATE_DEFAULT); lv_obj_align(clearBtn, LV_ALIGN_CENTER, 0, 0); - lv_obj_add_event_cb(clearBtn, onClearDoneClicked, LV_EVENT_CLICKED, nullptr); + lv_obj_add_event_cb(clearBtn, onClearDoneClicked, LV_EVENT_CLICKED, ctx); lv_obj_t* clearIcon = lv_label_create(clearBtn); lv_label_set_text(clearIcon, LV_SYMBOL_TRASH); @@ -327,45 +347,43 @@ void TodoList::onShow(AppHandle app, lv_obj_t* parent) { lv_obj_set_style_border_width(cont, 0, 0); /* Scrollable list */ - list = lv_list_create(cont); - lv_obj_set_width(list, LV_PCT(100)); - lv_obj_set_flex_grow(list, 1); + ctx->list = lv_list_create(cont); + lv_obj_set_width(ctx->list, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->list, 1); /* Input row */ - inputRow = lv_obj_create(cont); - lv_obj_set_size(inputRow, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(inputRow, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(inputRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_all(inputRow, 0, 0); - lv_obj_set_style_pad_gap(inputRow, 4, 0); - lv_obj_remove_flag(inputRow, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_border_width(inputRow, 0, 0); - - inputTa = lv_textarea_create(inputRow); - lv_textarea_set_placeholder_text(inputTa, "New task..."); - lv_textarea_set_one_line(inputTa, true); - lv_obj_set_flex_grow(inputTa, 1); - lv_obj_set_style_text_font(inputTa, lv_font_get_default(), 0); - lv_obj_add_event_cb(inputTa, onInputReady, LV_EVENT_READY, nullptr); - - lv_obj_t* addBtn = lv_button_create(inputRow); + ctx->inputRow = lv_obj_create(cont); + lv_obj_set_size(ctx->inputRow, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(ctx->inputRow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(ctx->inputRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(ctx->inputRow, 0, 0); + lv_obj_set_style_pad_gap(ctx->inputRow, 4, 0); + lv_obj_remove_flag(ctx->inputRow, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(ctx->inputRow, 0, 0); + + ctx->inputTa = lv_textarea_create(ctx->inputRow); + lv_textarea_set_placeholder_text(ctx->inputTa, "New task..."); + lv_textarea_set_one_line(ctx->inputTa, true); + lv_obj_set_flex_grow(ctx->inputTa, 1); + lv_obj_set_style_text_font(ctx->inputTa, lv_font_get_default(), 0); + lv_obj_add_event_cb(ctx->inputTa, onInputReady, LV_EVENT_READY, ctx); + + lv_obj_t* addBtn = lv_button_create(ctx->inputRow); lv_obj_t* addLbl = lv_label_create(addBtn); lv_label_set_text(addLbl, LV_SYMBOL_PLUS); - lv_obj_add_event_cb(addBtn, onAddClicked, LV_EVENT_CLICKED, nullptr); + lv_obj_add_event_cb(addBtn, onAddClicked, LV_EVENT_CLICKED, ctx); - rebuildList(); + rebuildList(ctx); } -void TodoList::onHide(AppHandle app) { - if (rebuildTimer) { - lv_timer_delete(rebuildTimer); - rebuildTimer = nullptr; +void todoListTeardown(Context* ctx) { + if (ctx->rebuildTimer) { + lv_timer_delete(ctx->rebuildTimer); + ctx->rebuildTimer = nullptr; } - rebuildPending = false; - list = nullptr; - inputRow = nullptr; - inputTa = nullptr; - countLabel = nullptr; - g_instance = nullptr; - s_appHandle = nullptr; + ctx->rebuildPending = false; + ctx->list = nullptr; + ctx->inputRow = nullptr; + ctx->inputTa = nullptr; + ctx->countLabel = nullptr; } diff --git a/Apps/TodoList/main/Source/TodoList.h b/Apps/TodoList/main/Source/TodoList.h index 50b9b14..eb4fb9a 100644 --- a/Apps/TodoList/main/Source/TodoList.h +++ b/Apps/TodoList/main/Source/TodoList.h @@ -1,12 +1,12 @@ #pragma once -#include +#include +#include #include -#include -class TodoList final : public App { - -private: +struct Context { + AppInstanceId appInstanceId = 0; + WindowId window = 0; static constexpr int MAX_TODOS = 50; static constexpr int MAX_TEXT_LEN = 128; @@ -16,7 +16,7 @@ class TodoList final : public App { bool done; }; - // UI pointers (nulled in onHide) + // UI pointers (nulled in teardown) lv_obj_t* list = nullptr; lv_obj_t* inputRow = nullptr; lv_obj_t* inputTa = nullptr; @@ -27,27 +27,10 @@ class TodoList final : public App { int count = 0; bool rebuildPending = false; lv_timer_t* rebuildTimer = nullptr; +}; - // Persistence - void saveTodos(); - void loadTodos(); - - // UI helpers - void updateCountLabel(); - void rebuildList(); - void scheduleRebuild(); - void addItem(const char* text); - - static void onDeferredRebuild(lv_timer_t* timer); - - // Static callbacks - static void onItemClicked(lv_event_t* e); - static void onDeleteClicked(lv_event_t* e); - static void onAddClicked(lv_event_t* e); - static void onInputReady(lv_event_t* e); - static void onClearDoneClicked(lv_event_t* e); +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void todoListCreateWidgets(lv_obj_t* parent, void* userData); -public: - void onShow(AppHandle context, lv_obj_t* parent) override; - void onHide(AppHandle context) override; -}; +/** Releases widget-tracking state. Call once, after the window has been torn down. */ +void todoListTeardown(Context* ctx); diff --git a/Apps/TodoList/main/Source/main.cpp b/Apps/TodoList/main/Source/main.cpp index 0f34b03..bde5492 100644 --- a/Apps/TodoList/main/Source/main.cpp +++ b/Apps/TodoList/main/Source/main.cpp @@ -1,10 +1,46 @@ #include "TodoList.h" -#include + +#include +#include +#include + +#include + +#include extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + // Heap-allocated: Context::items is a 50-entry array of 128-byte strings (~6.5KB), too big + // for a stack frame on an 8192-byte task stack (see Brainfuck's port for the same issue). + auto ctx = std::make_unique(); + ctx->appInstanceId = app_instance_id; + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, todoListCreateWidgets, ctx.get()); + ctx->window = window; + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(app_instance_id); + should_close = true; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + todoListTeardown(ctx.get()); + return 0; } diff --git a/Apps/TwoEleven/CMakeLists.txt b/Apps/TwoEleven/CMakeLists.txt index 7dda003..3bd0e05 100644 --- a/Apps/TwoEleven/CMakeLists.txt +++ b/Apps/TwoEleven/CMakeLists.txt @@ -10,7 +10,15 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +# Must be set before project() - ESP-IDF resolves components at that point, so setting these +# from inside the tactility_project() macro (which necessarily runs after project(), since it +# also calls project_elf()) would be too late. +set(EXTRA_COMPONENT_DIRS + ${TACTILITY_SDK_PATH} + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" +) project(TwoEleven) tactility_project(TwoEleven) diff --git a/Apps/TwoEleven/main/CMakeLists.txt b/Apps/TwoEleven/main/CMakeLists.txt index 0309010..3d04446 100644 --- a/Apps/TwoEleven/main/CMakeLists.txt +++ b/Apps/TwoEleven/main/CMakeLists.txt @@ -4,8 +4,5 @@ file(GLOB_RECURSE SOURCE_FILES idf_component_register( SRCS ${SOURCE_FILES} - # Library headers must be included directly, - # because all regular dependencies get stripped by elf_loader's cmake script - INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include REQUIRES TactilitySDK ) diff --git a/Apps/TwoEleven/main/Source/TwoEleven.cpp b/Apps/TwoEleven/main/Source/TwoEleven.cpp index 932d90d..371cee0 100644 --- a/Apps/TwoEleven/main/Source/TwoEleven.cpp +++ b/Apps/TwoEleven/main/Source/TwoEleven.cpp @@ -6,41 +6,32 @@ #include #include -#include -#include -#include +#include +#include +#include #include #include -#include +#include +#include +#include -constexpr auto* TAG = "TwoEleven"; +namespace { -// Preferences keys for high scores (one per grid size) -static constexpr const char* PREF_NAMESPACE = "TwoEleven"; -static constexpr const char* PREF_HIGH_3X3 = "high_3x3"; -static constexpr const char* PREF_HIGH_4X4 = "high_4x4"; -static constexpr const char* PREF_HIGH_5X5 = "high_5x5"; -static constexpr const char* PREF_HIGH_6X6 = "high_6x6"; - -// High scores for each grid size (loaded from preferences) -static int32_t highScore3x3 = 0; -static int32_t highScore4x4 = 0; -static int32_t highScore5x5 = 0; -static int32_t highScore6x6 = 0; - -static constexpr size_t SIZE_COUNT = 4; - -// Selection dialog indices (0 = How to Play, 1-4 = grid sizes) -static constexpr int32_t SELECTION_HOW_TO_PLAY = 0; -static constexpr int32_t SELECTION_3X3 = 1; -static constexpr int32_t SELECTION_4X4 = 2; -static constexpr int32_t SELECTION_5X5 = 3; -static constexpr int32_t SELECTION_6X6 = 4; +constexpr size_t SIZE_COUNT = 4; // Grid size options (index matches selection - 1) -static const uint16_t gridSizes[SIZE_COUNT] = { 3, 4, 5, 6 }; +constexpr uint16_t gridSizes[SIZE_COUNT] = { 3, 4, 5, 6 }; + +bool getPreferencesPath(std::string& outPath) { + char root[128]; + if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) { + return false; + } + outPath = std::string(root) + "/two_eleven.properties"; + return true; +} -static uint32_t getToolbarHeight(UiDensity uiDensity) { +uint32_t getToolbarHeight(UiDensity uiDensity) { if (uiDensity == LVGL_UI_DENSITY_COMPACT) { return lvgl_get_text_font_height(FONT_SIZE_DEFAULT) * 1.4f; } else { @@ -48,208 +39,208 @@ static uint32_t getToolbarHeight(UiDensity uiDensity) { } } -static uint32_t getActionIconPadding(UiDensity uiDensity) { +uint32_t getActionIconPadding(UiDensity uiDensity) { auto toolbar_height = getToolbarHeight(uiDensity); return (uiDensity != LVGL_UI_DENSITY_COMPACT) ? (uint32_t)(toolbar_height * 0.2f) : 8; } -static void loadHighScores() { - PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE); - if (prefs) { - tt_preferences_opt_int32(prefs, PREF_HIGH_3X3, &highScore3x3); - tt_preferences_opt_int32(prefs, PREF_HIGH_4X4, &highScore4x4); - tt_preferences_opt_int32(prefs, PREF_HIGH_5X5, &highScore5x5); - tt_preferences_opt_int32(prefs, PREF_HIGH_6X6, &highScore6x6); - tt_preferences_free(prefs); - } +void loadHighScores(Context* ctx) { + std::string path; + if (!getPreferencesPath(path)) return; + Preferences* prefs = preferences_open(path.c_str()); + if (!prefs) return; + preferences_opt_int32(prefs, "high_3x3", &ctx->highScore3x3); + preferences_opt_int32(prefs, "high_4x4", &ctx->highScore4x4); + preferences_opt_int32(prefs, "high_5x5", &ctx->highScore5x5); + preferences_opt_int32(prefs, "high_6x6", &ctx->highScore6x6); + preferences_close(prefs); } -static void saveHighScore(int32_t gridSize, int32_t score) { - PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE); - if (prefs) { - switch (gridSize) { - case SELECTION_3X3: - highScore3x3 = score; - tt_preferences_put_int32(prefs, PREF_HIGH_3X3, score); - break; - case SELECTION_4X4: - highScore4x4 = score; - tt_preferences_put_int32(prefs, PREF_HIGH_4X4, score); - break; - case SELECTION_5X5: - highScore5x5 = score; - tt_preferences_put_int32(prefs, PREF_HIGH_5X5, score); - break; - case SELECTION_6X6: - highScore6x6 = score; - tt_preferences_put_int32(prefs, PREF_HIGH_6X6, score); - break; - } - tt_preferences_free(prefs); +void saveHighScore(Context* ctx, int32_t gridSize, int32_t score) { + std::string path; + if (!getPreferencesPath(path)) return; + Preferences* prefs = preferences_open(path.c_str()); + if (!prefs) return; + switch (gridSize) { + case TWOELEVEN_SELECTION_3X3: + ctx->highScore3x3 = score; + preferences_put_int32(prefs, "high_3x3", score); + break; + case TWOELEVEN_SELECTION_4X4: + ctx->highScore4x4 = score; + preferences_put_int32(prefs, "high_4x4", score); + break; + case TWOELEVEN_SELECTION_5X5: + ctx->highScore5x5 = score; + preferences_put_int32(prefs, "high_5x5", score); + break; + case TWOELEVEN_SELECTION_6X6: + ctx->highScore6x6 = score; + preferences_put_int32(prefs, "high_6x6", score); + break; } + preferences_close(prefs); } -static int32_t getHighScore(int32_t gridSize) { +int32_t getHighScore(Context* ctx, int32_t gridSize) { switch (gridSize) { - case SELECTION_3X3: return highScore3x3; - case SELECTION_4X4: return highScore4x4; - case SELECTION_5X5: return highScore5x5; - case SELECTION_6X6: return highScore6x6; + case TWOELEVEN_SELECTION_3X3: return ctx->highScore3x3; + case TWOELEVEN_SELECTION_4X4: return ctx->highScore4x4; + case TWOELEVEN_SELECTION_5X5: return ctx->highScore5x5; + case TWOELEVEN_SELECTION_6X6: return ctx->highScore6x6; default: return 0; } } -void TwoEleven::showSelectionDialog() { - const char* items[] = { "How to Play", "3x3", "4x4", "5x5", "6x6" }; - selectionDialogId = tt_app_selectiondialog_start("2048", 5, items); -} - -void TwoEleven::showHelpDialog() { - const char* buttons[] = { "OK" }; - helpDialogId = tt_app_alertdialog_start( +void showHelpDialog(Context* ctx) { + const char* argv[] = { "How to Play", "Swipe or use arrow keys to move tiles.\n" "Tiles with the same number merge.\n" "Reach 2048 to win!", - buttons, 1); + "OK", + }; + app_manager_start_for_result("AlertDialog", ctx->appInstanceId, 3, argv, &ctx->helpDialogId); } -void TwoEleven::twoElevenEventCb(lv_event_t* e) { - TwoEleven* self = (TwoEleven*)lv_event_get_user_data(e); - if (self == nullptr) { - return; - } +void showSelectionDialog(Context* ctx) { + const char* argv[] = { "2048", "How to Play", "3x3", "4x4", "5x5", "6x6" }; + app_manager_start_for_result("SelectionDialog", ctx->appInstanceId, 6, argv, &ctx->selectionDialogId); +} + +void twoElevenEventCb(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx == nullptr) return; lv_event_code_t code = lv_event_get_code(e); if (code == LV_EVENT_VALUE_CHANGED) { - int32_t score = twoeleven_get_score(self->gameObject); + int32_t score = twoeleven_get_score(ctx->gameObject); - if (self->gameOverDialogId == 0 && twoeleven_get_best_tile(self->gameObject) >= 2048) { - int32_t prevHighScore = getHighScore(self->currentGridSize); + if (ctx->gameOverDialogId == 0 && twoeleven_get_best_tile(ctx->gameObject) >= 2048) { + int32_t prevHighScore = getHighScore(ctx, ctx->currentGridSize); bool isNewHighScore = score > prevHighScore; // Save high score if it's a new record if (isNewHighScore) { - saveHighScore(self->currentGridSize, score); + saveHighScore(ctx, ctx->currentGridSize, score); } - const char* alertDialogLabels[] = { "OK" }; char message[100]; + const char* title = "YOU WIN!"; if (isNewHighScore) { snprintf(message, sizeof(message), "NEW HIGH SCORE!\n\nSCORE: %" PRId32, score); - self->gameOverDialogId = tt_app_alertdialog_start("YOU WIN!", message, alertDialogLabels, 1); } else { - snprintf(message, sizeof(message), "YOU WIN!\n\nSCORE: %" PRId32 "\nBEST: %" PRId32, score, getHighScore(self->currentGridSize)); - self->gameOverDialogId = tt_app_alertdialog_start("YOU WIN!", message, alertDialogLabels, 1); + snprintf(message, sizeof(message), "YOU WIN!\n\nSCORE: %" PRId32 "\nBEST: %" PRId32, score, getHighScore(ctx, ctx->currentGridSize)); } - } else if (self->gameOverDialogId == 0 && twoeleven_get_status(self->gameObject)) { - int32_t prevHighScore = getHighScore(self->currentGridSize); + const char* argv[] = { title, message, "OK" }; + app_manager_start_for_result("AlertDialog", ctx->appInstanceId, 3, argv, &ctx->gameOverDialogId); + } else if (ctx->gameOverDialogId == 0 && twoeleven_get_status(ctx->gameObject)) { + int32_t prevHighScore = getHighScore(ctx, ctx->currentGridSize); bool isNewHighScore = score > prevHighScore; // Save high score if it's a new record if (isNewHighScore) { - saveHighScore(self->currentGridSize, score); + saveHighScore(ctx, ctx->currentGridSize, score); } - const char* alertDialogLabels[] = { "OK" }; char message[100]; + const char* title; if (isNewHighScore && score > 0) { + title = "NEW HIGH SCORE!"; snprintf(message, sizeof(message), "NEW HIGH SCORE!\n\nSCORE: %" PRId32, score); - self->gameOverDialogId = tt_app_alertdialog_start("NEW HIGH SCORE!", message, alertDialogLabels, 1); } else { - snprintf(message, sizeof(message), "GAME OVER!\n\nSCORE: %" PRId32 "\nBEST: %" PRId32, score, getHighScore(self->currentGridSize)); - self->gameOverDialogId = tt_app_alertdialog_start("GAME OVER!", message, alertDialogLabels, 1); + title = "GAME OVER!"; + snprintf(message, sizeof(message), "GAME OVER!\n\nSCORE: %" PRId32 "\nBEST: %" PRId32, score, getHighScore(ctx, ctx->currentGridSize)); } + const char* argv[] = { title, message, "OK" }; + app_manager_start_for_result("AlertDialog", ctx->appInstanceId, 3, argv, &ctx->gameOverDialogId); } else { // Update score display - lv_label_set_text_fmt(self->scoreLabel, "SCORE: %" PRId32, score); + lv_label_set_text_fmt(ctx->scoreLabel, "SCORE: %" PRId32, score); } } } -void TwoEleven::newGameBtnEvent(lv_event_t* e) { - TwoEleven* self = (TwoEleven*)lv_event_get_user_data(e); - if (self == nullptr) { - return; - } - twoeleven_set_new_game(self->gameObject); +void newGameBtnEvent(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx == nullptr) return; + twoeleven_set_new_game(ctx->gameObject); // Update score label - if (self->scoreLabel) { - lv_label_set_text_fmt(self->scoreLabel, "SCORE: %" PRId32, twoeleven_get_score(self->gameObject)); + if (ctx->scoreLabel) { + lv_label_set_text_fmt(ctx->scoreLabel, "SCORE: %" PRId32, twoeleven_get_score(ctx->gameObject)); } } -void TwoEleven::createGame(lv_obj_t* parent, uint16_t size, lv_obj_t* tb) { +void createGame(Context* ctx, lv_obj_t* parent, uint16_t size, lv_obj_t* tb) { lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); // Create game widget - gameObject = twoeleven_create(parent, size); - lv_obj_set_style_text_font(gameObject, lv_font_get_default(), 0); - lv_obj_set_size(gameObject, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_grow(gameObject, 1); + ctx->gameObject = twoeleven_create(parent, size); + lv_obj_set_style_text_font(ctx->gameObject, lv_font_get_default(), 0); + lv_obj_set_size(ctx->gameObject, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_grow(ctx->gameObject, 1); // Create score wrapper in toolbar - scoreWrapper = lv_obj_create(tb); - lv_obj_set_size(scoreWrapper, LV_SIZE_CONTENT, LV_PCT(100)); - lv_obj_set_style_pad_top(scoreWrapper, 4, LV_STATE_DEFAULT); - lv_obj_set_style_pad_bottom(scoreWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_left(scoreWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_right(scoreWrapper, 10, LV_STATE_DEFAULT); - lv_obj_set_style_pad_row(scoreWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_column(scoreWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(scoreWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(scoreWrapper, 0, LV_STATE_DEFAULT); - lv_obj_remove_flag(scoreWrapper, LV_OBJ_FLAG_SCROLLABLE); + ctx->scoreWrapper = lv_obj_create(tb); + lv_obj_set_size(ctx->scoreWrapper, LV_SIZE_CONTENT, LV_PCT(100)); + lv_obj_set_style_pad_top(ctx->scoreWrapper, 4, LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(ctx->scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(ctx->scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(ctx->scoreWrapper, 10, LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(ctx->scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(ctx->scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ctx->scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(ctx->scoreWrapper, 0, LV_STATE_DEFAULT); + lv_obj_remove_flag(ctx->scoreWrapper, LV_OBJ_FLAG_SCROLLABLE); // Create score label - scoreLabel = lv_label_create(scoreWrapper); - lv_label_set_text_fmt(scoreLabel, "SCORE: %" PRId32, twoeleven_get_score(gameObject)); - lv_obj_set_style_text_align(scoreLabel, LV_TEXT_ALIGN_LEFT, LV_STATE_DEFAULT); - lv_obj_align(scoreLabel, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_size(scoreLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_style_text_font(scoreLabel, lv_font_get_default(), 0); - lv_obj_set_style_text_color(scoreLabel, lv_palette_main(LV_PALETTE_AMBER), LV_PART_MAIN); - lv_obj_add_event_cb(gameObject, twoElevenEventCb, LV_EVENT_VALUE_CHANGED, this); + ctx->scoreLabel = lv_label_create(ctx->scoreWrapper); + lv_label_set_text_fmt(ctx->scoreLabel, "SCORE: %" PRId32, twoeleven_get_score(ctx->gameObject)); + lv_obj_set_style_text_align(ctx->scoreLabel, LV_TEXT_ALIGN_LEFT, LV_STATE_DEFAULT); + lv_obj_align(ctx->scoreLabel, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_size(ctx->scoreLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_text_font(ctx->scoreLabel, lv_font_get_default(), 0); + lv_obj_set_style_text_color(ctx->scoreLabel, lv_palette_main(LV_PALETTE_AMBER), LV_PART_MAIN); + lv_obj_add_event_cb(ctx->gameObject, twoElevenEventCb, LV_EVENT_VALUE_CHANGED, ctx); auto ui_density = lvgl_get_ui_density(); auto toolbar_height = getToolbarHeight(ui_density); auto icon_padding = getActionIconPadding(ui_density); // Create new game button wrapper - newGameWrapper = lv_obj_create(tb); - lv_obj_set_width(newGameWrapper, LV_SIZE_CONTENT); - lv_obj_set_flex_flow(newGameWrapper, LV_FLEX_FLOW_ROW); - lv_obj_set_style_pad_all(newGameWrapper, icon_padding / 2, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(newGameWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(newGameWrapper, 0, LV_STATE_DEFAULT); + ctx->newGameWrapper = lv_obj_create(tb); + lv_obj_set_width(ctx->newGameWrapper, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(ctx->newGameWrapper, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_all(ctx->newGameWrapper, icon_padding / 2, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ctx->newGameWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(ctx->newGameWrapper, 0, LV_STATE_DEFAULT); // Create new game button - lv_obj_t* newGameBtn = lv_btn_create(newGameWrapper); + lv_obj_t* newGameBtn = lv_btn_create(ctx->newGameWrapper); lv_obj_set_size(newGameBtn, toolbar_height - icon_padding, toolbar_height - icon_padding); lv_obj_set_style_pad_all(newGameBtn, 0, LV_STATE_DEFAULT); lv_obj_align(newGameBtn, LV_ALIGN_CENTER, 0, 0); - lv_obj_add_event_cb(newGameBtn, newGameBtnEvent, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(newGameBtn, newGameBtnEvent, LV_EVENT_CLICKED, ctx); lv_obj_t* btnIcon = lv_image_create(newGameBtn); lv_image_set_src(btnIcon, LV_SYMBOL_REFRESH); lv_obj_align(btnIcon, LV_ALIGN_CENTER, 0, 0); } -void TwoEleven::onHide(AppHandle appHandle) { - scoreLabel = nullptr; - scoreWrapper = nullptr; - toolbar = nullptr; - mainWrapper = nullptr; - newGameWrapper = nullptr; - gameObject = nullptr; -} +} // namespace + +void twoElevenCreateWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); -void TwoEleven::onShow(AppHandle appHandle, lv_obj_t* parent) { - // Check if we should exit (user closed selection dialog) - if (shouldExit) { - shouldExit = false; - tt_app_stop(); + // Closed the selection dialog without picking anything - close self. Emit our own close + // event rather than calling app_manager_finish()/window_manager APIs directly from inside + // this callback (window_manager's own docs warn against that - it would deadlock); the main + // loop picks this up and does the actual finish. + if (ctx->shouldExit) { + ctx->shouldExit = false; + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &event); return; } @@ -257,81 +248,56 @@ void TwoEleven::onShow(AppHandle appHandle, lv_obj_t* parent) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); // Create toolbar - toolbar = lvgl_toolbar_create(parent, "2048"); - lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + ctx->toolbar = lvgl_toolbar_create(parent, "2048"); + lv_obj_align(ctx->toolbar, LV_ALIGN_TOP_MID, 0, 0); // Create main wrapper - mainWrapper = lv_obj_create(parent); - lv_obj_set_width(mainWrapper, LV_PCT(100)); - lv_obj_set_flex_grow(mainWrapper, 1); - lv_obj_set_style_pad_all(mainWrapper, 2, LV_PART_MAIN); - lv_obj_set_style_pad_row(mainWrapper, 2, LV_PART_MAIN); - lv_obj_set_style_pad_column(mainWrapper, 2, LV_PART_MAIN); - lv_obj_set_style_border_width(mainWrapper, 0, LV_PART_MAIN); - lv_obj_remove_flag(mainWrapper, LV_OBJ_FLAG_SCROLLABLE); - - // Load high scores on first show - if (!highScoresLoaded) { - loadHighScores(); - highScoresLoaded = true; + ctx->mainWrapper = lv_obj_create(parent); + lv_obj_set_width(ctx->mainWrapper, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->mainWrapper, 1); + lv_obj_set_style_pad_all(ctx->mainWrapper, 2, LV_PART_MAIN); + lv_obj_set_style_pad_row(ctx->mainWrapper, 2, LV_PART_MAIN); + lv_obj_set_style_pad_column(ctx->mainWrapper, 2, LV_PART_MAIN); + lv_obj_set_style_border_width(ctx->mainWrapper, 0, LV_PART_MAIN); + lv_obj_remove_flag(ctx->mainWrapper, LV_OBJ_FLAG_SCROLLABLE); + + // Load high scores on first build + if (!ctx->highScoresLoaded) { + loadHighScores(ctx); + ctx->highScoresLoaded = true; } - // Check if we need to show the help dialog - if (showHelpOnShow) { - showHelpOnShow = false; - showHelpDialog(); - // Check if we have a pending size selection from onResult - } else if (pendingSelection >= SELECTION_3X3 && pendingSelection <= SELECTION_6X6) { - // Force layout update before creating game so dimensions are computed + if (ctx->showHelpOnShow) { + // A dialog we opened just closed, telling us to show help next. + ctx->showHelpOnShow = false; + showHelpDialog(ctx); + } else if (ctx->pendingSelection >= TWOELEVEN_SELECTION_3X3 && ctx->pendingSelection <= TWOELEVEN_SELECTION_6X6) { + // A dialog we opened just closed, telling us to start a game at this grid size. lv_obj_update_layout(parent); - // Track which grid size we're playing for high score saving - currentGridSize = pendingSelection; - // Start game with selected size (convert selection index to size index) - int32_t sizeIndex = pendingSelection - SELECTION_3X3; - createGame(mainWrapper, gridSizes[sizeIndex], toolbar); - pendingSelection = -1; + ctx->currentGridSize = ctx->pendingSelection; + int32_t sizeIndex = ctx->pendingSelection - TWOELEVEN_SELECTION_3X3; + createGame(ctx, ctx->mainWrapper, gridSizes[sizeIndex], ctx->toolbar); + ctx->pendingSelection = -1; + } else if (ctx->currentGridSize >= TWOELEVEN_SELECTION_3X3 && ctx->currentGridSize <= TWOELEVEN_SELECTION_6X6) { + // Resurfacing while a game was already active, but not because one of our own dialogs + // closed (e.g. another app was briefly switched to and this window got buried, which + // destroys its whole widget tree). twoeleven_create() owns all game state internally + // and that's gone now too, so there's no cheap way to resume the exact position - start + // a fresh game at the same grid size instead of dropping back to the selection dialog. + lv_obj_update_layout(parent); + int32_t sizeIndex = ctx->currentGridSize - TWOELEVEN_SELECTION_3X3; + createGame(ctx, ctx->mainWrapper, gridSizes[sizeIndex], ctx->toolbar); } else { - // Show selection dialog - showSelectionDialog(); + // First creation - show selection dialog + showSelectionDialog(ctx); } } -void TwoEleven::onResult(AppHandle appHandle, void* _Nullable data, AppLaunchId launchId, AppResult result, BundleHandle resultData) { - // Don't manipulate LVGL objects here - they may be invalid - // Just store state for onShow to handle - - if (launchId == selectionDialogId && selectionDialogId != 0) { - selectionDialogId = 0; - - int32_t selection = -1; - if (resultData != nullptr) { - selection = tt_app_selectiondialog_get_result_index(resultData); - } - - if (selection == SELECTION_HOW_TO_PLAY) { - // Mark to show help dialog in onShow - showHelpOnShow = true; - } else if (selection >= SELECTION_3X3 && selection <= SELECTION_6X6) { - // Store selection for onShow to handle - pendingSelection = selection; - } else { - // User closed dialog without selecting - mark for exit - shouldExit = true; - } - - } else if (launchId == helpDialogId && helpDialogId != 0) { - helpDialogId = 0; - // Return to selection dialog - pendingSelection = -1; - - } else if (launchId == gameOverDialogId && gameOverDialogId != 0) { - gameOverDialogId = 0; - // Mark to show selection dialog in onShow - pendingSelection = -1; - - } else if (launchId == winDialogId && winDialogId != 0) { - winDialogId = 0; - // Mark to show selection dialog in onShow - pendingSelection = -1; - } +void twoElevenTeardown(Context* ctx) { + ctx->scoreLabel = nullptr; + ctx->scoreWrapper = nullptr; + ctx->toolbar = nullptr; + ctx->mainWrapper = nullptr; + ctx->newGameWrapper = nullptr; + ctx->gameObject = nullptr; } diff --git a/Apps/TwoEleven/main/Source/TwoEleven.h b/Apps/TwoEleven/main/Source/TwoEleven.h index 1952fdd..fb47cea 100644 --- a/Apps/TwoEleven/main/Source/TwoEleven.h +++ b/Apps/TwoEleven/main/Source/TwoEleven.h @@ -1,18 +1,30 @@ +/** + * @file TwoEleven.h + * @brief 2048 game app for Tactility + */ #pragma once -#include - -#include -#include - #include "TwoElevenUi.h" #include "TwoElevenLogic.h" #include "TwoElevenHelpers.h" -class TwoEleven final : public App { +#include +#include +#include + +// Selection dialog indices (0 = How to Play, 1-4 = grid sizes) - shared between TwoEleven.cpp +// (which builds the dialog) and main.cpp (which interprets its APP_EVENT_RESULT). +constexpr int32_t TWOELEVEN_SELECTION_HOW_TO_PLAY = 0; +constexpr int32_t TWOELEVEN_SELECTION_3X3 = 1; +constexpr int32_t TWOELEVEN_SELECTION_4X4 = 2; +constexpr int32_t TWOELEVEN_SELECTION_5X5 = 3; +constexpr int32_t TWOELEVEN_SELECTION_6X6 = 4; + +struct Context { + AppInstanceId appInstanceId = 0; + WindowId window = 0; -private: - // UI element pointers (invalidated on hide, recreated on show) + // UI element pointers (invalidated on rebuild, recreated in twoElevenCreateWidgets) lv_obj_t* scoreLabel = nullptr; lv_obj_t* scoreWrapper = nullptr; lv_obj_t* toolbar = nullptr; @@ -20,28 +32,28 @@ class TwoEleven final : public App { lv_obj_t* newGameWrapper = nullptr; lv_obj_t* gameObject = nullptr; - // State tracking (persists across hide/show cycles) + // State tracking (persists across widget rebuilds) int32_t pendingSelection = -1; bool shouldExit = false; - bool showHelpOnShow = false; - int32_t currentGridSize = -1; + bool showHelpOnShow = false; // Show help dialog next time widgets are (re)built + int32_t currentGridSize = -1; // Which grid size is being played, -1 = none bool highScoresLoaded = false; - // Dialog launch IDs - AppLaunchId selectionDialogId = 0; - AppLaunchId gameOverDialogId = 0; - AppLaunchId winDialogId = 0; - AppLaunchId helpDialogId = 0; + // High scores for each grid size (loaded from preferences on first widget build) + int32_t highScore3x3 = 0; + int32_t highScore4x4 = 0; + int32_t highScore5x5 = 0; + int32_t highScore6x6 = 0; - static void twoElevenEventCb(lv_event_t* e); - static void newGameBtnEvent(lv_event_t* e); - void createGame(lv_obj_t* parent, uint16_t size, lv_obj_t* toolbar); - void showSelectionDialog(); - void showHelpDialog(); + // Dialog launch ids for tracking which dialog returned + uint32_t selectionDialogId = 0; + uint32_t gameOverDialogId = 0; + uint32_t helpDialogId = 0; +}; -public: +/** window_manager_create()'s WindowCreateWidgetsFn - @a userData is the Context* for this instance. */ +void twoElevenCreateWidgets(lv_obj_t* parent, void* userData); - void onShow(AppHandle context, lv_obj_t* parent) override; - void onHide(AppHandle context) override; - void onResult(AppHandle appHandle, void* _Nullable data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override; -}; \ No newline at end of file +/** Nothing to release beyond widget-tracking state - call once, after the window has been torn + * down. */ +void twoElevenTeardown(Context* ctx); diff --git a/Apps/TwoEleven/main/Source/main.cpp b/Apps/TwoEleven/main/Source/main.cpp index 12d95aa..ee2f4c6 100644 --- a/Apps/TwoEleven/main/Source/main.cpp +++ b/Apps/TwoEleven/main/Source/main.cpp @@ -1,10 +1,84 @@ #include "TwoEleven.h" -#include + +#include +#include +#include + +#include extern "C" { int main(int argc, char* argv[]) { - registerApp(); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + Context ctx {}; + ctx.appInstanceId = app_instance_id; + + struct AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, twoElevenCreateWidgets, &ctx); + ctx.window = window; + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(app_instance_id); + should_close = true; + break; + + case APP_EVENT_RESULT: { + uint32_t launch_id = event.result.launch_id; + + // Don't manipulate LVGL objects here - they may be invalid. Just store state for + // twoElevenCreateWidgets to handle once it runs again. + if (launch_id == ctx.selectionDialogId && ctx.selectionDialogId != 0) { + ctx.selectionDialogId = 0; + int32_t selection = event.result.result; + + if (selection == TWOELEVEN_SELECTION_HOW_TO_PLAY) { + ctx.showHelpOnShow = true; + } else if (selection >= TWOELEVEN_SELECTION_3X3 && selection <= TWOELEVEN_SELECTION_6X6) { + ctx.pendingSelection = selection; + } else { + // Closed without selecting + ctx.shouldExit = true; + } + app_manager_stop(launch_id); + + } else if (launch_id == ctx.helpDialogId && ctx.helpDialogId != 0) { + ctx.helpDialogId = 0; + // Return to selection dialog + ctx.pendingSelection = -1; + app_manager_stop(launch_id); + + } else if (launch_id == ctx.gameOverDialogId && ctx.gameOverDialogId != 0) { + ctx.gameOverDialogId = 0; + // Game has genuinely ended - return to selection dialog rather than letting + // twoElevenCreateWidgets' resurface handling start a fresh game at the same + // grid size (that path is only for burial by something else entirely). + ctx.pendingSelection = -1; + ctx.currentGridSize = -1; + app_manager_stop(launch_id); + } + break; + } + + default: + break; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + twoElevenTeardown(&ctx); + return 0; } From a35c6f617f026cae5c01db90e53b6520609c6b68 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Mon, 10 Aug 2026 01:05:38 +0200 Subject: [PATCH 6/7] Version bump --- Apps/Brainfuck/manifest.properties | 4 ++-- Apps/Breakout/manifest.properties | 4 ++-- Apps/Calculator/manifest.properties | 4 ++-- Apps/Diceware/manifest.properties | 4 ++-- Apps/EpubReader/manifest.properties | 4 ++-- Apps/EspNowBridge/manifest.properties | 4 ++-- Apps/GPIO/manifest.properties | 4 ++-- Apps/GraphicsDemo/manifest.properties | 4 ++-- Apps/HelloWorld/manifest.properties | 4 ++-- Apps/M5UnitTest/manifest.properties | 4 ++-- Apps/Magic8Ball/manifest.properties | 4 ++-- Apps/MediaKeys/manifest.properties | 4 ++-- Apps/MystifyDemo/manifest.properties | 4 ++-- Apps/SerialConsole/manifest.properties | 4 ++-- Apps/Snake/manifest.properties | 4 ++-- Apps/TamaTac/manifest.properties | 4 ++-- Apps/TodoList/manifest.properties | 4 ++-- Apps/TwoEleven/manifest.properties | 4 ++-- 18 files changed, 36 insertions(+), 36 deletions(-) diff --git a/Apps/Brainfuck/manifest.properties b/Apps/Brainfuck/manifest.properties index 7b2cd74..1e2fee1 100644 --- a/Apps/Brainfuck/manifest.properties +++ b/Apps/Brainfuck/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.brainfuck -app.version.name=0.6.0 -app.version.code=6 +app.version.name=0.8.0 +app.version.code=8 app.name=Brainfuck interpreter app.description=Brainfuck esoteric language interpreter diff --git a/Apps/Breakout/manifest.properties b/Apps/Breakout/manifest.properties index 8fc27d4..12b6fde 100644 --- a/Apps/Breakout/manifest.properties +++ b/Apps/Breakout/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.breakout -app.version.name=0.7.0 -app.version.code=7 +app.version.name=0.9.0 +app.version.code=9 app.name=Breakout app.description=Classic brick-breaking arcade game diff --git a/Apps/Calculator/manifest.properties b/Apps/Calculator/manifest.properties index 56bcdc3..591ed0d 100644 --- a/Apps/Calculator/manifest.properties +++ b/Apps/Calculator/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.calculator -app.version.name=0.7.0 -app.version.code=7 +app.version.name=0.9.0 +app.version.code=9 app.name=Calculator diff --git a/Apps/Diceware/manifest.properties b/Apps/Diceware/manifest.properties index 90ac937..62401f3 100644 --- a/Apps/Diceware/manifest.properties +++ b/Apps/Diceware/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.diceware -app.version.name=0.8.0 -app.version.code=8 +app.version.name=0.10.0 +app.version.code=10 app.name=Diceware diff --git a/Apps/EpubReader/manifest.properties b/Apps/EpubReader/manifest.properties index e546dc8..863fbd9 100644 --- a/Apps/EpubReader/manifest.properties +++ b/Apps/EpubReader/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32s3,esp32p4 app.id=one.tactility.epubreader -app.version.name=0.5.0 -app.version.code=5 +app.version.name=0.7.0 +app.version.code=7 app.name=Epub Reader app.description=Epub and text file reader. Requires PSRAM! diff --git a/Apps/EspNowBridge/manifest.properties b/Apps/EspNowBridge/manifest.properties index 6f5379d..3c8169c 100644 --- a/Apps/EspNowBridge/manifest.properties +++ b/Apps/EspNowBridge/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32p4 app.id=one.tactility.espnowbridge -app.version.name=0.3.0 -app.version.code=3 +app.version.name=0.5.0 +app.version.code=5 app.name=ESP-NOW Bridge app.description=Companion app for updating P4 device C6 co-processor firmware to enable ESP-NOW bridge support. diff --git a/Apps/GPIO/manifest.properties b/Apps/GPIO/manifest.properties index 0c06976..2211386 100644 --- a/Apps/GPIO/manifest.properties +++ b/Apps/GPIO/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.gpio -app.version.name=0.9.0 -app.version.code=9 +app.version.name=0.11.0 +app.version.code=11 app.name=GPIO diff --git a/Apps/GraphicsDemo/manifest.properties b/Apps/GraphicsDemo/manifest.properties index ffccd94..28382af 100644 --- a/Apps/GraphicsDemo/manifest.properties +++ b/Apps/GraphicsDemo/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.graphicsdemo -app.version.name=0.7.0 -app.version.code=7 +app.version.name=0.9.0 +app.version.code=9 app.name=Graphics Demo diff --git a/Apps/HelloWorld/manifest.properties b/Apps/HelloWorld/manifest.properties index 97b5b3b..81dab11 100644 --- a/Apps/HelloWorld/manifest.properties +++ b/Apps/HelloWorld/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.helloworld -app.version.name=0.7.0 -app.version.code=7 +app.version.name=0.9.0 +app.version.code=9 app.name=Hello World diff --git a/Apps/M5UnitTest/manifest.properties b/Apps/M5UnitTest/manifest.properties index 03b6951..65809dc 100644 --- a/Apps/M5UnitTest/manifest.properties +++ b/Apps/M5UnitTest/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32s3,esp32p4 app.id=one.tactility.m5unittest -app.version.name=0.5.0 -app.version.code=5 +app.version.name=0.7.0 +app.version.code=7 app.name=M5 Unit Test diff --git a/Apps/Magic8Ball/manifest.properties b/Apps/Magic8Ball/manifest.properties index ced441d..3abdc42 100644 --- a/Apps/Magic8Ball/manifest.properties +++ b/Apps/Magic8Ball/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.magic8ball -app.version.name=0.6.0 -app.version.code=6 +app.version.name=0.8.0 +app.version.code=8 app.name=Magic 8-Ball diff --git a/Apps/MediaKeys/manifest.properties b/Apps/MediaKeys/manifest.properties index 2c02be9..216f01a 100644 --- a/Apps/MediaKeys/manifest.properties +++ b/Apps/MediaKeys/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32s3,esp32p4 app.id=one.tactility.mediakeys -app.version.name=0.6.0 -app.version.code=6 +app.version.name=0.8.0 +app.version.code=8 app.name=Media Keys app.description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus. diff --git a/Apps/MystifyDemo/manifest.properties b/Apps/MystifyDemo/manifest.properties index 80f6484..e89769a 100644 --- a/Apps/MystifyDemo/manifest.properties +++ b/Apps/MystifyDemo/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.mystifydemo -app.version.name=0.8.0 -app.version.code=8 +app.version.name=0.10.0 +app.version.code=10 app.name=Mystify Demo diff --git a/Apps/SerialConsole/manifest.properties b/Apps/SerialConsole/manifest.properties index bb1579b..17ee83d 100644 --- a/Apps/SerialConsole/manifest.properties +++ b/Apps/SerialConsole/manifest.properties @@ -2,6 +2,6 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.serialconsole -app.version.name=0.9.0 -app.version.code=9 +app.version.name=0.11.0 +app.version.code=11 app.name=Serial Console diff --git a/Apps/Snake/manifest.properties b/Apps/Snake/manifest.properties index 4df898c..001482e 100644 --- a/Apps/Snake/manifest.properties +++ b/Apps/Snake/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.snake -app.version.name=0.10.0 -app.version.code=10 +app.version.name=0.12.0 +app.version.code=12 app.name=Snake app.description=Classic Snake game diff --git a/Apps/TamaTac/manifest.properties b/Apps/TamaTac/manifest.properties index d66c1f4..bf28336 100644 --- a/Apps/TamaTac/manifest.properties +++ b/Apps/TamaTac/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.tamatac -app.version.name=0.5.0 -app.version.code=5 +app.version.name=0.7.0 +app.version.code=7 app.name=TamaTac app.description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM. diff --git a/Apps/TodoList/manifest.properties b/Apps/TodoList/manifest.properties index fa45ee9..d621a4d 100644 --- a/Apps/TodoList/manifest.properties +++ b/Apps/TodoList/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.todolist -app.version.name=0.7.0 -app.version.code=7 +app.version.name=0.9.0 +app.version.code=9 app.name=Todo List app.description=Simple task list manager diff --git a/Apps/TwoEleven/manifest.properties b/Apps/TwoEleven/manifest.properties index 279875c..500dcd7 100644 --- a/Apps/TwoEleven/manifest.properties +++ b/Apps/TwoEleven/manifest.properties @@ -2,7 +2,7 @@ manifest.version=0.2 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4 app.id=one.tactility.twoeleven -app.version.name=0.9.0 -app.version.code=9 +app.version.name=0.11.0 +app.version.code=11 app.name=2048 app.description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert). From 7a70b31706bdc9432d1a96d309cce496efa9447d Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Tue, 11 Aug 2026 23:41:36 +0200 Subject: [PATCH 7/7] Fixes --- Apps/Snake/main/Source/Snake.cpp | 96 ++++++++++++------------ Apps/Snake/main/Source/Snake.h | 30 +++++++- Apps/Snake/main/Source/main.cpp | 40 ++++++---- Apps/TwoEleven/main/Source/TwoEleven.cpp | 94 ++++++++++++----------- Apps/TwoEleven/main/Source/TwoEleven.h | 30 +++++++- Apps/TwoEleven/main/Source/main.cpp | 39 ++++++---- 6 files changed, 197 insertions(+), 132 deletions(-) diff --git a/Apps/Snake/main/Source/Snake.cpp b/Apps/Snake/main/Source/Snake.cpp index b381195..e629115 100644 --- a/Apps/Snake/main/Source/Snake.cpp +++ b/Apps/Snake/main/Source/Snake.cpp @@ -9,7 +9,6 @@ #include #include -#include #include #include #include @@ -102,22 +101,6 @@ int32_t getHighScore(Context* ctx, int32_t difficulty) { } } -void showHelpDialog(Context* ctx) { - const char* argv[] = { - "How to Play", - "Swipe or use arrow keys to change direction.\n" - "Eat food to grow longer.\n" - "Don't hit yourself!", - "OK", - }; - app_manager_start_for_result("AlertDialog", ctx->appInstanceId, 3, argv, &ctx->helpDialogId); -} - -void showSelectionDialog(Context* ctx) { - const char* argv[] = { "Snake", "How to Play", "Easy", "Medium", "Hard", "Hell" }; - app_manager_start_for_result("SelectionDialog", ctx->appInstanceId, 6, argv, &ctx->selectionDialogId); -} - void snakeEventCb(lv_event_t* e) { auto* ctx = static_cast(lv_event_get_user_data(e)); lv_obj_t* target = lv_event_get_target_obj(e); @@ -229,17 +212,6 @@ void createGame(Context* ctx, lv_obj_t* parent, uint16_t cell_size, bool wallCol void snakeCreateWidgets(lv_obj_t* parent, void* userData) { auto* ctx = static_cast(userData); - // Closed the selection dialog without picking anything - close self. Emit our own close - // event rather than calling app_manager_finish()/window_manager APIs directly from inside - // this callback (window_manager's own docs warn against that - it would deadlock); the main - // loop picks this up and does the actual finish. - if (ctx->shouldExit) { - ctx->shouldExit = false; - AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; - app_event_emit(ctx->appInstanceId, &event); - return; - } - lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); @@ -263,31 +235,21 @@ void snakeCreateWidgets(lv_obj_t* parent, void* userData) { ctx->highScoresLoaded = true; } - if (ctx->showHelpOnShow) { - // A dialog we opened just closed, telling us to show help next. - ctx->showHelpOnShow = false; - showHelpDialog(ctx); - } else if (ctx->pendingSelection >= SNAKE_SELECTION_EASY && ctx->pendingSelection <= SNAKE_SELECTION_HELL) { - // A dialog we opened just closed, telling us to start a game at this difficulty. - lv_obj_update_layout(parent); - ctx->currentDifficulty = ctx->pendingSelection; - int32_t difficultyIndex = ctx->pendingSelection - SNAKE_SELECTION_EASY; - bool wallCollision = (ctx->pendingSelection == SNAKE_SELECTION_HELL); - createGame(ctx, ctx->mainWrapper, difficultySizes[difficultyIndex], wallCollision, ctx->toolbar); - ctx->pendingSelection = -1; - } else if (ctx->currentDifficulty >= SNAKE_SELECTION_EASY && ctx->currentDifficulty <= SNAKE_SELECTION_HELL) { - // Resurfacing while a game was already active, but not because one of our own dialogs - // closed (e.g. another app was briefly switched to and this window got buried, which - // destroys its whole widget tree). snake_create() owns all game state internally and - // that's gone now too, so there's no cheap way to resume the exact position - start a - // fresh game at the same difficulty instead of dropping back to the selection dialog. + // Rebuild whatever was already committed. A game in progress survives a resurface caused by + // burial from something other than our own dialogs (window_manager only ever destroys the + // widget tree - Context's state, including currentDifficulty, is untouched) - snake_create() + // owns all game state internally though, so there's no cheap way to resume the exact + // position; this starts a fresh game at the same difficulty instead. + // + // Deliberately never opens a dialog from here - see Snake.h's comment on + // snakeShowSelectionDialog()/snakeShowHelpDialog()/snakeStartGame()/snakeClearGame() for why: + // this callback can run on a different app's thread mid-resurface, racing ahead of main()'s + // own APP_EVENT_RESULT processing. + if (ctx->currentDifficulty >= SNAKE_SELECTION_EASY && ctx->currentDifficulty <= SNAKE_SELECTION_HELL) { lv_obj_update_layout(parent); int32_t difficultyIndex = ctx->currentDifficulty - SNAKE_SELECTION_EASY; bool wallCollision = (ctx->currentDifficulty == SNAKE_SELECTION_HELL); createGame(ctx, ctx->mainWrapper, difficultySizes[difficultyIndex], wallCollision, ctx->toolbar); - } else { - // First creation - show selection dialog - showSelectionDialog(ctx); } } @@ -299,3 +261,39 @@ void snakeTeardown(Context* ctx) { ctx->newGameWrapper = nullptr; ctx->gameObject = nullptr; } + +void snakeShowSelectionDialog(Context* ctx) { + const char* argv[] = { "Snake", "How to Play", "Easy", "Medium", "Hard", "Hell" }; + app_manager_start_for_result("SelectionDialog", ctx->appInstanceId, 6, argv, &ctx->selectionDialogId); +} + +void snakeShowHelpDialog(Context* ctx) { + const char* argv[] = { + "How to Play", + "Swipe or use arrow keys to change direction.\n" + "Eat food to grow longer.\n" + "Don't hit yourself!", + "OK", + }; + app_manager_start_for_result("AlertDialog", ctx->appInstanceId, 3, argv, &ctx->helpDialogId); +} + +void snakeClearGame(Context* ctx) { + if (ctx->scoreWrapper) { lv_obj_delete(ctx->scoreWrapper); ctx->scoreWrapper = nullptr; } + if (ctx->newGameWrapper) { lv_obj_delete(ctx->newGameWrapper); ctx->newGameWrapper = nullptr; } + if (ctx->mainWrapper) lv_obj_clean(ctx->mainWrapper); + ctx->scoreLabel = nullptr; + ctx->gameObject = nullptr; + ctx->currentDifficulty = -1; +} + +void snakeStartGame(Context* ctx, int32_t difficulty) { + if (!ctx->mainWrapper || !ctx->toolbar) return; + snakeClearGame(ctx); // tear down any previous game's leftovers first (harmless if none) + + lv_obj_update_layout(ctx->mainWrapper); + ctx->currentDifficulty = difficulty; + int32_t difficultyIndex = difficulty - SNAKE_SELECTION_EASY; + bool wallCollision = (difficulty == SNAKE_SELECTION_HELL); + createGame(ctx, ctx->mainWrapper, difficultySizes[difficultyIndex], wallCollision, ctx->toolbar); +} diff --git a/Apps/Snake/main/Source/Snake.h b/Apps/Snake/main/Source/Snake.h index 7af76a8..93a48fc 100644 --- a/Apps/Snake/main/Source/Snake.h +++ b/Apps/Snake/main/Source/Snake.h @@ -33,9 +33,6 @@ struct Context { lv_obj_t* gameObject = nullptr; // State tracking (persists across widget rebuilds) - int32_t pendingSelection = -1; // -1 = show selection, 1-4 = start game with difficulty - bool shouldExit = false; - bool showHelpOnShow = false; // Show help dialog next time widgets are (re)built bool highScoresLoaded = false; int32_t currentDifficulty = -1; // Which difficulty is being played, -1 = none @@ -57,3 +54,30 @@ void snakeCreateWidgets(lv_obj_t* parent, void* userData); /** Nothing to release beyond widget-tracking state - call once, after the window has been torn * down. */ void snakeTeardown(Context* ctx); + +// The three functions below are driven directly by main.cpp - once at startup, and again after +// each dialog's APP_EVENT_RESULT is processed. They must NOT be called from snakeCreateWidgets: +// window_manager_create()'s docs warn that a window's create_widgets callback can run on a +// *different app's* thread (here, whichever dialog we're resurfacing past, inside its own +// window_manager_remove() call) - racing ahead of our own main() thread's APP_EVENT_RESULT +// processing. Deciding "what's next" from inside create_widgets would act on stale state and +// can open a duplicate dialog before the real result is even seen (this was a real, always-on +// bug: every dialog close spawned a fresh duplicate SelectionDialog before its own result was +// processed, snowballing into an unbounded start/stop loop). + +/** Opens the difficulty/help SelectionDialog. Doesn't touch any widgets - safe to call without + * the LVGL lock. */ +void snakeShowSelectionDialog(Context* ctx); + +/** Opens the "How to Play" AlertDialog. Doesn't touch any widgets - safe to call without the + * LVGL lock. */ +void snakeShowHelpDialog(Context* ctx); + +/** Tears down any previous game and starts a fresh one at @a difficulty (one of + * SNAKE_SELECTION_EASY..SNAKE_SELECTION_HELL). Touches widgets - caller must hold the LVGL + * lock. */ +void snakeStartGame(Context* ctx, int32_t difficulty); + +/** Tears down the current game (if any), leaving mainWrapper empty and currentDifficulty back + * to -1. Touches widgets - caller must hold the LVGL lock. */ +void snakeClearGame(Context* ctx); diff --git a/Apps/Snake/main/Source/main.cpp b/Apps/Snake/main/Source/main.cpp index 87971e1..2b302e0 100644 --- a/Apps/Snake/main/Source/main.cpp +++ b/Apps/Snake/main/Source/main.cpp @@ -4,11 +4,14 @@ #include #include +#include #include extern "C" { int main(int argc, char* argv[]) { + constexpr TickType_t LVGL_LOCK_TIMEOUT = 500; + AppInstanceId app_instance_id = app_scheduler_current_app_id(); Context ctx {}; @@ -21,6 +24,11 @@ int main(int argc, char* argv[]) { WindowId window = window_manager_create(app_instance_id, snakeCreateWidgets, &ctx); ctx.window = window; + // First launch: nothing active yet - open the selection dialog. Every later transition is + // likewise driven from here (APP_EVENT_RESULT below), never from snakeCreateWidgets - see + // Snake.h's comment on why. + snakeShowSelectionDialog(&ctx); + bool should_close = false; while (!should_close) { struct AppEvent event; @@ -36,37 +44,39 @@ int main(int argc, char* argv[]) { case APP_EVENT_RESULT: { uint32_t launch_id = event.result.launch_id; - // Don't manipulate LVGL objects here - they may be invalid (this window may - // still be buried, or mid-rebuild). Just store state for snakeCreateWidgets to - // handle once it runs again. if (launch_id == ctx.selectionDialogId && ctx.selectionDialogId != 0) { ctx.selectionDialogId = 0; int32_t selection = event.result.result; + app_manager_stop(launch_id); if (selection == SNAKE_SELECTION_HOW_TO_PLAY) { - ctx.showHelpOnShow = true; + snakeShowHelpDialog(&ctx); } else if (selection >= SNAKE_SELECTION_EASY && selection <= SNAKE_SELECTION_HELL) { - ctx.pendingSelection = selection; + if (lvgl_try_lock(LVGL_LOCK_TIMEOUT)) { + snakeStartGame(&ctx, selection); + lvgl_unlock(); + } } else { - // Closed without selecting - ctx.shouldExit = true; + // Closed without selecting - close self. + app_manager_finish(app_instance_id); + should_close = true; } - app_manager_stop(launch_id); } else if (launch_id == ctx.helpDialogId && ctx.helpDialogId != 0) { ctx.helpDialogId = 0; - // Return to selection dialog - ctx.pendingSelection = -1; app_manager_stop(launch_id); + // Return to selection dialog + snakeShowSelectionDialog(&ctx); } else if (launch_id == ctx.gameOverDialogId && ctx.gameOverDialogId != 0) { ctx.gameOverDialogId = 0; - // Game has genuinely ended - return to selection dialog rather than letting - // snakeCreateWidgets' resurface handling start a fresh game at the same - // difficulty (that path is only for burial by something else entirely). - ctx.pendingSelection = -1; - ctx.currentDifficulty = -1; app_manager_stop(launch_id); + // Game has genuinely ended - clear it and return to the selection dialog. + if (lvgl_try_lock(LVGL_LOCK_TIMEOUT)) { + snakeClearGame(&ctx); + lvgl_unlock(); + } + snakeShowSelectionDialog(&ctx); } break; } diff --git a/Apps/TwoEleven/main/Source/TwoEleven.cpp b/Apps/TwoEleven/main/Source/TwoEleven.cpp index 371cee0..de1039c 100644 --- a/Apps/TwoEleven/main/Source/TwoEleven.cpp +++ b/Apps/TwoEleven/main/Source/TwoEleven.cpp @@ -7,7 +7,6 @@ #include #include #include -#include #include #include #include @@ -92,22 +91,6 @@ int32_t getHighScore(Context* ctx, int32_t gridSize) { } } -void showHelpDialog(Context* ctx) { - const char* argv[] = { - "How to Play", - "Swipe or use arrow keys to move tiles.\n" - "Tiles with the same number merge.\n" - "Reach 2048 to win!", - "OK", - }; - app_manager_start_for_result("AlertDialog", ctx->appInstanceId, 3, argv, &ctx->helpDialogId); -} - -void showSelectionDialog(Context* ctx) { - const char* argv[] = { "2048", "How to Play", "3x3", "4x4", "5x5", "6x6" }; - app_manager_start_for_result("SelectionDialog", ctx->appInstanceId, 6, argv, &ctx->selectionDialogId); -} - void twoElevenEventCb(lv_event_t* e) { auto* ctx = static_cast(lv_event_get_user_data(e)); if (ctx == nullptr) return; @@ -233,17 +216,6 @@ void createGame(Context* ctx, lv_obj_t* parent, uint16_t size, lv_obj_t* tb) { void twoElevenCreateWidgets(lv_obj_t* parent, void* userData) { auto* ctx = static_cast(userData); - // Closed the selection dialog without picking anything - close self. Emit our own close - // event rather than calling app_manager_finish()/window_manager APIs directly from inside - // this callback (window_manager's own docs warn against that - it would deadlock); the main - // loop picks this up and does the actual finish. - if (ctx->shouldExit) { - ctx->shouldExit = false; - AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; - app_event_emit(ctx->appInstanceId, &event); - return; - } - lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); @@ -267,29 +239,20 @@ void twoElevenCreateWidgets(lv_obj_t* parent, void* userData) { ctx->highScoresLoaded = true; } - if (ctx->showHelpOnShow) { - // A dialog we opened just closed, telling us to show help next. - ctx->showHelpOnShow = false; - showHelpDialog(ctx); - } else if (ctx->pendingSelection >= TWOELEVEN_SELECTION_3X3 && ctx->pendingSelection <= TWOELEVEN_SELECTION_6X6) { - // A dialog we opened just closed, telling us to start a game at this grid size. - lv_obj_update_layout(parent); - ctx->currentGridSize = ctx->pendingSelection; - int32_t sizeIndex = ctx->pendingSelection - TWOELEVEN_SELECTION_3X3; - createGame(ctx, ctx->mainWrapper, gridSizes[sizeIndex], ctx->toolbar); - ctx->pendingSelection = -1; - } else if (ctx->currentGridSize >= TWOELEVEN_SELECTION_3X3 && ctx->currentGridSize <= TWOELEVEN_SELECTION_6X6) { - // Resurfacing while a game was already active, but not because one of our own dialogs - // closed (e.g. another app was briefly switched to and this window got buried, which - // destroys its whole widget tree). twoeleven_create() owns all game state internally - // and that's gone now too, so there's no cheap way to resume the exact position - start - // a fresh game at the same grid size instead of dropping back to the selection dialog. + // Rebuild whatever was already committed. A game in progress survives a resurface caused by + // burial from something other than our own dialogs (window_manager only ever destroys the + // widget tree - Context's state, including currentGridSize, is untouched) - twoeleven_create() + // owns all game state internally though, so there's no cheap way to resume the exact + // position; this starts a fresh game at the same grid size instead. + // + // Deliberately never opens a dialog from here - see TwoEleven.h's comment on + // twoElevenShowSelectionDialog()/twoElevenShowHelpDialog()/twoElevenStartGame()/ + // twoElevenClearGame() for why: this callback can run on a different app's thread + // mid-resurface, racing ahead of main()'s own APP_EVENT_RESULT processing. + if (ctx->currentGridSize >= TWOELEVEN_SELECTION_3X3 && ctx->currentGridSize <= TWOELEVEN_SELECTION_6X6) { lv_obj_update_layout(parent); int32_t sizeIndex = ctx->currentGridSize - TWOELEVEN_SELECTION_3X3; createGame(ctx, ctx->mainWrapper, gridSizes[sizeIndex], ctx->toolbar); - } else { - // First creation - show selection dialog - showSelectionDialog(ctx); } } @@ -301,3 +264,38 @@ void twoElevenTeardown(Context* ctx) { ctx->newGameWrapper = nullptr; ctx->gameObject = nullptr; } + +void twoElevenShowSelectionDialog(Context* ctx) { + const char* argv[] = { "2048", "How to Play", "3x3", "4x4", "5x5", "6x6" }; + app_manager_start_for_result("SelectionDialog", ctx->appInstanceId, 6, argv, &ctx->selectionDialogId); +} + +void twoElevenShowHelpDialog(Context* ctx) { + const char* argv[] = { + "How to Play", + "Swipe or use arrow keys to move tiles.\n" + "Tiles with the same number merge.\n" + "Reach 2048 to win!", + "OK", + }; + app_manager_start_for_result("AlertDialog", ctx->appInstanceId, 3, argv, &ctx->helpDialogId); +} + +void twoElevenClearGame(Context* ctx) { + if (ctx->scoreWrapper) { lv_obj_delete(ctx->scoreWrapper); ctx->scoreWrapper = nullptr; } + if (ctx->newGameWrapper) { lv_obj_delete(ctx->newGameWrapper); ctx->newGameWrapper = nullptr; } + if (ctx->mainWrapper) lv_obj_clean(ctx->mainWrapper); + ctx->scoreLabel = nullptr; + ctx->gameObject = nullptr; + ctx->currentGridSize = -1; +} + +void twoElevenStartGame(Context* ctx, int32_t gridSize) { + if (!ctx->mainWrapper || !ctx->toolbar) return; + twoElevenClearGame(ctx); // tear down any previous game's leftovers first (harmless if none) + + lv_obj_update_layout(ctx->mainWrapper); + ctx->currentGridSize = gridSize; + int32_t sizeIndex = gridSize - TWOELEVEN_SELECTION_3X3; + createGame(ctx, ctx->mainWrapper, gridSizes[sizeIndex], ctx->toolbar); +} diff --git a/Apps/TwoEleven/main/Source/TwoEleven.h b/Apps/TwoEleven/main/Source/TwoEleven.h index fb47cea..6db43bc 100644 --- a/Apps/TwoEleven/main/Source/TwoEleven.h +++ b/Apps/TwoEleven/main/Source/TwoEleven.h @@ -33,9 +33,6 @@ struct Context { lv_obj_t* gameObject = nullptr; // State tracking (persists across widget rebuilds) - int32_t pendingSelection = -1; - bool shouldExit = false; - bool showHelpOnShow = false; // Show help dialog next time widgets are (re)built int32_t currentGridSize = -1; // Which grid size is being played, -1 = none bool highScoresLoaded = false; @@ -57,3 +54,30 @@ void twoElevenCreateWidgets(lv_obj_t* parent, void* userData); /** Nothing to release beyond widget-tracking state - call once, after the window has been torn * down. */ void twoElevenTeardown(Context* ctx); + +// The four functions below are driven directly by main.cpp - once at startup, and again after +// each dialog's APP_EVENT_RESULT is processed. They must NOT be called from +// twoElevenCreateWidgets: window_manager_create()'s docs warn that a window's create_widgets +// callback can run on a *different app's* thread (here, whichever dialog we're resurfacing past, +// inside its own window_manager_remove() call) - racing ahead of our own main() thread's +// APP_EVENT_RESULT processing. Deciding "what's next" from inside create_widgets would act on +// stale state and can open a duplicate dialog before the real result is even seen (this was a +// real, always-on bug: every dialog close spawned a fresh duplicate SelectionDialog before its +// own result was processed, snowballing into an unbounded start/stop loop). + +/** Opens the grid-size/help SelectionDialog. Doesn't touch any widgets - safe to call without + * the LVGL lock. */ +void twoElevenShowSelectionDialog(Context* ctx); + +/** Opens the "How to Play" AlertDialog. Doesn't touch any widgets - safe to call without the + * LVGL lock. */ +void twoElevenShowHelpDialog(Context* ctx); + +/** Tears down any previous game and starts a fresh one at @a gridSize (one of + * TWOELEVEN_SELECTION_3X3..TWOELEVEN_SELECTION_6X6). Touches widgets - caller must hold the + * LVGL lock. */ +void twoElevenStartGame(Context* ctx, int32_t gridSize); + +/** Tears down the current game (if any), leaving mainWrapper empty and currentGridSize back to + * -1. Touches widgets - caller must hold the LVGL lock. */ +void twoElevenClearGame(Context* ctx); diff --git a/Apps/TwoEleven/main/Source/main.cpp b/Apps/TwoEleven/main/Source/main.cpp index ee2f4c6..70c842f 100644 --- a/Apps/TwoEleven/main/Source/main.cpp +++ b/Apps/TwoEleven/main/Source/main.cpp @@ -4,11 +4,14 @@ #include #include +#include #include extern "C" { int main(int argc, char* argv[]) { + constexpr TickType_t LVGL_LOCK_TIMEOUT = 500; + AppInstanceId app_instance_id = app_scheduler_current_app_id(); Context ctx {}; @@ -21,6 +24,11 @@ int main(int argc, char* argv[]) { WindowId window = window_manager_create(app_instance_id, twoElevenCreateWidgets, &ctx); ctx.window = window; + // First launch: nothing active yet - open the selection dialog. Every later transition is + // likewise driven from here (APP_EVENT_RESULT below), never from twoElevenCreateWidgets - + // see TwoEleven.h's comment on why. + twoElevenShowSelectionDialog(&ctx); + bool should_close = false; while (!should_close) { struct AppEvent event; @@ -36,36 +44,39 @@ int main(int argc, char* argv[]) { case APP_EVENT_RESULT: { uint32_t launch_id = event.result.launch_id; - // Don't manipulate LVGL objects here - they may be invalid. Just store state for - // twoElevenCreateWidgets to handle once it runs again. if (launch_id == ctx.selectionDialogId && ctx.selectionDialogId != 0) { ctx.selectionDialogId = 0; int32_t selection = event.result.result; + app_manager_stop(launch_id); if (selection == TWOELEVEN_SELECTION_HOW_TO_PLAY) { - ctx.showHelpOnShow = true; + twoElevenShowHelpDialog(&ctx); } else if (selection >= TWOELEVEN_SELECTION_3X3 && selection <= TWOELEVEN_SELECTION_6X6) { - ctx.pendingSelection = selection; + if (lvgl_try_lock(LVGL_LOCK_TIMEOUT)) { + twoElevenStartGame(&ctx, selection); + lvgl_unlock(); + } } else { - // Closed without selecting - ctx.shouldExit = true; + // Closed without selecting - close self. + app_manager_finish(app_instance_id); + should_close = true; } - app_manager_stop(launch_id); } else if (launch_id == ctx.helpDialogId && ctx.helpDialogId != 0) { ctx.helpDialogId = 0; - // Return to selection dialog - ctx.pendingSelection = -1; app_manager_stop(launch_id); + // Return to selection dialog + twoElevenShowSelectionDialog(&ctx); } else if (launch_id == ctx.gameOverDialogId && ctx.gameOverDialogId != 0) { ctx.gameOverDialogId = 0; - // Game has genuinely ended - return to selection dialog rather than letting - // twoElevenCreateWidgets' resurface handling start a fresh game at the same - // grid size (that path is only for burial by something else entirely). - ctx.pendingSelection = -1; - ctx.currentGridSize = -1; app_manager_stop(launch_id); + // Game has genuinely ended - clear it and return to the selection dialog. + if (lvgl_try_lock(LVGL_LOCK_TIMEOUT)) { + twoElevenClearGame(&ctx); + lvgl_unlock(); + } + twoElevenShowSelectionDialog(&ctx); } break; }