From 83b0f93fb95ecd2b327e6463a3b489cf147f7af7 Mon Sep 17 00:00:00 2001 From: liu weikai Date: Fri, 16 Oct 2026 09:43:54 +0800 Subject: [PATCH] Add PSRAM-backed menu widgets and unify adaptive launchers --- .github/workflows/ci.yml | 9 +- .../src/esp32_lvgl_arduino_agenda.cpp | 2 + .../esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake | 3 + .../t_display_p4_amoled/sdkconfig.defaults | 1 + .../t_display_p4_tft/sdkconfig.defaults | 1 + docs/images/menu-widgets/p4-menu.png | Bin 0 -> 68118 bytes docs/images/menu-widgets/pager-agenda.png | Bin 0 -> 8275 bytes docs/images/menu-widgets/pager-menu.png | Bin 0 -> 20545 bytes docs/images/menu-widgets/tdeck-menu.png | Bin 0 -> 14544 bytes docs/uiux/components/menu_widgets.md | 109 ++++ .../ui/menu/dashboard/dashboard_state.h | 91 +--- .../ui/menu/dashboard/dashboard_widget.h | 76 +++ .../ui/menu/dashboard/dashboard_widgets.h | 25 +- .../include/ui/menu/menu_dashboard.h | 16 +- .../dashboard/dashboard_agenda_widget.cpp | 85 +++ .../dashboard/dashboard_compass_widget.cpp | 35 +- .../menu/dashboard/dashboard_gps_widget.cpp | 96 ++-- .../menu/dashboard/dashboard_mesh_widget.cpp | 51 +- .../dashboard/dashboard_recent_widget.cpp | 43 +- .../src/ui/menu/dashboard/dashboard_state.cpp | 27 +- .../ui_shared/src/ui/menu/menu_dashboard.cpp | 494 +++++++++++------- modules/ui_shared/src/ui/menu/menu_layout.cpp | 97 +++- .../ui_shared/src/ui/menu/menu_profile.cpp | 68 +-- .../ui_shared/src/ui/menu/menu_runtime.cpp | 18 +- modules/ui_shared/tests/agenda/CMakeLists.txt | 1 + .../tests/agenda/menu_widget_stub.cpp | 9 + .../test_cardputer_zero_menu_profile.cpp | 5 +- .../esp/arduino_common/src/LV_Helper_v9.cpp | 2 + platform/esp/idf_common/src/lvgl_memory.cpp | 33 ++ tests/menu_widgets/CMakeLists.txt | 32 ++ tests/menu_widgets/fakes/esp_attr.h | 3 + tests/menu_widgets/fakes/esp_heap_caps.h | 7 + tests/menu_widgets/fakes/sdkconfig.h | 2 + tests/menu_widgets/idf_fakes/lvgl.h | 13 + tests/menu_widgets/test_idf_allocator.cpp | 39 ++ tests/menu_widgets/test_ownership.cpp | 64 +++ tests/site/menu-widgets-native.test.mjs | 128 +++++ tests/site/native-frame-png.mjs | 32 ++ tools/web_simulator/CMakeLists.txt | 14 +- tools/web_simulator/native_menu.cpp | 8 + tools/web_simulator/native_runtime.cpp | 2 + tools/web_simulator/preview_agenda.cpp | 48 ++ tools/web_simulator/preview_p4_dashboard.cpp | 98 ++-- 43 files changed, 1394 insertions(+), 493 deletions(-) create mode 100644 docs/images/menu-widgets/p4-menu.png create mode 100644 docs/images/menu-widgets/pager-agenda.png create mode 100644 docs/images/menu-widgets/pager-menu.png create mode 100644 docs/images/menu-widgets/tdeck-menu.png create mode 100644 docs/uiux/components/menu_widgets.md create mode 100644 modules/ui_shared/include/ui/menu/dashboard/dashboard_widget.h create mode 100644 modules/ui_shared/src/ui/menu/dashboard/dashboard_agenda_widget.cpp create mode 100644 modules/ui_shared/tests/agenda/menu_widget_stub.cpp create mode 100644 platform/esp/idf_common/src/lvgl_memory.cpp create mode 100644 tests/menu_widgets/CMakeLists.txt create mode 100644 tests/menu_widgets/fakes/esp_attr.h create mode 100644 tests/menu_widgets/fakes/esp_heap_caps.h create mode 100644 tests/menu_widgets/fakes/sdkconfig.h create mode 100644 tests/menu_widgets/idf_fakes/lvgl.h create mode 100644 tests/menu_widgets/test_idf_allocator.cpp create mode 100644 tests/menu_widgets/test_ownership.cpp create mode 100644 tests/site/menu-widgets-native.test.mjs create mode 100644 tests/site/native-frame-png.mjs create mode 100644 tools/web_simulator/preview_agenda.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28716cab..f30c0c67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -594,6 +594,12 @@ jobs: cmake --build .codex-build/p4-hardware --parallel 2 ctest --test-dir .codex-build/p4-hardware --output-on-failure + - name: Verify isolated widget ownership and strict PSRAM allocation + run: | + cmake -S tests/menu_widgets -B .codex-build/menu-widgets -G Ninja -DCMAKE_BUILD_TYPE=Debug -DLVGL_DIR="$PWD/.codex-build/p4-keyboard-deps/lvgl" -DLVGL_LIBRARY="$PWD/.codex-build/p4-keyboard/lvgl/lib/liblvgl.a" + cmake --build .codex-build/menu-widgets --parallel 2 + ctest --test-dir .codex-build/menu-widgets --output-on-failure + p4_rendered_ui: name: T-Display P4 Rendered UI runs-on: ubuntu-latest @@ -617,8 +623,9 @@ jobs: run: | source "$RUNNER_TEMP/p4-emsdk/emsdk_env.sh" emcmake cmake -S tools/web_simulator -B .codex-build/p4-preview/build -G Ninja -DCMAKE_BUILD_TYPE=Release -DTRAIL_MATE_PREVIEW_P4=ON -DLVGL_SOURCE_DIR="$PWD/.codex-build/p4-preview/lvgl" - cmake --build .codex-build/p4-preview/build --target trailmate_p4_tft trailmate_p4_amoled --parallel 2 + cmake --build .codex-build/p4-preview/build --target trailmate_p4_tft trailmate_p4_amoled trailmate_pager trailmate_tdeck trailmate_wio --parallel 2 P4_UI_ARTIFACT_DIR=.codex-build/p4-preview/artifacts node tests/site/p4-native-ui.test.mjs + WIDGET_UI_ARTIFACT_DIR=.codex-build/p4-preview/artifacts node tests/site/menu-widgets-native.test.mjs - name: Retain actual LVGL frames and layout coordinates if: always() uses: actions/upload-artifact@v4 diff --git a/apps/esp32_lvgl/src/esp32_lvgl_arduino_agenda.cpp b/apps/esp32_lvgl/src/esp32_lvgl_arduino_agenda.cpp index ab2c80e9..ce440cb5 100644 --- a/apps/esp32_lvgl/src/esp32_lvgl_arduino_agenda.cpp +++ b/apps/esp32_lvgl/src/esp32_lvgl_arduino_agenda.cpp @@ -14,6 +14,7 @@ #include "product_composition/agenda_target.h" #include "ui/app_runtime.h" #include "ui/callback_app_screen.h" +#include "ui/menu/menu_dashboard.h" #include "ui/presentation_sources/runtime_gps_status_source.h" #include "ui/screens/agenda/agenda_page_flow.h" #include "ui/screens/agenda/agenda_page_runtime.h" @@ -188,6 +189,7 @@ void initialize() root.updateStorage(true); // Keep the page available to report storage failure, never silently format. root.initialized = true; + ui::menu::dashboard::bindAgendaSource(root.page.source); } AppScreen* application() diff --git a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake index f303190b..dc3b6da5 100644 --- a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake +++ b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake @@ -238,6 +238,7 @@ set(TRAILMATE_ESP_IDF_UI_SHARED_SOURCES "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/assets/fonts/font_utils.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp" + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/menu/dashboard/dashboard_agenda_widget.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/menu/dashboard/dashboard_gps_widget.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/menu/dashboard/dashboard_recent_widget.cpp" @@ -485,6 +486,7 @@ set(TRAILMATE_ESP_IDF_PLATFORM_COMMON_SOURCES "${TRAILMATE_ROOT}/platform/esp/idf_common/src/sdmmc_host_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/gps_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/lv_helper.cpp" + "${TRAILMATE_ROOT}/platform/esp/idf_common/src/lvgl_memory.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_device_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_map_diagnostics.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/platform_ui_firmware_update_runtime.cpp" @@ -638,6 +640,7 @@ set(TRAILMATE_ESP_IDF_FINAL_INCLUDE_DIRS "${TRAILMATE_ROOT}/modules/core_hostlink/include" "${TRAILMATE_ROOT}/modules/core_track_sharing/include" "${TRAILMATE_ROOT}/modules/core_sys/include" + "${TRAILMATE_ROOT}/modules/core_agenda/include" "${TRAILMATE_ROOT}/modules/core_chat/include" "${TRAILMATE_ROOT}/modules/core_chat/generated" "${TRAILMATE_ROOT}/modules/core_chat/third_party/nanopb" diff --git a/builds/esp_idf/targets/t_display_p4_amoled/sdkconfig.defaults b/builds/esp_idf/targets/t_display_p4_amoled/sdkconfig.defaults index dff2c51d..a3f5fafc 100644 --- a/builds/esp_idf/targets/t_display_p4_amoled/sdkconfig.defaults +++ b/builds/esp_idf/targets/t_display_p4_amoled/sdkconfig.defaults @@ -5,6 +5,7 @@ CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" CONFIG_SPIRAM=y +CONFIG_LV_USE_CUSTOM_MALLOC=y CONFIG_SPIRAM_MODE_HEX=y CONFIG_SPIRAM_SPEED_200M=y # Keep PSRAM available to the continuous MIPI-DSI scan-out while the diff --git a/builds/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults b/builds/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults index 20608f9a..5c6f9b81 100644 --- a/builds/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults +++ b/builds/esp_idf/targets/t_display_p4_tft/sdkconfig.defaults @@ -5,6 +5,7 @@ CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" CONFIG_SPIRAM=y +CONFIG_LV_USE_CUSTOM_MALLOC=y CONFIG_SPIRAM_MODE_HEX=y CONFIG_SPIRAM_SPEED_200M=y # Keep PSRAM available to the continuous MIPI-DSI scan-out while the diff --git a/docs/images/menu-widgets/p4-menu.png b/docs/images/menu-widgets/p4-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..556d09e8ba5a12e8db1e3b798db897d6e6c64042 GIT binary patch literal 68118 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

5!@Fh{Wo%#K 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

y-nw{Lz8GM)a^QTd3;7=>qSnSe`9QvQOf}N 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 `@+k?;=)92yT;HGgOMP8HrQ= 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

21+>(g*{tBae-Oxa)~iRlW9UMVVU0?uKaQ`jO% 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 A6951J literal 0 HcmV?d00001 diff --git a/docs/images/menu-widgets/pager-agenda.png b/docs/images/menu-widgets/pager-agenda.png new file mode 100644 index 0000000000000000000000000000000000000000..de1a740e7a7689729fa636f99bc74f7f8a23d213 GIT binary patch literal 8275 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 F{{eoFeKP<6 literal 0 HcmV?d00001 diff --git a/docs/images/menu-widgets/pager-menu.png b/docs/images/menu-widgets/pager-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..550c65e89c7d401b2de63e66b7d7693f046ec0cd GIT binary patch literal 20545 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 literal 0 HcmV?d00001 diff --git a/docs/images/menu-widgets/tdeck-menu.png b/docs/images/menu-widgets/tdeck-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..0f0aca024c66b1736d3763f1b5c47a2a7050d602 GIT binary patch literal 14544 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 OVH9LlWoo5>A^!_ynfE;a literal 0 HcmV?d00001 diff --git a/docs/uiux/components/menu_widgets.md b/docs/uiux/components/menu_widgets.md new file mode 100644 index 00000000..3e7464a2 --- /dev/null +++ b/docs/uiux/components/menu_widgets.md @@ -0,0 +1,109 @@ +# Menu widget pages + +Pager, T-Deck and Wio Tracker L2 share the same adaptive launcher grid. The +76-pixel-high cards use a 72-pixel minimum width and wrap according to available width: six columns on the +480-pixel Pager and four on the 320-pixel T-Deck/L2. Overflow scrolls vertically. +Pager no longer uses a separate large-card carousel. + +Column widths divide the available width equally after reserving inter-column +gaps. Indivisible pixels are split between the outside margins, keeping their +difference within one pixel. Incomplete rows retain the same column tracks. +Keyboard focus uses a pale rounded tile with an inset border; the launcher +starts below the top bar with an explicit gap. The compact viewport contains +whole rows and centers them vertically between the top and bottom bars. + +## Navigation + +| Surface | Input | Result | +| --- | --- | --- | +| T-Deck/L2 launcher | Swipe right | Open the negative screen | +| Negative screen | Swipe left, D, Right, Escape or Back | Return to the launcher | +| Negative screen | Swipe up/down, W/S, Up/Down | Next/previous widget | +| Pager launcher | W/A/S/D | Move through the icon grid; A at the left boundary opens the negative screen | +| Pager launcher | Encoder rotation | Change the focused application icon | +| Pager negative screen | Encoder rotation | Next/previous widget | +| P4 right dashboard | Swipe left/right | Next/previous widget page | + +The negative screen shows exactly one widget: Mesh, GPS, Recent Activity, +Compass or Agenda. Navigation stops at the first/last widget. P4 keeps the +left launcher and displays four status widgets on its first right-hand page, +then Agenda on the second. Footer controls provide a visible alternative to +gestures. Widget browsing does not activate a background launcher icon. + +A drag is owned by its original menu gesture until physical release. Leaving +the initially pressed control is not cancellation of that gesture. Recognized +swipes cancel the original click before replacing any controls. App screens, +sleep scenes and modal focus groups retain their own input ownership. + +## Widget boundaries + +- `dashboard_widget.h` defines the root/refresh handle, strict external-memory + allocation and DELETE ownership. It contains no service data. +- Each `dashboard_*_widget.cpp` owns its private state, child controls, data + reads and cleanup. No widget accesses `DashboardUi` or another widget's state. +- `menu_dashboard.cpp` owns selection, arrangement, gestures and one refresh + timer. `dashboard_state.*` retains only this container's state in PSRAM. +- Widgets read the existing GPS, messaging, mesh, orientation and Agenda ports. + They create no service tasks, persistent stores or duplicate background caches. +- The target composition explicitly binds its `IAgendaSource`. T-Deck, L2 and + Pager bind the existing Agenda model. P4 currently has no Agenda composition; + its page reports that capability as unavailable until one is bound. Preview + Agenda data is supplied only by a test adapter, never by firmware. + +The container creates only visible widgets. Closing the negative screen, +changing a widget/page, or entering an app deletes those controls. Root DELETE +callbacks destroy each private state exactly once. Deleting/reinitializing the +container cancels its timer before its state disappears. + +## Memory policy + +Container state, widget state, GNSS satellite/ranking arrays, recent-text caches +and the bounded Agenda snapshot are allocated with +`MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT` on ESP. Allocation failure returns no +widget; it never substitutes internal RAM. Large snapshots are not automatic +LVGL-task locals. + +Arduino widgets use the existing `PsramUiLease` contract for LVGL allocation. +The unified menu grid is also created within that contract. P4 selects a custom +LVGL allocator that routes small and large UI allocations to PSRAM, including +subsequent label reallocations. Peripheral/DMA buffers retain their SDK policy. +Only small registry pointers remain in internal static storage. Existing +service APIs retain their own transient allocation behavior; this change does +not claim that the whole firmware or its stack has zero internal-RAM activity. + +The shared container ticks once per second and schedules visible widgets at +one-, two- or four-second cadences. Agenda's bounded +query runs on opening and at most once per 30 seconds while visible. Hidden +Agenda widgets are destroyed and issue no queries. All widgets retain localized +text until it actually changes. + +## Verification + +`tests/menu_widgets` tests the actual allocation/ownership helpers against real +LVGL, repeated destruction and explicit PSRAM failure. It separately compiles +the production IDF allocator and checks that even small allocations and failed +reallocations obey the external-memory policy. + +`tests/site/menu-widgets-native.test.mjs` compiles/runs the real production UI +for Pager, T-Deck, L2 and both P4 panels. It checks adaptive columns, contextual +encoder behavior, touch/key navigation, one visible widget, hidden-query +suppression, lease release, repeated entry/exit and P4 paging. Setting +`WIDGET_UI_ARTIFACT_DIR` saves actual RGB565-rendered PNGs and layout coordinates. +The existing P4 lifecycle suite continues to exercise 250 container rebuilds +per panel. + +Physical touch thresholds, encoder direction, internal/PSRAM heap peaks and +rendering latency still require device acceptance under live service load. + +## Actual LVGL captures + +These frames are rendered by the production LVGL code with deterministic +preview services. Agenda entries below are preview fixtures, not device data. + +![Pager: six symmetric columns and inset focus](../../images/menu-widgets/pager-menu.png) + +![T-Deck and L2: four columns and whole rows](../../images/menu-widgets/tdeck-menu.png) + +![P4: left launcher and paged right dashboard](../../images/menu-widgets/p4-menu.png) + +![Pager negative screen: Agenda widget](../../images/menu-widgets/pager-agenda.png) diff --git a/modules/ui_shared/include/ui/menu/dashboard/dashboard_state.h b/modules/ui_shared/include/ui/menu/dashboard/dashboard_state.h index 3febc87c..6b4b283c 100644 --- a/modules/ui_shared/include/ui/menu/dashboard/dashboard_state.h +++ b/modules/ui_shared/include/ui/menu/dashboard/dashboard_state.h @@ -1,90 +1,31 @@ #pragma once - -#include "ui/menu/dashboard/dashboard_style.h" -#include +#include "ui/menu/dashboard/dashboard_widget.h" +#include namespace ui::menu::dashboard { - -struct MeshWidgetUi -{ - DashboardCardChrome chrome{}; - lv_obj_t* orbit_outer = nullptr; - lv_obj_t* orbit_mid = nullptr; - lv_obj_t* orbit_core = nullptr; - lv_obj_t* hero_value = nullptr; - lv_obj_t* hero_caption = nullptr; - lv_obj_t* stat_tiles[3]{}; - lv_obj_t* stat_values[3]{}; - lv_obj_t* stat_captions[3]{}; - lv_obj_t* signal_bar_wrap = nullptr; - lv_obj_t* signal_bars[12]{}; - lv_obj_t* footer_label = nullptr; -}; - -struct GpsWidgetUi -{ - DashboardCardChrome chrome{}; - lv_obj_t* radar = nullptr; - lv_obj_t* radar_rings[3]{}; - lv_obj_t* radar_cross_h = nullptr; - lv_obj_t* radar_cross_v = nullptr; - lv_obj_t* sat_dots[8]{}; - lv_obj_t* sat_labels[8]{}; - lv_obj_t* sat_use_tags[8]{}; - lv_obj_t* stat_tiles[3]{}; - lv_obj_t* stat_values[3]{}; - lv_obj_t* stat_captions[3]{}; - lv_obj_t* footer_label = nullptr; -}; - -struct RecentWidgetUi -{ - DashboardCardChrome chrome{}; - lv_obj_t* rows[2]{}; - lv_obj_t* row_dots[2]{}; - lv_obj_t* row_names[2]{}; - lv_obj_t* row_previews[2]{}; - std::string row_name_text[2]; - std::string row_preview_text[2]; - lv_obj_t* row_badges[2]{}; - lv_obj_t* row_badge_labels[2]{}; - lv_obj_t* scan_track = nullptr; - lv_obj_t* scan_runner = nullptr; - lv_obj_t* footer_label = nullptr; -}; - -struct CompassWidgetUi -{ - DashboardCardChrome chrome{}; - lv_obj_t* dial = nullptr; - lv_obj_t* dial_ring = nullptr; - lv_obj_t* dial_inner_ring = nullptr; - lv_obj_t* north_marker = nullptr; - lv_obj_t* needle = nullptr; - lv_point_precise_t needle_points[2]{}; - lv_obj_t* center_dot = nullptr; - lv_obj_t* cardinal_labels[4]{}; - lv_obj_t* target_name = nullptr; - lv_obj_t* distance_value = nullptr; - lv_obj_t* bearing_value = nullptr; - lv_obj_t* footer_label = nullptr; -}; - +// Container state only. No child controls or service data belong here. +// This owner is allocated in PSRAM; the registry retains one pointer. struct DashboardUi { lv_obj_t* panel = nullptr; lv_obj_t* grid = nullptr; + lv_obj_t* menu_grid = nullptr; + lv_obj_t* pager = nullptr; lv_timer_t* timer = nullptr; + std::array widgets{}; uint32_t tick = 0; bool dock_right = false; - MeshWidgetUi mesh{}; - GpsWidgetUi gps{}; - RecentWidgetUi recent{}; - CompassWidgetUi compass{}; + bool feed_open = false; + bool active = true; + uint8_t page = 0; + lv_indev_t* pointer = nullptr; + lv_point_t start{}; + bool pressed = false; + bool inside_dashboard = false; }; -DashboardUi& dashboard_state(); +DashboardUi* dashboard_state(); +bool dashboard_create(); void dashboard_reset(); - } // namespace ui::menu::dashboard diff --git a/modules/ui_shared/include/ui/menu/dashboard/dashboard_widget.h b/modules/ui_shared/include/ui/menu/dashboard/dashboard_widget.h new file mode 100644 index 00000000..37689cbe --- /dev/null +++ b/modules/ui_shared/include/ui/menu/dashboard/dashboard_widget.h @@ -0,0 +1,76 @@ +#pragma once +#include "lvgl.h" +#include "platform/ui/psram_ui_lease.h" +#include +#include +#if defined(ESP_PLATFORM) +#include "esp_heap_caps.h" +#endif + +namespace ui::menu::dashboard +{ +enum class WidgetKind : uint8_t +{ + Mesh, + Gps, + Recent, + Compass, + Agenda, + Count +}; +struct WidgetHandle +{ + lv_obj_t* root = nullptr; + void* state = nullptr; + void (*refresh)(void*) = nullptr; + uint32_t period_ms = 1000; + uint32_t next_refresh_ms = 0; +}; +inline void* widget_allocate(std::size_t bytes) +{ +#if defined(ESP_PLATFORM) + return heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +#else + return std::malloc(bytes); +#endif +} +inline void widget_free(void* memory) +{ +#if defined(ESP_PLATFORM) + heap_caps_free(memory); +#else + std::free(memory); +#endif +} +struct WidgetLifetime +{ + platform::ui::PsramUiLease memory; +}; +template +T* create_widget_state() +{ + void* memory = widget_allocate(sizeof(T)); + return memory ? new (memory) T{} : nullptr; +} +template +void delete_widget_state(T* state) +{ + if (!state) return; + state->~T(); + widget_free(state); +} +template +WidgetHandle own_widget(lv_obj_t* root, T* state, void (*refresh)(void*), uint32_t period_ms = 1000) +{ + lv_obj_set_user_data(root, state); + lv_obj_add_event_cb( + root, [](lv_event_t* event) + { + // Child controls can borrow arrays in this state (compass points). + // They must finish DELETE before the backing PSRAM is released. + lv_obj_clean(lv_event_get_target_obj(event)); + delete_widget_state(static_cast(lv_event_get_user_data(event))); }, + LV_EVENT_DELETE, state); + return {root, state, refresh, period_ms, 0}; +} +} // namespace ui::menu::dashboard diff --git a/modules/ui_shared/include/ui/menu/dashboard/dashboard_widgets.h b/modules/ui_shared/include/ui/menu/dashboard/dashboard_widgets.h index c021822b..3b870c3b 100644 --- a/modules/ui_shared/include/ui/menu/dashboard/dashboard_widgets.h +++ b/modules/ui_shared/include/ui/menu/dashboard/dashboard_widgets.h @@ -1,20 +1,15 @@ #pragma once +#include "ui/menu/dashboard/dashboard_widget.h" -#include "lvgl.h" - +namespace ui::agenda +{ +class IAgendaSource; +} namespace ui::menu::dashboard { - -void create_mesh_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h); -void refresh_mesh_widget(); - -void create_gps_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h); -void refresh_gps_widget(); - -void create_recent_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h); -void refresh_recent_widget(); - -void create_compass_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h); -void refresh_compass_widget(); - +WidgetHandle create_mesh_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t height); +WidgetHandle create_gps_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t height); +WidgetHandle create_recent_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t height); +WidgetHandle create_compass_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t height); +WidgetHandle create_agenda_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t height, ui::agenda::IAgendaSource* source); } // namespace ui::menu::dashboard diff --git a/modules/ui_shared/include/ui/menu/menu_dashboard.h b/modules/ui_shared/include/ui/menu/menu_dashboard.h index ff741635..e1de3ffd 100644 --- a/modules/ui_shared/include/ui/menu/menu_dashboard.h +++ b/modules/ui_shared/include/ui/menu/menu_dashboard.h @@ -4,14 +4,24 @@ #include "ui/menu/menu_layout.h" +namespace ui::agenda +{ +class IAgendaSource; +} + namespace ui::menu::dashboard { -#if defined(TRAIL_MATE_ESP_BOARD_TAB5) || defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) +#if defined(TRAIL_MATE_ESP_BOARD_TAB5) || defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) || defined(ARDUINO_T_LORA_PAGER) void init(lv_obj_t* menu_panel, lv_obj_t* grid_panel, const ui::menu_layout::InitOptions& options); void bringToFront(); void refresh_localized_text(); void setActive(bool active); +// Target-owned read port; its lifetime must exceed the menu's lifetime. +void bindAgendaSource(ui::agenda::IAgendaSource* source); +bool isFeedOpen(); +bool handleKey(uint32_t key, bool at_left_edge = false); +bool handleEncoder(int delta); #else inline void init(lv_obj_t* menu_panel, lv_obj_t* grid_panel, const ui::menu_layout::InitOptions& options) { @@ -32,6 +42,10 @@ inline void setActive(bool active) { (void)active; } +inline void bindAgendaSource(ui::agenda::IAgendaSource*) {} +inline bool isFeedOpen() { return false; } +inline bool handleKey(uint32_t, bool = false) { return false; } +inline bool handleEncoder(int) { return false; } #endif } // namespace ui::menu::dashboard diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_agenda_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_agenda_widget.cpp new file mode 100644 index 00000000..70f99000 --- /dev/null +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_agenda_widget.cpp @@ -0,0 +1,85 @@ +#include "ui/localization.h" +#include "ui/menu/dashboard/dashboard_style.h" +#include "ui/menu/dashboard/dashboard_widgets.h" +#include "ui_presentation/agenda/agenda_source.h" +#include +#include +#include + +namespace ui::menu::dashboard +{ +namespace +{ +struct AgendaWidgetUi : WidgetLifetime +{ + DashboardCardChrome chrome{}; + lv_obj_t* text = nullptr; + ui::agenda::IAgendaSource* source = nullptr; + ui::agenda::AgendaSnapshot snapshot{}; + uint32_t next_query = 0; + char buffer[640]{}; +}; +void refresh_agenda_widget(void* context) +{ + auto& state = *static_cast(context); + if (!state.source) + { + const auto* unavailable = ui::i18n::tr("Agenda is unavailable on this device."); + if (std::strcmp(lv_label_get_text(state.text), unavailable) != 0) + ui::i18n::set_label_text_raw(state.text, unavailable); + return; + } + const uint32_t now = lv_tick_get(); + if (state.next_query != 0 && static_cast(now - state.next_query) < 0) return; + state.next_query = now + 30000U; + const auto clock = state.source->currentTime(); + ui::agenda::AgendaRequest request; + request.day_start = clock.calendar_seconds / 86400 * 86400; + request.visible_rows = lv_obj_get_height(state.chrome.card) < 230 ? 2 : 3; + state.source->snapshot(request, state.snapshot); + if (!state.snapshot.clock_valid) std::snprintf(state.buffer, sizeof(state.buffer), "%s", ui::i18n::tr("Waiting for time")); + else if (!state.snapshot.storage_ready || state.snapshot.result != ::agenda::StoreResult::Ok) + std::snprintf(state.buffer, sizeof(state.buffer), "%s", ui::i18n::tr("Storage unavailable")); + else if (state.snapshot.page.count == 0) + std::snprintf(state.buffer, sizeof(state.buffer), "%s", ui::i18n::tr("No upcoming events")); + else + { + size_t used = 0; + for (uint8_t index = 0; index < std::min(state.snapshot.page.count, request.visible_rows); ++index) + { + const auto& row = state.snapshot.page.rows[index]; + const auto start = row.occurrence.start; + const auto days = start / 86400 - clock.calendar_seconds / 86400; + char day[64]; + if (days == 0 || days == 1) std::snprintf(day, sizeof(day), "%s", ui::i18n::tr(days == 0 ? "Today" : "Tomorrow")); + else std::snprintf(day, sizeof(day), "%s", ui::i18n::format("In %d days", static_cast(days)).c_str()); + const int bytes = std::snprintf(state.buffer + used, sizeof(state.buffer) - used, + "%s%s %02d:%02d\n%.*s", index ? "\n\n" : "", day, + static_cast((start / 3600) % 24), static_cast((start / 60) % 60), + static_cast(sizeof(row.title)), row.title); + if (bytes < 0 || static_cast(bytes) >= sizeof(state.buffer) - used) break; + used += static_cast(bytes); + } + } + if (std::strcmp(lv_label_get_text(state.text), state.buffer) != 0) + ui::i18n::set_label_text_raw(state.text, state.buffer); + set_status_chip(state.chrome, state.snapshot.storage_ready ? "Upcoming" : "--", color_soft_amber(), color_text()); +} +} // namespace +WidgetHandle create_agenda_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t height, ui::agenda::IAgendaSource* source) +{ + auto* state = create_widget_state(); + if (!state) return {}; + state->source = source; + state->chrome = create_card_chrome(parent, ui::i18n::tr("Agenda"), width, height); + state->text = lv_label_create(state->chrome.body); + style_body_label(state->text, &lv_font_montserrat_14, color_text()); + lv_obj_set_width(state->text, LV_PCT(100)); + lv_label_set_long_mode(state->text, LV_LABEL_LONG_WRAP); + ui::i18n::set_label_text_raw(state->text, ""); + auto* footer = lv_label_create(state->chrome.footer); + style_footer_label(footer); + ui::i18n::set_label_text(footer, "Upcoming"); + return own_widget(state->chrome.card, state, refresh_agenda_widget); +} +} // namespace ui::menu::dashboard diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp index aa268e0f..72620413 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp @@ -12,7 +12,7 @@ #include "platform/ui/orientation_runtime.h" #include "platform/ui/team_ui_store_runtime.h" #include "ui/localization.h" -#include "ui/menu/dashboard/dashboard_state.h" +#include "ui/menu/dashboard/dashboard_style.h" #if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12 #define lv_font_montserrat_12 lv_font_montserrat_14 @@ -23,6 +23,24 @@ namespace ui::menu::dashboard namespace { +struct CompassWidgetUi : WidgetLifetime +{ + DashboardCardChrome chrome{}; + lv_obj_t* dial = nullptr; + lv_obj_t* dial_ring = nullptr; + lv_obj_t* dial_inner_ring = nullptr; + lv_obj_t* north_marker = nullptr; + lv_obj_t* needle = nullptr; + lv_point_precise_t needle_points[2]{}; + float displayed_bearing = -1.0f; + lv_obj_t* center_dot = nullptr; + lv_obj_t* cardinal_labels[4]{}; + lv_obj_t* target_name = nullptr; + lv_obj_t* distance_value = nullptr; + lv_obj_t* bearing_value = nullptr; + lv_obj_t* footer_label = nullptr; +}; + void set_label_text_if_changed_raw(lv_obj_t* label, const char* text) { if (label == nullptr || text == nullptr) @@ -65,6 +83,8 @@ std::string best_name_for(const chat::contacts::PeerDirectoryItem& info) void update_needle(CompassWidgetUi& compass, float bearing) { + if (compass.displayed_bearing == bearing) return; + compass.displayed_bearing = bearing; const float center = static_cast(lv_obj_get_content_width(compass.dial)) / 2.0f; const float radius = std::max(1.0f, center - 12.0f); const float radians = bearing * 3.14159265358979323846f / 180.0f; @@ -78,9 +98,13 @@ void update_needle(CompassWidgetUi& compass, float bearing) } // namespace -void create_compass_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) +void refresh_compass_widget(void* context); + +WidgetHandle create_compass_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) { - auto& compass = dashboard_state().compass; + auto* memory = create_widget_state(); + if (!memory) return {}; + auto& compass = *memory; compass.chrome = create_card_chrome(parent, ::ui::i18n::tr("Mini Compass"), card_w, card_h); const lv_coord_t body_w = lv_obj_get_content_width(compass.chrome.body); @@ -163,11 +187,12 @@ void create_compass_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_ style_footer_label(compass.footer_label); lv_obj_align(compass.footer_label, LV_ALIGN_LEFT_MID, 0, 0); ::ui::i18n::set_label_text(compass.footer_label, "compass wakes after GPS fix"); + return own_widget(compass.chrome.card, memory, refresh_compass_widget); } -void refresh_compass_widget() +void refresh_compass_widget(void* context) { - auto& compass = dashboard_state().compass; + auto& compass = *static_cast(context); if (compass.chrome.card == nullptr) { return; diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_gps_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_gps_widget.cpp index 555b364b..97ca93c8 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_gps_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_gps_widget.cpp @@ -9,7 +9,7 @@ #include "platform/ui/gps_runtime.h" #include "sys/clock.h" #include "ui/localization.h" -#include "ui/menu/dashboard/dashboard_state.h" +#include "ui/menu/dashboard/dashboard_style.h" #include "ui/presentation_sources/runtime_gps_status_source.h" #include "ui_presentation/gps/gps_status_model.h" @@ -22,47 +22,25 @@ namespace ui::menu::dashboard namespace { -::ui::gps::GpsStatusModel& dashboard_gps_status_model() +struct GpsWidgetUi : WidgetLifetime { - static ::ui::gps::GpsStatusModel model( - ::ui::presentation_sources::runtime_gps_status_source()); - return model; -} - -void logDashboardGpsFlow(bool has_snapshot, std::size_t sat_count, const ::gps::GnssStatus& gnss, bool fix) -{ - static bool s_last_has_snapshot = false; - static bool s_last_fix = false; - static std::size_t s_last_sat_count = static_cast(-1); - static uint8_t s_last_sats_in_view = 0xFF; - static uint8_t s_last_sats_in_use = 0xFF; - static uint32_t s_last_log_ms = 0; - - const uint32_t now_ms = sys::millis_now(); - const bool changed = (has_snapshot != s_last_has_snapshot) || (fix != s_last_fix) || (sat_count != s_last_sat_count) || - (gnss.sats_in_view != s_last_sats_in_view) || (gnss.sats_in_use != s_last_sats_in_use); - const bool suspicious_full_scale = - has_snapshot && sat_count == ::gps::kMaxGnssSats && gnss.sats_in_view != static_cast(sat_count); - if (!changed && !suspicious_full_scale && (now_ms - s_last_log_ms) < 2000U) - { - return; - } - - s_last_has_snapshot = has_snapshot; - s_last_fix = fix; - s_last_sat_count = sat_count; - s_last_sats_in_view = gnss.sats_in_view; - s_last_sats_in_use = gnss.sats_in_use; - s_last_log_ms = now_ms; - - std::printf("[ui][gps][dashboard] has=%u fix=%u count=%u used=%u view=%u hdop=%.1f\n", - static_cast(has_snapshot ? 1 : 0), - static_cast(fix ? 1 : 0), - static_cast(sat_count), - static_cast(gnss.sats_in_use), - static_cast(gnss.sats_in_view), - static_cast(gnss.hdop)); -} + DashboardCardChrome chrome{}; + lv_obj_t* radar = nullptr; + lv_obj_t* radar_rings[3]{}; + lv_obj_t* radar_cross_h = nullptr; + lv_obj_t* radar_cross_v = nullptr; + lv_obj_t* sat_dots[8]{}; + lv_obj_t* sat_labels[8]{}; + lv_obj_t* sat_use_tags[8]{}; + lv_obj_t* stat_tiles[3]{}; + lv_obj_t* stat_values[3]{}; + lv_obj_t* stat_captions[3]{}; + lv_obj_t* footer_label = nullptr; + ui::gps::GpsStatusModel model{ui::presentation_sources::runtime_gps_status_source()}; + ::gps::GnssSatInfo sats[::gps::kMaxGnssSats]{}; + ::gps::GnssStatus gnss{}; + std::array<::gps::GnssSatInfo, ::gps::kMaxGnssSats> ranked{}; +}; void set_label_text_if_changed_raw(lv_obj_t* label, const char* text) { @@ -77,20 +55,6 @@ void set_label_text_if_changed_raw(lv_obj_t* label, const char* text) } } -void set_label_text_if_changed(lv_obj_t* label, const char* english) -{ - const char* localized = ::ui::i18n::tr(english); - if (label == nullptr || localized == nullptr) - { - return; - } - const char* current = lv_label_get_text(label); - if (current == nullptr || std::strcmp(current, localized) != 0) - { - ::ui::i18n::set_label_text_raw(label, localized); - } -} - lv_color_t satellite_color(::gps::GnssSystem sys) { switch (sys) @@ -154,9 +118,13 @@ void create_stat_tiles(GpsWidgetUi& gps_ui, lv_obj_t* body, lv_coord_t x, lv_coo } // namespace -void create_gps_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) +void refresh_gps_widget(void* context); + +WidgetHandle create_gps_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) { - auto& gps_ui = dashboard_state().gps; + auto* memory = create_widget_state(); + if (!memory) return {}; + auto& gps_ui = *memory; gps_ui.chrome = create_card_chrome(parent, ::ui::i18n::tr("GPS Status"), card_w, card_h); const lv_coord_t body_w = lv_obj_get_content_width(gps_ui.chrome.body); @@ -232,29 +200,29 @@ void create_gps_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) style_footer_label(gps_ui.footer_label); lv_obj_align(gps_ui.footer_label, LV_ALIGN_LEFT_MID, 0, 0); ::ui::i18n::set_label_text(gps_ui.footer_label, "searching satellites"); + return own_widget(gps_ui.chrome.card, memory, refresh_gps_widget, 2000); } -void refresh_gps_widget() +void refresh_gps_widget(void* context) { - auto& gps_ui = dashboard_state().gps; + auto& gps_ui = *static_cast(context); if (gps_ui.chrome.card == nullptr) { return; } - ::gps::GnssSatInfo sats[::gps::kMaxGnssSats]; + auto& sats = gps_ui.sats; std::size_t sat_count = 0; - ::gps::GnssStatus gnss{}; + auto& gnss = gps_ui.gnss; const bool has_snapshot = platform::ui::gps::get_gnss_snapshot(sats, ::gps::kMaxGnssSats, &sat_count, &gnss); - const auto status_snapshot = dashboard_gps_status_model().snapshot(); + const auto status_snapshot = gps_ui.model.snapshot(); const bool fix = status_snapshot.header.valid ? status_snapshot.fix_valid : (has_snapshot && gnss.fix != ::gps::GnssFix::NOFIX); const unsigned visible_sats = status_snapshot.header.valid ? static_cast(status_snapshot.satellites) : static_cast(has_snapshot ? sat_count : 0); - logDashboardGpsFlow(has_snapshot, sat_count, gnss, fix); const char* status_label = status_snapshot.header.valid ? status_snapshot.fix_label.c_str() @@ -277,7 +245,7 @@ void refresh_gps_widget() std::snprintf(hdop_buf, sizeof(hdop_buf), "%.1f", has_snapshot ? gnss.hdop : 0.0f); set_label_text_if_changed_raw(gps_ui.stat_values[2], hdop_buf); - std::array<::gps::GnssSatInfo, ::gps::kMaxGnssSats> ranked{}; + auto& ranked = gps_ui.ranked; for (size_t i = 0; i < sat_count && i < ranked.size(); ++i) { ranked[i] = sats[i]; diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp index ead266e5..94b8e269 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_mesh_widget.cpp @@ -6,7 +6,7 @@ #include "platform/ui/device_runtime.h" #include "ui/localization.h" -#include "ui/menu/dashboard/dashboard_state.h" +#include "ui/menu/dashboard/dashboard_style.h" #include "ui/presentation_sources/runtime_mesh_status_source.h" #include "ui_presentation/mesh/mesh_status_model.h" @@ -19,6 +19,23 @@ namespace ui::menu::dashboard namespace { +struct MeshWidgetUi : WidgetLifetime +{ + DashboardCardChrome chrome{}; + lv_obj_t* orbit_outer = nullptr; + lv_obj_t* orbit_mid = nullptr; + lv_obj_t* orbit_core = nullptr; + lv_obj_t* hero_value = nullptr; + lv_obj_t* hero_caption = nullptr; + lv_obj_t* stat_tiles[3]{}; + lv_obj_t* stat_values[3]{}; + lv_obj_t* stat_captions[3]{}; + lv_obj_t* signal_bar_wrap = nullptr; + lv_obj_t* signal_bars[12]{}; + lv_obj_t* footer_label = nullptr; + ui::mesh::MeshStatusModel model{ui::presentation_sources::runtime_mesh_status_source()}; +}; + void set_label_text_if_changed_raw(lv_obj_t* label, const char* text) { if (label == nullptr || text == nullptr) @@ -69,21 +86,23 @@ void create_stat_column(MeshWidgetUi& mesh, lv_obj_t* parent, lv_coord_t x, lv_c style_body_label(mesh.stat_captions[i], &lv_font_montserrat_12, color_text_dim()); lv_obj_align(mesh.stat_captions[i], LV_ALIGN_BOTTOM_LEFT, 0, 0); ::ui::i18n::set_label_text(mesh.stat_captions[i], kCaptions[i]); + if (body_h < 132) + { + lv_obj_align(mesh.stat_values[i], LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_align(mesh.stat_captions[i], LV_ALIGN_RIGHT_MID, 0, 0); + } } } -::ui::mesh::MeshStatusModel& meshStatusModel() -{ - static ::ui::mesh::MeshStatusModel model( - ::ui::presentation_sources::runtime_mesh_status_source()); - return model; -} - } // namespace -void create_mesh_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) +void refresh_mesh_widget(void* context); + +WidgetHandle create_mesh_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) { - auto& mesh = dashboard_state().mesh; + auto* memory = create_widget_state(); + if (!memory) return {}; + auto& mesh = *memory; mesh.chrome = create_card_chrome(parent, ::ui::i18n::tr("Mesh Status"), card_w, card_h); const lv_coord_t body_w = lv_obj_get_content_width(mesh.chrome.body); @@ -114,6 +133,11 @@ void create_mesh_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) mesh.hero_caption = lv_label_create(mesh.orbit_core); style_body_label(mesh.hero_caption, &lv_font_montserrat_12, lv_color_hex(0xFFF7E8)); lv_obj_align(mesh.hero_caption, LV_ALIGN_CENTER, 0, 10); + if (hero_d < 105) + { + lv_obj_add_flag(mesh.hero_caption, LV_OBJ_FLAG_HIDDEN); + lv_obj_align(mesh.hero_value, LV_ALIGN_CENTER, 0, 0); + } ::ui::i18n::set_label_text(mesh.hero_caption, "online"); create_stat_column(mesh, mesh.chrome.body, hero_d + 16, body_h); @@ -141,17 +165,18 @@ void create_mesh_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) style_footer_label(mesh.footer_label); lv_obj_align(mesh.footer_label, LV_ALIGN_RIGHT_MID, 0, 0); ::ui::i18n::set_label_text(mesh.footer_label, "waiting for traffic"); + return own_widget(mesh.chrome.card, memory, refresh_mesh_widget, 4000); } -void refresh_mesh_widget() +void refresh_mesh_widget(void* context) { - auto& mesh = dashboard_state().mesh; + auto& mesh = *static_cast(context); if (mesh.chrome.card == nullptr) { return; } - const auto status = meshStatusModel().snapshot(); + const auto status = mesh.model.snapshot(); const bool has_team = status.header.valid && status.team_linked; const int unread = status.header.valid ? static_cast(status.unread_messages) : 0; const auto battery = platform::ui::device::battery_info(); diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_recent_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_recent_widget.cpp index f1b9b46d..b62d79c0 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_recent_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_recent_widget.cpp @@ -1,5 +1,6 @@ #include "ui/menu/dashboard/dashboard_widgets.h" +#include #include #include #include @@ -8,7 +9,7 @@ #include "chat/domain/chat_types.h" #include "chat/usecase/chat_service.h" #include "ui/localization.h" -#include "ui/menu/dashboard/dashboard_state.h" +#include "ui/menu/dashboard/dashboard_style.h" #if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12 #define lv_font_montserrat_12 lv_font_montserrat_14 @@ -19,16 +20,33 @@ namespace ui::menu::dashboard namespace { +struct RecentWidgetUi : WidgetLifetime +{ + DashboardCardChrome chrome{}; + lv_obj_t* rows[2]{}; + lv_obj_t* row_dots[2]{}; + lv_obj_t* row_names[2]{}; + lv_obj_t* row_previews[2]{}; + std::array row_name_text[2]{}; + std::array row_preview_text[2]{}; + lv_obj_t* row_badges[2]{}; + lv_obj_t* row_badge_labels[2]{}; + lv_obj_t* scan_track = nullptr; + lv_obj_t* scan_runner = nullptr; + lv_obj_t* footer_label = nullptr; +}; + constexpr size_t kVisibleRows = 2; -void set_recent_text(lv_obj_t* label, std::string& cached, const char* text) +template +void set_recent_text(lv_obj_t* label, std::array& cached, const char* text) { // DOT mode temporarily modifies LVGL's text buffer. Compare the full source // instead, otherwise an unchanged elided label is redrawn every second. - if (label != nullptr && text != nullptr && cached != text) + if (label != nullptr && text != nullptr && std::strcmp(cached.data(), text) != 0) { - cached = text; - ::ui::i18n::set_label_text_raw(label, cached.c_str()); + std::snprintf(cached.data(), cached.size(), "%s", text); + ::ui::i18n::set_label_text_raw(label, text); } } @@ -61,9 +79,13 @@ void set_label_text_if_changed(lv_obj_t* label, const char* english) } // namespace -void create_recent_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) +void refresh_recent_widget(void* context); + +WidgetHandle create_recent_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h) { - auto& recent = dashboard_state().recent; + auto* memory = create_widget_state(); + if (!memory) return {}; + auto& recent = *memory; recent.chrome = create_card_chrome(parent, ::ui::i18n::tr("Recent Activity"), card_w, card_h); const lv_coord_t body_w = lv_obj_get_content_width(recent.chrome.body); @@ -107,7 +129,7 @@ void create_recent_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h lv_obj_set_width(recent.row_previews[i], static_cast(body_w - 80)); lv_obj_align(recent.row_previews[i], LV_ALIGN_BOTTOM_LEFT, 18, -1); lv_label_set_long_mode(recent.row_previews[i], LV_LABEL_LONG_DOT); - lv_obj_set_height(recent.row_previews[i], 2 * lv_font_get_line_height(&lv_font_montserrat_12)); + lv_obj_set_height(recent.row_previews[i], (body_h < 132 ? 1 : 2) * lv_font_get_line_height(&lv_font_montserrat_12)); ::ui::i18n::set_label_text_raw(recent.row_previews[i], ""); recent.row_badges[i] = lv_obj_create(recent.rows[i]); @@ -148,11 +170,12 @@ void create_recent_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord_t card_h style_footer_label(recent.footer_label); lv_obj_align(recent.footer_label, LV_ALIGN_RIGHT_MID, 0, 0); ::ui::i18n::set_label_text(recent.footer_label, "no recent traffic"); + return own_widget(recent.chrome.card, memory, refresh_recent_widget, 2000); } -void refresh_recent_widget() +void refresh_recent_widget(void* context) { - auto& recent = dashboard_state().recent; + auto& recent = *static_cast(context); if (recent.chrome.card == nullptr) { return; diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_state.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_state.cpp index 3597ff55..87a9e9b4 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_state.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_state.cpp @@ -4,24 +4,19 @@ namespace ui::menu::dashboard { namespace { - -DashboardUi s_dashboard{}; - -} // namespace - -DashboardUi& dashboard_state() -{ - return s_dashboard; +DashboardUi* s_dashboard = nullptr; +} +DashboardUi* dashboard_state() { return s_dashboard; } +bool dashboard_create() +{ + s_dashboard = create_widget_state(); + return s_dashboard != nullptr; } - void dashboard_reset() { - if (s_dashboard.timer != nullptr) - { - lv_timer_del(s_dashboard.timer); - s_dashboard.timer = nullptr; - } - s_dashboard = DashboardUi{}; + if (!s_dashboard) return; + if (s_dashboard->timer) lv_timer_delete(s_dashboard->timer); + delete_widget_state(s_dashboard); + s_dashboard = nullptr; } - } // namespace ui::menu::dashboard diff --git a/modules/ui_shared/src/ui/menu/menu_dashboard.cpp b/modules/ui_shared/src/ui/menu/menu_dashboard.cpp index 61e1da66..f80d9f39 100644 --- a/modules/ui_shared/src/ui/menu/menu_dashboard.cpp +++ b/modules/ui_shared/src/ui/menu/menu_dashboard.cpp @@ -1,233 +1,347 @@ #include "ui/menu/menu_dashboard.h" -#if defined(TRAIL_MATE_ESP_BOARD_TAB5) || defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) - -#include -#include -#include - -#include "platform/ui/orientation_runtime.h" +#if defined(TRAIL_MATE_ESP_BOARD_TAB5) || defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) || defined(ARDUINO_T_LORA_PAGER) +#include "ui/app_runtime.h" #include "ui/localization.h" #include "ui/menu/dashboard/dashboard_state.h" #include "ui/menu/dashboard/dashboard_widgets.h" #include "ui/menu/menu_profile.h" +#include "ui/menu/menu_runtime.h" #include "ui/ui_theme.h" +#include +#include +#include +#include namespace ui::menu::dashboard { namespace { - -// Tab5's 720x1280 menu can starve the LVGL task if we keep animating the -// dashboard aggressively while the menu grid is also visible. A slower cadence -// keeps the dashboard useful without continuously forcing large redraws. constexpr uint32_t kDashboardTimerMs = 1000; -constexpr bool kEnableTab5Dashboard = false; - -bool is_tab5_profile() -{ - return std::strcmp(ui::menu_profile::current().name, "tab5") == 0; -} +constexpr lv_coord_t kPagerHeight = 24; +ui::agenda::IAgendaSource* s_agenda_source = nullptr; bool dashboard_enabled() { - return std::strcmp(ui::menu_profile::current().name, "t_display_p4") == 0 || - (kEnableTab5Dashboard && is_tab5_profile()); +#if defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) || defined(ARDUINO_T_LORA_PAGER) + return true; +#else + return std::strcmp(ui::menu_profile::current().name, "t_display_p4") == 0; +#endif } - -void panel_deleted(lv_event_t* event) +void refresh_dashboard(lv_timer_t*) { - if (lv_event_get_target(event) == dashboard_state().panel) dashboard_reset(); + auto* state = dashboard_state(); + if (!state || !state->active || !lv_obj_is_visible(state->panel)) return; + ++state->tick; + const uint32_t now = lv_tick_get(); + for (auto& widget : state->widgets) + if (widget.root && lv_obj_is_valid(widget.root) && + lv_obj_get_user_data(widget.root) == widget.state && widget.refresh) + { + if (widget.next_refresh_ms != 0 && static_cast(now - widget.next_refresh_ms) < 0) continue; + widget.next_refresh_ms = now + widget.period_ms; + widget.refresh(widget.state); + } } - -void refresh_dashboard(lv_timer_t* timer) +void clear_widgets() { - (void)timer; - auto& dashboard = dashboard_state(); - if (!dashboard_enabled() || dashboard.panel == nullptr || !lv_obj_is_visible(dashboard.panel)) + auto* state = dashboard_state(); + if (!state) return; + state->widgets = {}; + lv_obj_clean(state->grid); // Root DELETE callbacks release private PSRAM. +} +WidgetHandle create_widget(WidgetKind kind, lv_obj_t* parent, lv_coord_t width, lv_coord_t height) +{ + switch (kind) { - return; - } - - ++dashboard.tick; - - refresh_compass_widget(); - - if ((dashboard.tick % 2U) == 1U) - { - refresh_recent_widget(); - refresh_gps_widget(); - } - if ((dashboard.tick % 4U) == 1U) - { - refresh_mesh_widget(); + case WidgetKind::Mesh: + return create_mesh_widget(parent, width, height); + case WidgetKind::Gps: + return create_gps_widget(parent, width, height); + case WidgetKind::Recent: + return create_recent_widget(parent, width, height); + case WidgetKind::Compass: + return create_compass_widget(parent, width, height); + case WidgetKind::Agenda: + return create_agenda_widget(parent, width, height, s_agenda_source); + default: + return {}; } } - -} // namespace - -void init(lv_obj_t* menu_panel, lv_obj_t* grid_panel, const ui::menu_layout::InitOptions& options) +void build_page() { - (void)options; - if (!dashboard_enabled() || menu_panel == nullptr || grid_panel == nullptr) + auto* state = dashboard_state(); + if (!state) return; + clear_widgets(); + lv_obj_update_layout(state->grid); + const auto width = lv_obj_get_content_width(state->grid); + const auto height = lv_obj_get_content_height(state->grid); + if (state->dock_right && state->page == 0) { - return; - } - - auto* old_panel = dashboard_state().panel; - dashboard_reset(); - if (old_panel != nullptr && lv_obj_is_valid(old_panel)) lv_obj_delete(old_panel); - // Resolve percentage sizes before computing any child geometry. - lv_obj_update_layout(menu_panel); - auto& dashboard = dashboard_state(); - const lv_coord_t screen_w = lv_obj_get_content_width(menu_panel); - const lv_coord_t screen_h = lv_obj_get_content_height(menu_panel); - const lv_coord_t grid_w = lv_obj_get_width(grid_panel); - const lv_coord_t right_gap = static_cast(screen_w - grid_w - 16); - const auto& profile = ui::menu_profile::current(); - const lv_coord_t top = profile.grid_top_offset; - - dashboard.dock_right = right_gap >= 240; - dashboard.panel = lv_obj_create(menu_panel); - lv_obj_add_event_cb(dashboard.panel, panel_deleted, LV_EVENT_DELETE, nullptr); - lv_obj_set_style_bg_color(dashboard.panel, ui::theme::page_bg(), 0); - lv_obj_set_style_bg_opa(dashboard.panel, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(dashboard.panel, 0, 0); - lv_obj_set_style_pad_all(dashboard.panel, 0, 0); - lv_obj_set_style_pad_row(dashboard.panel, 12, 0); - lv_obj_set_style_pad_column(dashboard.panel, 12, 0); - lv_obj_set_style_shadow_width(dashboard.panel, 0, 0); - lv_obj_clear_flag(dashboard.panel, LV_OBJ_FLAG_SCROLLABLE); - - lv_coord_t panel_w = 0; - lv_coord_t panel_h = 0; - if (dashboard.dock_right) - { - panel_w = right_gap; - panel_h = std::max(screen_h - top - profile.top_bar_height - 8, 220); - lv_obj_set_size(dashboard.panel, panel_w, panel_h); - lv_obj_align(dashboard.panel, LV_ALIGN_TOP_RIGHT, -8, top); + const lv_coord_t card_w = (width - 12) / 2; + const lv_coord_t card_h = (height - 12) / 2; + for (unsigned index = 0; index < state->widgets.size(); ++index) + state->widgets[index] = create_widget(static_cast(index), state->grid, card_w, card_h); } else { - panel_w = std::max(screen_w - 16, 320); - panel_h = 240; - lv_obj_set_size(dashboard.panel, panel_w, panel_h); - lv_obj_align_to(dashboard.panel, grid_panel, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 12); - } - - dashboard.grid = lv_obj_create(dashboard.panel); - lv_obj_set_size(dashboard.grid, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_bg_opa(dashboard.grid, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(dashboard.grid, 0, 0); - lv_obj_set_style_pad_all(dashboard.grid, 0, 0); - lv_obj_set_style_pad_row(dashboard.grid, 12, 0); - lv_obj_set_style_pad_column(dashboard.grid, 12, 0); - lv_obj_set_flex_flow(dashboard.grid, LV_FLEX_FLOW_ROW_WRAP); - lv_obj_set_flex_align(dashboard.grid, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_clear_flag(dashboard.grid, LV_OBJ_FLAG_SCROLLABLE); - - const lv_coord_t card_w = (panel_w - 12) / 2; - const lv_coord_t card_h = dashboard.dock_right ? std::max((panel_h - 12) / 2, 124) : 132; - - create_mesh_widget(dashboard.grid, card_w, card_h); - create_gps_widget(dashboard.grid, card_w, card_h); - create_recent_widget(dashboard.grid, card_w, card_h); - create_compass_widget(dashboard.grid, card_w, card_h); - - dashboard.timer = lv_timer_create(refresh_dashboard, kDashboardTimerMs, nullptr); - if (dashboard.timer != nullptr) - { - lv_timer_set_repeat_count(dashboard.timer, -1); + const auto kind = static_cast(state->dock_right ? state->page + 3 : state->page); + state->widgets[0] = create_widget(kind, state->grid, width, height); } + char page[24]; + std::snprintf(page, sizeof(page), "%u / %u", state->page + 1U, + state->dock_right ? static_cast(WidgetKind::Count) - 3U : static_cast(WidgetKind::Count)); + lv_label_set_text(state->pager, page); refresh_dashboard(nullptr); } - -void bringToFront() +bool change_page(int delta) { - auto& dashboard = dashboard_state(); - if (dashboard.panel != nullptr) - { - lv_obj_move_foreground(dashboard.panel); - } + auto* state = dashboard_state(); + if (!state) return false; + const int last = static_cast(WidgetKind::Count) - (state->dock_right ? 4 : 1); + const int next = std::clamp(static_cast(state->page) + delta, 0, last); + if (next == state->page) return false; + state->page = static_cast(next); + build_page(); + return true; } - -void refresh_localized_text() +void set_feed_open(bool open) { - auto& dashboard = dashboard_state(); - if (dashboard.panel == nullptr) + auto* state = dashboard_state(); + if (!state || state->dock_right || state->feed_open == open) return; + state->feed_open = open; + if (open) { - return; - } - - if (dashboard.mesh.chrome.title != nullptr) - { - ::ui::i18n::set_label_text(dashboard.mesh.chrome.title, "Mesh Status"); - } - if (dashboard.mesh.stat_captions[0] != nullptr) - { - ::ui::i18n::set_label_text(dashboard.mesh.stat_captions[0], "Team"); - } - if (dashboard.mesh.stat_captions[1] != nullptr) - { - ::ui::i18n::set_label_text(dashboard.mesh.stat_captions[1], "Unread"); - } - if (dashboard.mesh.stat_captions[2] != nullptr) - { - ::ui::i18n::set_label_text(dashboard.mesh.stat_captions[2], "Power"); - } - - if (dashboard.gps.chrome.title != nullptr) - { - ::ui::i18n::set_label_text(dashboard.gps.chrome.title, "GPS Status"); - } - if (dashboard.gps.stat_captions[0] != nullptr) - { - ::ui::i18n::set_label_text(dashboard.gps.stat_captions[0], "Fix"); - } - if (dashboard.gps.stat_captions[1] != nullptr) - { - ::ui::i18n::set_label_text(dashboard.gps.stat_captions[1], "Used"); - } - if (dashboard.gps.stat_captions[2] != nullptr) - { - ::ui::i18n::set_label_text(dashboard.gps.stat_captions[2], "HDOP"); - } - - if (dashboard.recent.chrome.title != nullptr) - { - ::ui::i18n::set_label_text(dashboard.recent.chrome.title, "Recent Activity"); - } - - if (dashboard.compass.chrome.title != nullptr) - { - ::ui::i18n::set_label_text(dashboard.compass.chrome.title, "Mini Compass"); - } - - refresh_compass_widget(); - refresh_recent_widget(); - refresh_gps_widget(); - refresh_mesh_widget(); -} - -void setActive(bool active) -{ - auto& dashboard = dashboard_state(); - if (dashboard.timer == nullptr) - { - return; - } - - if (active) - { - lv_timer_resume(dashboard.timer); + lv_obj_remove_flag(state->panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(state->panel); + build_page(); + if (state->timer) lv_timer_resume(state->timer); } else { - lv_timer_pause(dashboard.timer); + clear_widgets(); + lv_obj_add_flag(state->panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(state->menu_grid, LV_OBJ_FLAG_HIDDEN); + if (state->timer) lv_timer_pause(state->timer); } } +void pager_button(lv_event_t* event) +{ + const auto delta = reinterpret_cast(lv_event_get_user_data(event)); + if (delta == 0) set_feed_open(false); + else change_page(static_cast(delta)); +} +void add_pager_button(lv_obj_t* parent, const char* text, lv_align_t align, lv_coord_t x, intptr_t delta) +{ + auto* button = lv_button_create(parent); + lv_group_remove_obj(button); // Encoder focus on the menu belongs to app icons. + lv_obj_set_size(button, delta == 0 ? 68 : 38, kPagerHeight - 2); + lv_obj_align(button, align, x, 0); + lv_obj_set_style_bg_color(button, ui::theme::surface_alt(), 0); + lv_obj_set_style_shadow_width(button, 0, 0); + lv_obj_set_style_pad_all(button, 0, 0); + auto* label = lv_label_create(button); + ui::i18n::set_label_text(label, text); + lv_obj_set_style_text_color(label, ui::theme::text(), 0); + lv_obj_center(label); + lv_obj_add_event_cb(button, pager_button, LV_EVENT_CLICKED, reinterpret_cast(delta)); +} +bool belongs_to(lv_obj_t* object, lv_obj_t* parent) +{ + for (auto* current = object; current; current = lv_obj_get_parent(current)) + if (current == parent) return true; + return false; +} +void menu_pointer(lv_event_t* event) +{ + auto* state = dashboard_state(); + auto* input = static_cast(lv_event_get_current_target(event)); + if (!state || !state->active || !input || lv_indev_get_type(input) != LV_INDEV_TYPE_POINTER) return; + const auto code = lv_event_get_code(event); + if (code != LV_EVENT_PRESSED && code != LV_EVENT_PRESSING && code != LV_EVENT_RELEASED) return; + if (ui::menu_runtime::currentScene() != ui::menu_runtime::Scene::Menu || + ui_is_overlay_active() || !lv_obj_is_visible(ui::menu_layout::menuPanel()) || + (lv_group_get_default() && lv_group_get_default() != menu_g)) + { + state->pressed = false; + return; + } + lv_point_t point; + lv_indev_get_point(input, &point); + if (code == LV_EVENT_PRESSED) + { + state->pressed = false; + lv_area_t area; + lv_obj_get_coords(ui::menu_layout::menuPanel(), &area); + if (point.x < area.x1 || point.x > area.x2 || point.y < area.y1 || point.y > area.y2) return; + lv_obj_get_coords(state->panel, &area); + state->inside_dashboard = point.x >= area.x1 && point.x <= area.x2 && point.y >= area.y1 && point.y <= area.y2; + state->pointer = input; + state->start = point; + state->pressed = true; + return; + } + if (!state->pressed || state->pointer != input) return; + auto* target = lv_indev_get_active_obj(); + if (target && !belongs_to(target, ui::menu_layout::menuPanel())) + { + state->pressed = false; // A modal or another screen owns this press. + return; + } + const int dx = point.x - state->start.x; + const int dy = point.y - state->start.y; + const int threshold = std::clamp(lv_obj_get_width(state->panel) / 7, 36, 96); + const bool horizontal = std::abs(dx) >= threshold && std::abs(dx) > std::abs(dy) + 16; + const bool vertical = std::abs(dy) >= 36 && std::abs(dy) > std::abs(dx) + 16; + int action = 0; + if (state->dock_right && horizontal) action = state->inside_dashboard ? (dx < 0 ? 2 : -2) : 3; + else if (!state->dock_right && horizontal && dx > 0 && !state->feed_open) action = 1; + else if (!state->dock_right && horizontal && dx < 0 && state->feed_open) action = -1; + else if (!state->dock_right && state->feed_open && vertical) action = dy < 0 ? 2 : -2; + if (action) + { + state->pressed = false; + // Cancel the original object's click before hiding/deleting its tree. + // A single physical press may perform exactly one navigation action. + lv_indev_reset(input, nullptr); + lv_indev_wait_release(input); + lv_indev_stop_processing(input); + if (std::abs(action) == 1) set_feed_open(action > 0); + else if (std::abs(action) == 2) change_page(action > 0 ? 1 : -1); + return; + } + if (code == LV_EVENT_RELEASED) state->pressed = false; +} +void panel_deleted(lv_event_t* event) +{ + auto* state = dashboard_state(); + if (!state || lv_event_get_target(event) != state->panel) return; + if (state->feed_open && state->menu_grid && lv_obj_is_valid(state->menu_grid)) + lv_obj_remove_flag(state->menu_grid, LV_OBJ_FLAG_HIDDEN); + dashboard_reset(); +} +} // namespace +void bindAgendaSource(ui::agenda::IAgendaSource* source) { s_agenda_source = source; } + +bool isFeedOpen() +{ + auto* state = dashboard_state(); + return state && state->active && state->feed_open; +} +bool handleEncoder(int delta) +{ + if (!isFeedOpen() || delta == 0 || (lv_group_get_default() && lv_group_get_default() != menu_g)) return false; + change_page(delta > 0 ? 1 : -1); + return true; +} +bool handleKey(uint32_t key, bool at_left_edge) +{ + auto* state = dashboard_state(); + if (!state || !state->active || state->dock_right || + ui::menu_runtime::currentScene() != ui::menu_runtime::Scene::Menu || ui_is_overlay_active() || + (lv_group_get_default() && lv_group_get_default() != menu_g)) return false; + if (state->feed_open) + { + if (key == 'w' || key == 'W' || key == LV_KEY_UP || key == LV_KEY_PREV) change_page(-1); + else if (key == 's' || key == 'S' || key == LV_KEY_DOWN || key == LV_KEY_NEXT) change_page(1); + else if (key == 'd' || key == 'D' || key == LV_KEY_RIGHT || key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) set_feed_open(false); + else if (key != LV_KEY_ENTER && key != 'a' && key != 'A' && key != LV_KEY_LEFT) return false; + return true; + } + if (at_left_edge && (key == LV_KEY_LEFT || key == 'a' || key == 'A')) + { + set_feed_open(true); + return true; + } + return false; +} + +void init(lv_obj_t* menu_panel, lv_obj_t* grid_panel, const ui::menu_layout::InitOptions&) +{ + if (!dashboard_enabled() || !menu_panel || !grid_panel) return; + if (auto* previous = dashboard_state()) + { + auto* panel = previous->panel; + if (previous->feed_open && lv_obj_is_valid(previous->menu_grid)) + lv_obj_remove_flag(previous->menu_grid, LV_OBJ_FLAG_HIDDEN); + dashboard_reset(); + if (panel && lv_obj_is_valid(panel)) lv_obj_delete(panel); + } + if (!dashboard_create()) return; // PSRAM exhaustion never falls back to RAM. + platform::ui::PsramUiLease allocation_scope; + auto& state = *dashboard_state(); + state.menu_grid = grid_panel; + lv_obj_update_layout(menu_panel); + const auto& profile = ui::menu_profile::current(); + const auto screen_w = lv_obj_get_content_width(menu_panel); + const auto screen_h = lv_obj_get_content_height(menu_panel); + state.dock_right = std::strcmp(profile.name, "t_display_p4") == 0; + const lv_coord_t width = state.dock_right ? screen_w - lv_obj_get_width(grid_panel) - 16 : screen_w; + const lv_coord_t height = state.dock_right ? screen_h - profile.grid_top_offset - profile.top_bar_height - 8 : screen_h - profile.top_bar_height; + state.panel = lv_obj_create(menu_panel); + lv_obj_set_size(state.panel, width, height); + lv_obj_align(state.panel, state.dock_right ? LV_ALIGN_TOP_RIGHT : LV_ALIGN_TOP_LEFT, state.dock_right ? -8 : 0, state.dock_right ? profile.grid_top_offset : 0); + lv_obj_set_style_bg_color(state.panel, ui::theme::page_bg(), 0); + lv_obj_set_style_bg_opa(state.panel, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(state.panel, 0, 0); + lv_obj_set_style_pad_all(state.panel, 0, 0); + lv_obj_clear_flag(state.panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(state.panel, panel_deleted, LV_EVENT_DELETE, nullptr); + state.grid = lv_obj_create(state.panel); + lv_obj_set_size(state.grid, width, height - kPagerHeight); + lv_obj_set_style_bg_opa(state.grid, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(state.grid, 0, 0); + lv_obj_set_style_pad_all(state.grid, 0, 0); + lv_obj_set_style_pad_row(state.grid, 12, 0); + lv_obj_set_style_pad_column(state.grid, 12, 0); + lv_obj_set_flex_flow(state.grid, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_clear_flag(state.grid, LV_OBJ_FLAG_SCROLLABLE); + state.pager = lv_label_create(state.panel); + lv_obj_align(state.pager, LV_ALIGN_BOTTOM_MID, 0, -3); + if (!state.dock_right) add_pager_button(state.panel, "Menu", LV_ALIGN_BOTTOM_LEFT, 0, 0); + add_pager_button(state.panel, state.dock_right ? LV_SYMBOL_LEFT : LV_SYMBOL_UP, LV_ALIGN_BOTTOM_RIGHT, -42, -1); + add_pager_button(state.panel, state.dock_right ? LV_SYMBOL_RIGHT : LV_SYMBOL_DOWN, LV_ALIGN_BOTTOM_RIGHT, 0, 1); + state.timer = lv_timer_create(refresh_dashboard, kDashboardTimerMs, nullptr); + if (state.dock_right) build_page(); + else + { + lv_obj_add_flag(state.panel, LV_OBJ_FLAG_HIDDEN); + if (state.timer) lv_timer_pause(state.timer); + } + for (auto* input = lv_indev_get_next(nullptr); input; input = lv_indev_get_next(input)) + { + if (lv_indev_get_type(input) != LV_INDEV_TYPE_POINTER) continue; + lv_indev_remove_event_cb_with_user_data(input, menu_pointer, nullptr); + lv_indev_add_event_cb(input, menu_pointer, LV_EVENT_ALL, nullptr); + } +} +void bringToFront() +{ + if (auto* state = dashboard_state()) lv_obj_move_foreground(state->panel); +} +void refresh_localized_text() +{ + if (auto* state = dashboard_state(); state && state->active && lv_obj_is_visible(state->panel)) build_page(); +} +void setActive(bool active) +{ + auto* state = dashboard_state(); + if (!state || state->active == active) return; + state->active = active; + state->pressed = false; + if (!active) + { + if (state->feed_open) set_feed_open(false); + else clear_widgets(); + if (state->timer) lv_timer_pause(state->timer); + } + else if (state->dock_right) + { + build_page(); + if (state->timer) lv_timer_resume(state->timer); + } +} } // namespace ui::menu::dashboard - #endif diff --git a/modules/ui_shared/src/ui/menu/menu_layout.cpp b/modules/ui_shared/src/ui/menu/menu_layout.cpp index 516e0041..9c957f38 100644 --- a/modules/ui_shared/src/ui/menu/menu_layout.cpp +++ b/modules/ui_shared/src/ui/menu/menu_layout.cpp @@ -1,5 +1,6 @@ #include "ui/menu/menu_layout.h" +#include #include #include #include @@ -10,6 +11,7 @@ #include "esp_log.h" #endif +#include "platform/ui/psram_ui_lease.h" #include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" #include "ui/localization.h" @@ -455,7 +457,29 @@ void menuButtonKeyCallback(lv_event_t* e) return; } - const uint32_t key = lv_event_get_key(e); + uint32_t key = lv_event_get_key(e); + if (ui::menu::dashboard::handleKey(key)) return; + switch (key) + { + case 'w': + case 'W': + key = LV_KEY_UP; + break; + case 'a': + case 'A': + key = LV_KEY_LEFT; + break; + case 's': + case 'S': + key = LV_KEY_DOWN; + break; + case 'd': + case 'D': + key = LV_KEY_RIGHT; + break; + default: + break; + } if (key != LV_KEY_LEFT && key != LV_KEY_RIGHT && key != LV_KEY_UP && key != LV_KEY_DOWN) { return; @@ -489,6 +513,7 @@ void menuButtonKeyCallback(lv_event_t* e) if (next == nullptr) { + if (nav_key == LV_KEY_LEFT && ui::menu::dashboard::handleKey(nav_key, true)) return; if (nav_key == LV_KEY_LEFT || nav_key == LV_KEY_UP) { next = lastMenuButton(); @@ -507,6 +532,7 @@ void menuButtonKeyCallback(lv_event_t* e) void menuButtonClickCallback(lv_event_t* e) { + if (ui::menu::dashboard::isFeedOpen()) return; if (lv_event_get_code(e) != LV_EVENT_CLICKED || s_app_panel == nullptr) { return; @@ -622,6 +648,29 @@ void chatBadgeSizeChangedCallback(lv_event_t* event) static_cast(lv_event_get_user_data(event))); } +struct GridTracks +{ + lv_coord_t card_width; + lv_coord_t pad_left; + lv_coord_t pad_right; +}; + +GridTracks gridTracks(const ui::menu_profile::MenuLayoutProfile& profile, lv_coord_t width) +{ + if (!profile.wrap_grid) return {profile.card_width, profile.grid_pad_left, profile.grid_pad_right}; + const lv_coord_t available = std::max(profile.card_width, + width - profile.grid_pad_left - profile.grid_pad_right); + int columns = std::max(1, static_cast((available + profile.grid_pad_column) / + (profile.card_width + profile.grid_pad_column))); + if (profile.max_columns > 0) columns = std::min(columns, static_cast(profile.max_columns)); + const lv_coord_t usable = available - profile.grid_pad_column * (columns - 1); + const lv_coord_t remainder = usable % columns; + // Every row shares these tracks, including an incomplete last row. Split + // indivisible pixels between the outer edges instead of leaving a right gutter. + return {usable / columns, profile.grid_pad_left + remainder / 2, + profile.grid_pad_right + remainder - remainder / 2}; +} + void createAppButton(lv_obj_t* parent, AppScreen* app, size_t idx) { const auto& profile = ui::menu_profile::current(); @@ -629,7 +678,7 @@ void createAppButton(lv_obj_t* parent, AppScreen* app, size_t idx) const lv_image_dsc_t* image = app ? app->icon() : nullptr; lv_obj_t* btn = lv_btn_create(parent); - const lv_coord_t width = profile.card_width; + const lv_coord_t width = gridTracks(profile, lv_obj_get_width(parent)).card_width; const lv_coord_t height = profile.card_height; lv_obj_set_size(btn, width, height); @@ -667,6 +716,14 @@ void createAppButton(lv_obj_t* parent, AppScreen* app, size_t idx) { lv_obj_set_style_bg_opa(btn, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(btn, 0, 0); + for (const auto state : {LV_STATE_FOCUSED, LV_STATE_FOCUS_KEY}) + { + lv_obj_set_style_bg_color(btn, lv_color_hex(0xF6E7C5), state); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, state); + lv_obj_set_style_radius(btn, 9, state); + lv_obj_set_style_border_width(btn, 1, state); + lv_obj_set_style_border_color(btn, lv_color_hex(0xE7C98F), state); + } } lv_obj_t* icon = nullptr; @@ -1117,16 +1174,24 @@ void createAppGrid() (profile.card_width * profile.max_columns) + (profile.grid_pad_column * (profile.max_columns - 1))); } + lv_coord_t grid_top = profile.grid_top_offset; lv_obj_set_size(panel, panel_width, LV_PCT(profile.grid_height_pct)); + if (profile.variant == ui::menu_profile::LayoutVariant::CompactGrid) + { + const lv_coord_t screen_height = lv_display_get_vertical_resolution(nullptr); + const lv_coord_t available = screen_height - 2 * profile.grid_top_offset; + const lv_coord_t rows = std::max(1, (available + profile.grid_pad_row) / + (profile.card_height + profile.grid_pad_row)); + const lv_coord_t height = rows * profile.card_height + (rows - 1) * profile.grid_pad_row; + lv_obj_set_height(panel, height); + grid_top = (screen_height - height) / 2; + } lv_obj_set_style_pad_row(panel, profile.grid_pad_row, 0); lv_obj_set_style_pad_column(panel, profile.grid_pad_column, 0); - if (profile.variant != ui::menu_profile::LayoutVariant::CompactGrid) - { - lv_obj_set_style_pad_top(panel, 0, 0); - lv_obj_set_style_pad_bottom(panel, 0, 0); - lv_obj_set_style_pad_left(panel, profile.grid_pad_left, 0); - lv_obj_set_style_pad_right(panel, profile.grid_pad_right, 0); - } + lv_obj_set_style_pad_top(panel, 0, 0); + lv_obj_set_style_pad_bottom(panel, 0, 0); + lv_obj_set_style_pad_left(panel, profile.grid_pad_left, 0); + lv_obj_set_style_pad_right(panel, profile.grid_pad_right, 0); lv_obj_set_scroll_dir(panel, profile.vertical_scroll ? LV_DIR_VER : LV_DIR_HOR); if (profile.wrap_grid) { @@ -1150,14 +1215,19 @@ void createAppGrid() lv_obj_set_flex_flow(panel, profile.wrap_grid ? LV_FLEX_FLOW_ROW_WRAP : LV_FLEX_FLOW_ROW); if (profile.grid_anchor_top_left) { - lv_obj_align(panel, LV_ALIGN_TOP_LEFT, 0, profile.grid_top_offset); + lv_obj_align(panel, LV_ALIGN_TOP_LEFT, 0, grid_top); } else { - lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, profile.grid_top_offset); + lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, grid_top); } lv_obj_add_style(panel, &frameless_style, 0); + lv_obj_update_layout(panel); + const auto tracks = gridTracks(profile, lv_obj_get_width(panel)); + lv_obj_set_style_pad_left(panel, tracks.pad_left, 0); + lv_obj_set_style_pad_right(panel, tracks.pad_right, 0); + if (!createAppButtonsFromUxMenu(panel)) { createAppButtonsFromCatalog(panel); @@ -1257,6 +1327,11 @@ void init(const InitOptions& options) s_init_options = options; +#if defined(ARDUINO_T_DECK) || defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_WIO_TRACKER_L2) || defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + // The unified grid's additional labels belong to external memory too. + platform::ui::PsramUiLease menu_memory; +#endif + menu_g = lv_group_create(); app_g = lv_group_create(); set_default_group(menu_g); diff --git a/modules/ui_shared/src/ui/menu/menu_profile.cpp b/modules/ui_shared/src/ui/menu/menu_profile.cpp index b8fd3878..e6451cf0 100644 --- a/modules/ui_shared/src/ui/menu/menu_profile.cpp +++ b/modules/ui_shared/src/ui/menu/menu_profile.cpp @@ -41,7 +41,7 @@ MenuLayoutProfile make_tdeck_profile() profile.input_mode = InputMode::Hybrid; profile.badge_anchor_mode = BadgeAnchorMode::IconTopRight; profile.card_width = 72; - profile.card_height = 83; + profile.card_height = 76; profile.icon_scale = 176; profile.card_radius = 0; profile.card_border_width = 0; @@ -49,9 +49,11 @@ MenuLayoutProfile make_tdeck_profile() profile.card_pad_bottom = 4; profile.card_pad_row = 2; profile.grid_height_pct = 80; - profile.grid_top_offset = 24; + profile.grid_top_offset = 32; profile.grid_pad_row = 6; - profile.grid_pad_column = 0; + profile.grid_pad_column = 6; + profile.grid_pad_left = 6; + profile.grid_pad_right = 6; profile.top_bar_height = ::ui::kUnifiedTopBarHeight; profile.top_bar_side_inset = 5; profile.top_bar_text_pad = 3; @@ -88,53 +90,10 @@ MenuLayoutProfile make_tdeck_profile() MenuLayoutProfile make_pager_profile() { - MenuLayoutProfile profile{}; + // The same adaptive grid as T-Deck/L2; only input hardware differs. + auto profile = make_tdeck_profile(); profile.name = "pager"; - profile.variant = LayoutVariant::PagerFocus; profile.input_mode = InputMode::EncoderPrimary; - profile.badge_anchor_mode = BadgeAnchorMode::IconTopLeft; - profile.card_width = 150; - profile.card_height = 122; - profile.icon_scale = 256; - profile.card_radius = 10; - profile.card_border_width = 2; - profile.grid_height_pct = 55; - profile.grid_top_offset = 40; - profile.grid_pad_row = 0; - profile.grid_pad_column = 10; - profile.grid_pad_left = 5; - profile.grid_pad_right = 5; - profile.top_bar_height = ::ui::kUnifiedTopBarHeight; - profile.top_bar_side_inset = 5; - profile.top_bar_text_pad = 3; - profile.status_row_gap = 2; - profile.status_row_offset_y = 2; - profile.desc_offset = -10; - profile.node_id_offset_x = 5; - profile.node_id_offset_y = -10; - profile.badge_offset_x = -4; - profile.badge_offset_y = -4; - profile.badge_min_size = 20; - profile.badge_pad_h = 6; - profile.badge_pad_v = 2; - profile.title_offset_x = 0; - profile.title_offset_y = 0; - profile.wrap_grid = false; - profile.vertical_scroll = false; - profile.snap_center = true; - profile.directional_key_nav = false; - profile.transparent_cards = false; - profile.show_top_bar = false; - profile.show_card_label = false; - profile.show_desc_label = true; - profile.show_node_id = true; - profile.large_touch_hitbox = false; - profile.transition_animation = false; - profile.card_label_font = &lv_font_montserrat_14; - profile.desc_font = &lv_font_montserrat_14; - profile.node_id_font = &lv_font_montserrat_14; - profile.top_bar_font = &lv_font_montserrat_14; - profile.badge_font = &lv_font_montserrat_14; return profile; } @@ -214,8 +173,19 @@ MenuLayoutProfile make_t_display_p4_profile() MenuLayoutProfile make_cardputer_zero_profile() { - MenuLayoutProfile profile = make_pager_profile(); + // Cardputer keeps its independent carousel after Pager joins the grid. + MenuLayoutProfile profile{}; profile.name = "cardputer_zero"; + profile.transition_animation = false; + profile.variant = LayoutVariant::PagerFocus; + profile.badge_anchor_mode = BadgeAnchorMode::IconTopLeft; + profile.grid_pad_row = 0; + profile.badge_offset_x = -4; + profile.wrap_grid = false; + profile.vertical_scroll = false; + profile.snap_center = true; + profile.show_card_label = false; + profile.show_desc_label = true; profile.input_mode = InputMode::Hybrid; profile.directional_key_nav = true; profile.show_top_bar = true; diff --git a/modules/ui_shared/src/ui/menu/menu_runtime.cpp b/modules/ui_shared/src/ui/menu/menu_runtime.cpp index 37aaecf7..0f74b51f 100644 --- a/modules/ui_shared/src/ui/menu/menu_runtime.cpp +++ b/modules/ui_shared/src/ui/menu/menu_runtime.cpp @@ -17,6 +17,7 @@ #include "ui/components/shortcut_help_modal.h" #endif #include "ui/formatters.h" +#include "ui/menu/menu_dashboard.h" #include "ui/menu/menu_layout.h" #include "ui/menu/menu_profile.h" #include "ui/ui_common.h" @@ -250,7 +251,7 @@ void openMenuHelpModal() return; } - ::ui::components::shortcut_help_modal::Row rows[7] = { + ::ui::components::shortcut_help_modal::Row rows[8] = { {"WASD", nullptr, "Select app"}, {"Enter", nullptr, "Open app"}, {"B", nullptr, "Cycle screen brightness"}, @@ -260,6 +261,19 @@ void openMenuHelpModal() {"H", "Back", "Close help"}, }; std::size_t row_count = 2; + if (ui::menu::dashboard::isFeedOpen()) + { + rows[0] = {"W/S", "Wheel", "Switch widget"}; + rows[1] = {"D", "Back", "Return to menu"}; + } + else + { +#if defined(ARDUINO_T_DECK) || defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_WIO_TRACKER_L2) + rows[row_count++] = {"A", nullptr, "Widgets at left edge"}; +#elif defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + rows[row_count++] = {"Swipe", nullptr, "More dashboard widgets"}; +#endif + } if (screenBrightnessShortcutEnabled()) { rows[row_count++] = {"B", nullptr, "Cycle screen brightness"}; @@ -925,6 +939,8 @@ bool handleShortcutKey(char key, int state) return false; } + if (ui::menu::dashboard::handleKey(static_cast(key))) return true; + if (isMenuHelpKey(key)) { #if defined(ARDUINO_T_DECK_PRO) diff --git a/modules/ui_shared/tests/agenda/CMakeLists.txt b/modules/ui_shared/tests/agenda/CMakeLists.txt index 44dd3039..4886bcb8 100644 --- a/modules/ui_shared/tests/agenda/CMakeLists.txt +++ b/modules/ui_shared/tests/agenda/CMakeLists.txt @@ -51,6 +51,7 @@ if(AGENDA_FONT_BINARY AND AGENDA_FONT_CHARSET) add_test(NAME agenda_font_binary COMMAND agenda_font_binary_test "${AGENDA_FONT_BINARY}" "${AGENDA_FONT_CHARSET}") endif() add_executable(agenda_ui_test + menu_widget_stub.cpp map_preview_stub.cpp test_agenda_ui.cpp test_support.cpp popup_test_support.cpp test_map_flow.cpp test_font_scope.cpp test_interruption_flow.cpp test_touch_agenda.cpp "${REPO_ROOT}/apps/esp32_lvgl/tests/agenda/test_target_root.cpp" diff --git a/modules/ui_shared/tests/agenda/menu_widget_stub.cpp b/modules/ui_shared/tests/agenda/menu_widget_stub.cpp new file mode 100644 index 00000000..e35c3dd4 --- /dev/null +++ b/modules/ui_shared/tests/agenda/menu_widget_stub.cpp @@ -0,0 +1,9 @@ +// The Agenda composition is tested independently from menu rendering. +namespace ui::agenda +{ +class IAgendaSource; +} +namespace ui::menu::dashboard +{ +void bindAgendaSource(ui::agenda::IAgendaSource*) {} +} // namespace ui::menu::dashboard diff --git a/modules/ui_shared/tests/test_cardputer_zero_menu_profile.cpp b/modules/ui_shared/tests/test_cardputer_zero_menu_profile.cpp index f78d078a..f0939f8f 100644 --- a/modules/ui_shared/tests/test_cardputer_zero_menu_profile.cpp +++ b/modules/ui_shared/tests/test_cardputer_zero_menu_profile.cpp @@ -20,13 +20,12 @@ lv_coord_t lv_display_get_physical_vertical_resolution(lv_display_t*) int main() { - const auto pager = ui::menu_profile::make_pager_profile(); const auto& profile = ui::menu_profile::current(); assert(std::strcmp(profile.name, "cardputer_zero") == 0); assert(profile.variant == ui::menu_profile::LayoutVariant::PagerFocus); - assert(profile.badge_anchor_mode == pager.badge_anchor_mode); - assert(profile.snap_center == pager.snap_center); + assert(profile.badge_anchor_mode == ui::menu_profile::BadgeAnchorMode::IconTopLeft); + assert(profile.snap_center); assert(!profile.vertical_scroll); assert(!profile.wrap_grid); diff --git a/platform/esp/arduino_common/src/LV_Helper_v9.cpp b/platform/esp/arduino_common/src/LV_Helper_v9.cpp index 14f7faf6..f2c2c2b6 100644 --- a/platform/esp/arduino_common/src/LV_Helper_v9.cpp +++ b/platform/esp/arduino_common/src/LV_Helper_v9.cpp @@ -17,6 +17,7 @@ #include "sys/clock.h" #include "ui/LV_Helper.h" #include "ui/app_runtime.h" +#include "ui/menu/menu_dashboard.h" #include "ui/menu/menu_runtime.h" #include "ui/support/lvgl_fs_utils.h" #include "walkie/walkie_service.h" @@ -1175,6 +1176,7 @@ static void lv_encoder_read(lv_indev_t* drv, lv_indev_data_t* data) #endif break; } + if (::ui::menu::dashboard::handleEncoder(data->enc_diff)) data->enc_diff = 0; if (msg.centerBtnPressed) { data->state = LV_INDEV_STATE_PRESSED; diff --git a/platform/esp/idf_common/src/lvgl_memory.cpp b/platform/esp/idf_common/src/lvgl_memory.cpp new file mode 100644 index 00000000..af76d94f --- /dev/null +++ b/platform/esp/idf_common/src/lvgl_memory.cpp @@ -0,0 +1,33 @@ +#include "lvgl.h" +#if LV_USE_STDLIB_MALLOC == LV_STDLIB_CUSTOM +#include "esp_heap_caps.h" +#include +#include + +// P4's UI uses external RAM, including small label and style allocations. +// DMA and peripheral allocations retain their SDK-owned memory policies. +extern "C" void lv_mem_init(void) {} +extern "C" void lv_mem_deinit(void) {} +extern "C" lv_mem_pool_t lv_mem_add_pool(void*, size_t) { return nullptr; } +extern "C" void lv_mem_remove_pool(lv_mem_pool_t) {} +extern "C" void* lv_malloc_core(size_t bytes) +{ + if (bytes == 0) return nullptr; + return heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +} +extern "C" void* lv_realloc_core(void* memory, size_t bytes) +{ + if (bytes == 0) + { + heap_caps_free(memory); + return nullptr; + } + return heap_caps_realloc(memory, bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +} +extern "C" void lv_free_core(void* memory) { heap_caps_free(memory); } +extern "C" void lv_mem_monitor_core(lv_mem_monitor_t* monitor) +{ + if (monitor) std::memset(monitor, 0, sizeof(*monitor)); +} +extern "C" lv_result_t lv_mem_test_core(void) { return LV_RESULT_OK; } +#endif diff --git a/tests/menu_widgets/CMakeLists.txt b/tests/menu_widgets/CMakeLists.txt new file mode 100644 index 00000000..dfb8403a --- /dev/null +++ b/tests/menu_widgets/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.20) +project(menu_widget_memory LANGUAGES C CXX) +set(CMAKE_CXX_STANDARD 17) +get_filename_component(REPO "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +set(LVGL_DIR "" CACHE PATH "Firmware LVGL source") +set(LVGL_LIBRARY "" CACHE FILEPATH "Compatible prebuilt LVGL library") +if(LVGL_LIBRARY) + add_library(lvgl STATIC IMPORTED) + set_target_properties(lvgl PROPERTIES IMPORTED_LOCATION "${LVGL_LIBRARY}") + target_include_directories(lvgl INTERFACE "${LVGL_DIR}" "${REPO}/tests/contacts_card_view") + target_compile_definitions(lvgl INTERFACE LV_CONF_INCLUDE_SIMPLE) +else() + set(LV_BUILD_CONF_PATH "${REPO}/tests/contacts_card_view/lv_conf.h" CACHE PATH "" FORCE) + set(CONFIG_LV_BUILD_DEMOS OFF CACHE BOOL "" FORCE) + set(CONFIG_LV_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + add_subdirectory("${LVGL_DIR}" lvgl) +endif() +enable_testing() +add_executable(widget_ownership test_ownership.cpp) +target_include_directories(widget_ownership PRIVATE fakes "${REPO}/modules/ui_shared/include" "${REPO}/modules/core_sys/include") +target_compile_definitions(widget_ownership PRIVATE ESP_PLATFORM=1) +target_link_libraries(widget_ownership PRIVATE lvgl) +add_executable(widget_idf_allocator test_idf_allocator.cpp) +target_include_directories(widget_idf_allocator PRIVATE idf_fakes fakes) +foreach(TARGET widget_ownership widget_idf_allocator) + if(MSVC) + target_compile_options(${TARGET} PRIVATE /UNDEBUG) + else() + target_compile_options(${TARGET} PRIVATE -UNDEBUG) + endif() + add_test(NAME ${TARGET} COMMAND ${TARGET}) +endforeach() diff --git a/tests/menu_widgets/fakes/esp_attr.h b/tests/menu_widgets/fakes/esp_attr.h new file mode 100644 index 00000000..1baae9f6 --- /dev/null +++ b/tests/menu_widgets/fakes/esp_attr.h @@ -0,0 +1,3 @@ +#pragma once +#define IRAM_ATTR +#define DRAM_ATTR diff --git a/tests/menu_widgets/fakes/esp_heap_caps.h b/tests/menu_widgets/fakes/esp_heap_caps.h new file mode 100644 index 00000000..290cb962 --- /dev/null +++ b/tests/menu_widgets/fakes/esp_heap_caps.h @@ -0,0 +1,7 @@ +#pragma once +#include +#include +constexpr uint32_t MALLOC_CAP_SPIRAM = 1, MALLOC_CAP_8BIT = 4; +void* heap_caps_malloc(std::size_t bytes, uint32_t caps); +void* heap_caps_realloc(void* memory, std::size_t bytes, uint32_t caps); +void heap_caps_free(void* memory); diff --git a/tests/menu_widgets/fakes/sdkconfig.h b/tests/menu_widgets/fakes/sdkconfig.h new file mode 100644 index 00000000..09a205b1 --- /dev/null +++ b/tests/menu_widgets/fakes/sdkconfig.h @@ -0,0 +1,2 @@ +#pragma once +// Native ownership test uses tests/contacts_card_view/lv_conf.h. diff --git a/tests/menu_widgets/idf_fakes/lvgl.h b/tests/menu_widgets/idf_fakes/lvgl.h new file mode 100644 index 00000000..a3c5b7e8 --- /dev/null +++ b/tests/menu_widgets/idf_fakes/lvgl.h @@ -0,0 +1,13 @@ +#pragma once +#include +#define LV_STDLIB_CUSTOM 99 +#define LV_USE_STDLIB_MALLOC LV_STDLIB_CUSTOM +using lv_mem_pool_t = void*; +struct lv_mem_monitor_t +{ + std::size_t total_size, free_size; +}; +enum lv_result_t +{ + LV_RESULT_OK +}; diff --git a/tests/menu_widgets/test_idf_allocator.cpp b/tests/menu_widgets/test_idf_allocator.cpp new file mode 100644 index 00000000..aa794bc9 --- /dev/null +++ b/tests/menu_widgets/test_idf_allocator.cpp @@ -0,0 +1,39 @@ +#include "esp_heap_caps.h" +#include +#include +#include +bool fail_external = false; +unsigned attempts = 0; +void* heap_caps_malloc(std::size_t bytes, uint32_t caps) +{ + ++attempts; + assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + return fail_external ? nullptr : std::malloc(bytes); +} +void* heap_caps_realloc(void* memory, std::size_t bytes, uint32_t caps) +{ + ++attempts; + assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + return fail_external ? nullptr : std::realloc(memory, bytes); +} +void heap_caps_free(void* memory) { std::free(memory); } +#include "../../platform/esp/idf_common/src/lvgl_memory.cpp" +int main() +{ + lv_mem_init(); + assert(lv_malloc_core(0) == nullptr && attempts == 0); + for (std::size_t bytes : {std::size_t(1), std::size_t(16), std::size_t(4096)}) + { + auto* memory = lv_malloc_core(bytes); + assert(memory); + fail_external = true; + const auto before = attempts; + assert(lv_malloc_core(bytes) == nullptr && attempts == before + 1); + assert(lv_realloc_core(memory, bytes * 2) == nullptr); + fail_external = false; + memory = lv_realloc_core(memory, bytes * 2); + assert(memory); + assert(lv_realloc_core(memory, 0) == nullptr); + } + lv_mem_deinit(); +} diff --git a/tests/menu_widgets/test_ownership.cpp b/tests/menu_widgets/test_ownership.cpp new file mode 100644 index 00000000..81ae8491 --- /dev/null +++ b/tests/menu_widgets/test_ownership.cpp @@ -0,0 +1,64 @@ +#include "ui/menu/dashboard/dashboard_widget.h" +#include +#include + +bool fail_external = false; +unsigned live = 0, releases = 0, destroyed = 0; +void* heap_caps_malloc(std::size_t bytes, uint32_t caps) +{ + assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (fail_external) return nullptr; + auto* memory = std::malloc(bytes); + if (memory) ++live; + return memory; +} +void heap_caps_free(void* memory) +{ + if (memory) + { + assert(live > 0); + --live; + ++releases; + } + std::free(memory); +} +struct State : ui::menu::dashboard::WidgetLifetime +{ + char scratch[4096]{}; + ~State() { ++destroyed; } +}; +int main() +{ + using namespace ui::menu::dashboard; + lv_init(); + auto* display = lv_display_create(480, 222); + assert(display); + for (int cycle = 0; cycle < 100; ++cycle) + { + auto* first = create_widget_state(); + auto* second = create_widget_state(); + assert(first && second && first != second && live == 2); + assert(platform::ui::psramUiRequired()); + auto* parent = lv_obj_create(lv_screen_active()); + auto a = own_widget(lv_obj_create(parent), first, nullptr); + auto b = own_widget(lv_obj_create(parent), second, nullptr); + first->scratch[0] = 42; + auto* borrowed = lv_obj_create(a.root); + lv_obj_add_event_cb( + borrowed, [](lv_event_t* event) + { + assert(live == 2); // Parent state still backs its deleting children. + assert(static_cast(lv_event_get_user_data(event))->scratch[0] == 42); }, + LV_EVENT_DELETE, first); + fail_external = true; + assert(create_widget_state() == nullptr); // No RAM fallback. + assert(live == 2); + fail_external = false; + lv_obj_delete(a.root); + assert(live == 1 && lv_obj_is_valid(b.root)); + second->scratch[0] = 42; // Deleting one widget preserves another. + lv_obj_delete(parent); + assert(live == 0 && !platform::ui::psramUiRequired()); + } + assert(destroyed == 200 && releases == 200); +} diff --git a/tests/site/menu-widgets-native.test.mjs b/tests/site/menu-widgets-native.test.mjs new file mode 100644 index 00000000..9a04ad1c --- /dev/null +++ b/tests/site/menu-widgets-native.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import {readFile, writeFile, mkdir} from 'node:fs/promises'; +import {join} from 'node:path'; +import {framePng} from './native-frame-png.mjs'; + +const boards = [['pager',480,222],['tdeck',320,240],['wio-tracker-l2',320,240], + ['t-display-p4-tft',1168,540],['t-display-p4-amoled',1232,568]]; +for (const [device,width,height] of boards.filter(([name])=>!process.env.WIDGET_UI_DEVICES || process.env.WIDGET_UI_DEVICES.split(',').includes(name))) { + const base = new URL(`../../site/assets/native/${device}`,import.meta.url); + const {default:create} = await import(`${base}.js`); + const data = await readFile(new URL(`${base}.data`)); + let runtime, frame; + runtime = await create({wasmBinary:await readFile(new URL(`${base}.wasm`)), + getPreloadedPackage(){return data.buffer.slice(data.byteOffset,data.byteOffset+data.byteLength);}, + onFrame(pointer,w,h){assert.equal(w,width);assert.equal(h,height);frame=Buffer.from(runtime.HEAPU8.subarray(pointer,pointer+w*h*2));}, + print(){},printErr(message){console.error(message);}}); + const tick = (ms=250) => {for(let elapsed=0;elapsedJSON.parse(runtime.UTF8ToString(runtime._native_snapshot())); + const key=text=>{runtime._native_key(typeof text==='string'?text.charCodeAt(0):text);tick();}; + const swipe=(x,y,dx,dy)=>{ + runtime._native_pointer(x,y,1); + for(let step=1;step<=8;step++){runtime._native_pointer(x+Math.round(dx*step/8),y+Math.round(dy*step/8),1);runtime._native_tick(30);} + runtime._native_pointer(x+dx,y+dy,0);tick(); + }; + const capture=async name=>{ + if(!process.env.WIDGET_UI_ARTIFACT_DIR)return; + const directory=join(process.env.WIDGET_UI_ARTIFACT_DIR,device); + await mkdir(directory,{recursive:true}); + await writeFile(join(directory,`${name}.png`),framePng(frame,width,height)); + await writeFile(join(directory,`${name}.json`),JSON.stringify(snapshot(),null,2)); + }; + const checkAgenda=()=>{ + const before=runtime._native_preview_agenda_queries(); + tick(5000); + assert.equal(runtime._native_preview_agenda_queries(),before,'visible Agenda does not query on every frame'); + runtime._native_preview_agenda_state(0,1);tick(31000); + assert.ok(snapshot().some(item=>item.text==='Storage unavailable')); + runtime._native_preview_agenda_state(1,0);tick(31000); + assert.ok(snapshot().some(item=>item.text==='Waiting for time')); + runtime._native_preview_agenda_state(1,1);tick(31000); + assert.ok(snapshot().some(item=>item.text.includes('Trail check-in')),'Agenda recovers from service unavailability'); + }; + runtime._native_init(device==='pager'?0:1);runtime._native_open_page(10);tick(); + const p4=device.startsWith('t-display'); + const map=snapshot().find(item=>item.button&&item.text==='Map'); + assert.ok(map,`${device}: Map launcher`); + await capture('menu'); + if(p4) { + assert.equal(runtime._native_widget_count(),4); + assert.equal(runtime._native_widget_leases(),4); + swipe(40,100,150,0); + assert.equal(runtime._native_widget_page(),0,'left app area does not page widgets'); + assert.equal(runtime._native_widget_count(),4,'dragging an app icon must not launch it'); + swipe(width-100,Math.floor(height/2),-200,0); + assert.equal(runtime._native_widget_page(),1,'right-side swipe pages widgets'); + assert.equal(runtime._native_widget_count(),1); + assert.equal(runtime._native_widget_leases(),1); + assert.ok(snapshot().some(item=>item.text.includes('Trail check-in'))); + assert.equal(runtime._native_dashboard_geometry(),0); + await capture('agenda'); + checkAgenda(); + swipe(width-320,Math.floor(height/2),200,0); + assert.equal(runtime._native_widget_page(),0); + assert.equal(runtime._native_widget_count(),4); + } else { + const names=['Map','Sky Plot','Tracker','Chat','Contacts','Team','Geocaching','Calculator']; + const icons=snapshot().filter(item=>item.button&&names.includes(item.text)); + const firstY=icons[0].y; + const firstRow=icons.filter(item=>item.y===firstY).sort((a,b)=>a.x-b.x); + const columns=firstRow.length; + assert.equal(columns,Math.floor(width/72),`${device}: adaptive column count`); + const left=firstRow[0].x, right=width-firstRow.at(-1).x-firstRow.at(-1).w; + assert.ok(Math.abs(left-right)<=1,`${device}: symmetric outside margins`); + assert.ok(firstY>=32,`${device}: focus is separated from top bar`); + for(let column=1;columnitem.button&&item.w===firstRow[0].w)) { + assert.ok(firstRow.some(column=>column.x===item.x),'stable row tracks'); + } + assert.equal(runtime._native_widget_page(),-1); + assert.equal(runtime._native_widget_leases(),0,'closed feed owns no widget state'); + runtime._native_focus(map.id); + const focused=runtime._native_menu_focus(); + runtime._native_encoder(1);tick(); + assert.notEqual(runtime._native_menu_focus(),focused,'menu encoder changes launcher focus'); + assert.equal(runtime._native_widget_page(),-1,'menu encoder never opens widgets'); + runtime._native_focus(map.id); + if(device==='pager')key('a');else swipe(30,80,100,0); + assert.equal(runtime._native_widget_page(),0,'open negative screen'); + assert.equal(runtime._native_widget_count(),1); + key('h'); + key('s');runtime._native_encoder(1);tick(); + assert.equal(runtime._native_widget_page(),0,'modal help owns keyboard and encoder input'); + key('h'); + for(let page=0;page<5;page++){ + assert.equal(runtime._native_widget_page(),page); + assert.equal(runtime._native_widget_count(),1,'one widget at a time'); + assert.equal(runtime._native_widget_leases(),1,'one independent PSRAM owner'); + assert.equal(runtime._native_dashboard_geometry(),0,`${device}: widget ${page} geometry`); + await capture(`widget-${page}`); + if(page<4){if(device==='pager'){runtime._native_encoder(1);tick();}else swipe(width/2,height-65,0,-70);} + } + assert.ok(snapshot().some(item=>item.text.includes('Trail check-in')),'Agenda reads the provided service port'); + checkAgenda(); + key('s');assert.equal(runtime._native_widget_page(),4,'bounded at final widget'); + runtime._native_encoder(-1);tick();assert.equal(runtime._native_widget_page(),3); + key('w');assert.equal(runtime._native_widget_page(),2); + const before=runtime._native_preview_agenda_queries(); + tick(35000);assert.equal(runtime._native_preview_agenda_queries(),before,'hidden Agenda is never queried'); + if(device==='pager')key('d');else swipe(width-30,80,-100,0); + assert.equal(runtime._native_widget_page(),-1); + assert.equal(runtime._native_widget_leases(),0); + runtime._native_encoder(1);tick(); + assert.equal(runtime._native_widget_page(),-1,'encoder resumes launcher navigation'); + for(let cycle=0;cycle<50;cycle++){ + runtime._native_focus(map.id);key('a'); + runtime._native_encoder(1);tick();key('d'); + assert.equal(runtime._native_widget_leases(),0,`${device}: repeated feed exit frees state`); + } + } + runtime._native_open_page(11);tick(); + assert.equal(runtime._native_widget_leases(),0,'entering an app frees all widget states'); + runtime._native_open_page(10);tick(); + assert.equal(runtime._native_widget_count(),p4?4:0); + console.log(`${device}: adaptive menu, contextual encoder, widget ownership and navigation passed.`); +} diff --git a/tests/site/native-frame-png.mjs b/tests/site/native-frame-png.mjs new file mode 100644 index 00000000..8d47c8ba --- /dev/null +++ b/tests/site/native-frame-png.mjs @@ -0,0 +1,32 @@ +import {deflateSync} from 'node:zlib'; +export function framePng(frame, width, height) { + const crc32 = bytes => { + let crc = -1; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + return (crc ^ -1) >>> 0; + }; + const chunk = (type, bytes) => { + const result = Buffer.alloc(bytes.length + 12); + result.writeUInt32BE(bytes.length); + result.write(type, 4); + bytes.copy(result, 8); + result.writeUInt32BE(crc32(result.subarray(4, -4)), result.length - 4); + return result; + }; + const header = Buffer.alloc(13); + header.writeUInt32BE(width); header.writeUInt32BE(height, 4); + header[8] = 8; header[9] = 2; + const pixels = Buffer.alloc((width * 3 + 1) * height); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + const value = frame.readUInt16LE((y * width + x) * 2); + const offset = y * (width * 3 + 1) + 1 + x * 3; + pixels[offset] = Math.round(((value >>> 11) & 31) * 255 / 31); + pixels[offset + 1] = Math.round(((value >>> 5) & 63) * 255 / 63); + pixels[offset + 2] = Math.round((value & 31) * 255 / 31); + } + return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]), + chunk('IHDR', header), chunk('IDAT', deflateSync(pixels)), chunk('IEND', Buffer.alloc(0))]); +} diff --git a/tools/web_simulator/CMakeLists.txt b/tools/web_simulator/CMakeLists.txt index 7836b414..c758cac8 100644 --- a/tools/web_simulator/CMakeLists.txt +++ b/tools/web_simulator/CMakeLists.txt @@ -32,6 +32,7 @@ add_executable(${PREVIEW_TARGET} native_chat.cpp preview_platform.cpp preview_sensors.cpp + preview_agenda.cpp preview_facade.cpp preview_status.cpp preview_extensions.cpp @@ -116,6 +117,7 @@ target_include_directories(${PREVIEW_TARGET} PRIVATE "${REPO_ROOT}/modules/core_sys/include" "${REPO_ROOT}/modules/core_chat/include" "${REPO_ROOT}/modules/core_gps/include" + "${REPO_ROOT}/modules/core_agenda/include" "${REPO_ROOT}/modules/core_track_sharing/include" "${REPO_ROOT}/modules/core_geocaching/include" "${REPO_ROOT}/third_party/yxml" @@ -124,6 +126,11 @@ target_compile_features(${PREVIEW_TARGET} PRIVATE cxx_std_17) target_compile_options(${PREVIEW_TARGET} PRIVATE -O2) target_compile_definitions(${PREVIEW_TARGET} PRIVATE LV_CONF_INCLUDE_SIMPLE TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 ${BOARD_DEFINITION}=1) target_link_libraries(${PREVIEW_TARGET} PRIVATE lvgl) +file(GLOB DASHBOARD_WIDGET_SOURCES "${UI}/menu/dashboard/*.cpp") +target_sources(${PREVIEW_TARGET} PRIVATE "${UI}/menu/menu_dashboard.cpp" ${DASHBOARD_WIDGET_SOURCES} + preview_p4_dashboard.cpp + "${UI}/presentation_sources/runtime_mesh_status_source.cpp" + "${REPO_ROOT}/modules/ui_presentation/src/mesh/mesh_status_model.cpp") file(GLOB PREVIEW_ASSETS "${UI}/assets/*.c") target_sources(${PREVIEW_TARGET} PRIVATE ${PREVIEW_ASSETS}) file(GLOB CHAT_VIEW_SOURCES @@ -188,7 +195,6 @@ target_sources(${PREVIEW_TARGET} PRIVATE "${REPO_ROOT}/modules/core_sys/src/platform/ui/timezone_profile.cpp" "${REPO_ROOT}/modules/core_sys/src/platform/ui/screen_power_state_machine.cpp" "${UI}/team_presence/team_presence_model.cpp" - "${UI}/menu/dashboard/dashboard_style.cpp" "${UI}/presentation_sources/runtime_map_workspace_source.cpp" "${UI}/presentation_sources/team_map_overlay_source.cpp" "${REPO_ROOT}/modules/ui_presentation/src/map/map_workspace_model.cpp" @@ -240,12 +246,6 @@ if(TRAIL_MATE_PREVIEW_P4) foreach(PANEL IN ITEMS tft amoled) add_native_preview(trailmate_p4_${PANEL} TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4 t-display-p4-${PANEL}) target_include_directories(trailmate_p4_${PANEL} PRIVATE "${REPO_ROOT}/boards/t_display_p4/include") - file(GLOB P4_DASHBOARD_SOURCES "${UI}/menu/dashboard/*.cpp") - target_sources(trailmate_p4_${PANEL} PRIVATE "${UI}/menu/menu_dashboard.cpp" ${P4_DASHBOARD_SOURCES}) - target_sources(trailmate_p4_${PANEL} PRIVATE - preview_p4_dashboard.cpp - "${UI}/presentation_sources/runtime_mesh_status_source.cpp" - "${REPO_ROOT}/modules/ui_presentation/src/mesh/mesh_status_model.cpp") endforeach() target_compile_definitions(trailmate_p4_tft PRIVATE CONFIG_TRAIL_MATE_T_DISPLAY_P4_PANEL_HI8561=1) endif() diff --git a/tools/web_simulator/native_menu.cpp b/tools/web_simulator/native_menu.cpp index 4e3e23ce..c0c21576 100644 --- a/tools/web_simulator/native_menu.cpp +++ b/tools/web_simulator/native_menu.cpp @@ -3,6 +3,7 @@ #include "ui/app_catalog.h" #include "ui/app_catalog_builder.h" #include "ui/app_runtime.h" +#include "ui/menu/menu_dashboard.h" #include "ui/menu/menu_layout.h" #include "ui/menu/menu_runtime.h" #include "ui/runtime/ui_feedback.h" @@ -10,6 +11,11 @@ #include extern "C" void native_open_page(int page); +namespace ui::agenda +{ +class IAgendaSource; +} +ui::agenda::IAgendaSource* preview_agenda_source(); static bool menu_created = false; static AppScreen* active_app = nullptr; static ui::AppCatalog catalog; @@ -37,6 +43,7 @@ void preview_menu_enter() #endif catalog = ui::app_catalog_builder::build(flags); ui::menu_layout::InitOptions options; + ui::menu::dashboard::bindAgendaSource(preview_agenda_source()); options.apps = catalog; ui::menu_layout::init(options); if (previous_group) lv_group_delete(previous_group); @@ -58,6 +65,7 @@ void preview_menu_exit() { if (menu_created) { + ui::menu::dashboard::setActive(false); ui::menu_runtime::setScene(ui::menu_runtime::Scene::App); lv_obj_add_flag(main_screen, LV_OBJ_FLAG_HIDDEN); } diff --git a/tools/web_simulator/native_runtime.cpp b/tools/web_simulator/native_runtime.cpp index f9a83b45..46e35638 100644 --- a/tools/web_simulator/native_runtime.cpp +++ b/tools/web_simulator/native_runtime.cpp @@ -4,6 +4,7 @@ #include "platform/ui/walkie_runtime.h" #include "ui/app_runtime.h" #include "ui/localization.h" +#include "ui/menu/menu_dashboard.h" #include "ui/menu/menu_runtime.h" #include "ui/page/page_profile.h" #include "ui/screens/calculator/calculator_page_runtime.h" @@ -232,6 +233,7 @@ extern "C" } EMSCRIPTEN_KEEPALIVE void native_encoder(int delta) { + if (ui::menu::dashboard::handleEncoder(delta)) return; if (auto* group = input_group()) { if (delta < 0) lv_group_focus_prev(group); diff --git a/tools/web_simulator/preview_agenda.cpp b/tools/web_simulator/preview_agenda.cpp new file mode 100644 index 00000000..f91365b9 --- /dev/null +++ b/tools/web_simulator/preview_agenda.cpp @@ -0,0 +1,48 @@ +// Deterministic service boundary for real widget rendering. Firmware binds the +// target-owned Agenda model instead; this source never enters a device build. +#include "ui_presentation/agenda/agenda_source.h" +#include +#include + +namespace +{ +bool available = true, storage_ready = true, clock_ready = true; +int queries = 0; +class Source : public ui::agenda::IAgendaSource +{ + public: + ::agenda::ClockSample currentTime() const override + { + ::agenda::ClockSample clock; + clock.valid = clock_ready; + clock.calendar_seconds = 1792108800 + 9 * 3600; + return clock; + } + void snapshot(const ui::agenda::AgendaRequest& request, ui::agenda::AgendaSnapshot& out) override + { + ++queries; + out = {}; + out.storage_ready = storage_ready && available; + out.clock_valid = clock_ready; + if (!out.storage_ready || !out.clock_valid) return; + out.page.count = 2; + const char* titles[] = {"Trail check-in", "Camp resupply"}; + for (int index = 0; index < 2; ++index) + { + out.page.rows[index].occurrence.start = request.day_start + (10 + index * 6) * 3600; + std::snprintf(out.page.rows[index].title, sizeof(out.page.rows[index].title), "%s", titles[index]); + } + } + ::agenda::AgendaResult detail(uint32_t, ::agenda::EventRecord&) override { return ::agenda::AgendaResult::NotFound; } + bool newDraft(ui::agenda::AgendaEditorModel&) const override { return false; } + void month(uint16_t, uint8_t, ui::agenda::MonthSnapshot& out) override { out = {}; } + ui::agenda::CommandResult commandResult() const override { return {}; } +} source; +} // namespace +ui::agenda::IAgendaSource* preview_agenda_source() { return &source; } +extern "C" EMSCRIPTEN_KEEPALIVE void native_preview_agenda_state(int ready, int clock) +{ + storage_ready = ready != 0; + clock_ready = clock != 0; +} +extern "C" EMSCRIPTEN_KEEPALIVE int native_preview_agenda_queries() { return queries; } diff --git a/tools/web_simulator/preview_p4_dashboard.cpp b/tools/web_simulator/preview_p4_dashboard.cpp index 6f0a1078..d036c405 100644 --- a/tools/web_simulator/preview_p4_dashboard.cpp +++ b/tools/web_simulator/preview_p4_dashboard.cpp @@ -1,57 +1,91 @@ -// Exercise the real dashboard's geometry and destruction boundaries. +// Inspect ownership and geometry through the host/widget boundary. +#include "ui/app_runtime.h" #include "ui/menu/dashboard/dashboard_state.h" #include "ui/menu/menu_dashboard.h" #include "ui/menu/menu_layout.h" #include #include -extern "C" EMSCRIPTEN_KEEPALIVE int native_dashboard_geometry() +namespace { - auto& state = ui::menu::dashboard::dashboard_state(); - if (!state.panel || !state.timer) return 1; - for (auto* object : {state.mesh.orbit_core, state.gps.radar, state.compass.dial, state.compass.needle}) - if (!object || lv_obj_get_width(object) <= 0 || lv_obj_get_height(object) <= 0) return 2; - lv_area_t panel{}, parent{}; - lv_obj_get_coords(state.panel, &panel); - lv_obj_get_coords(lv_obj_get_parent(state.panel), &parent); - if (panel.x1 < parent.x1 || panel.y1 < parent.y1 || panel.x2 > parent.x2 || panel.y2 >= parent.y2 - 24) return 6; - const float diameter = lv_obj_get_content_width(state.compass.dial); - for (const auto& point : state.compass.needle_points) - if (!std::isfinite(static_cast(point.x)) || !std::isfinite(static_cast(point.y)) || - point.x < 0 || point.y < 0 || point.x > diameter || point.y > diameter) return 7; +int inspect_lines(lv_obj_t* object) +{ + if (lv_obj_check_type(object, &lv_line_class)) + { + const auto* points = lv_line_get_points(object); + auto* parent = lv_obj_get_parent(object); + const auto width = lv_obj_get_content_width(parent); + const auto height = lv_obj_get_content_height(parent); + for (uint32_t index = 0; index < lv_line_get_point_count(object); ++index) + if (!std::isfinite(static_cast(points[index].x)) || !std::isfinite(static_cast(points[index].y)) || + points[index].x < 0 || points[index].y < 0 || points[index].x > width || points[index].y > height) return 7; + } + for (uint32_t index = 0; index < lv_obj_get_child_count(object); ++index) + if (const auto error = inspect_lines(lv_obj_get_child(object, index))) return error; + return 0; +} +} // namespace +extern "C" EMSCRIPTEN_KEEPALIVE int native_dashboard_geometry() +{ + auto* state = ui::menu::dashboard::dashboard_state(); + if (!state || !state->panel || !state->timer) return 1; + lv_area_t panel{}, parent{}; + lv_obj_get_coords(state->panel, &panel); + lv_obj_get_coords(lv_obj_get_parent(state->panel), &parent); + if (panel.x1 < parent.x1 || panel.y1 < parent.y1 || panel.x2 > parent.x2 || panel.y2 > parent.y2 - 24) return 6; + for (const auto& widget : state->widgets) + { + if (!widget.root) continue; + lv_area_t card{}, grid{}; + lv_obj_get_coords(widget.root, &card); + lv_obj_get_coords(state->grid, &grid); + if (card.x1 < grid.x1 || card.y1 < grid.y1 || card.x2 > grid.x2 || card.y2 > grid.y2) return 2; + if (const auto error = inspect_lines(widget.root)) return error; + } return 0; } - extern "C" EMSCRIPTEN_KEEPALIVE uint32_t native_dashboard_tick() { - return ui::menu::dashboard::dashboard_state().tick; + auto* state = ui::menu::dashboard::dashboard_state(); + return state ? state->tick : 0; +} +extern "C" EMSCRIPTEN_KEEPALIVE int native_widget_page() +{ + auto* state = ui::menu::dashboard::dashboard_state(); + return state && (state->dock_right || state->feed_open) ? state->page : -1; +} +extern "C" EMSCRIPTEN_KEEPALIVE int native_widget_leases() +{ + return platform::ui::psram_ui_leases.load(); +} +extern "C" EMSCRIPTEN_KEEPALIVE int native_widget_count() +{ + auto* state = ui::menu::dashboard::dashboard_state(); + int count = 0; + if (state) + for (const auto& widget : state->widgets) + if (widget.root) ++count; + return count; +} +extern "C" EMSCRIPTEN_KEEPALIVE uint32_t native_menu_focus() +{ + return static_cast(reinterpret_cast(lv_group_get_focused(menu_g))); } - extern "C" EMSCRIPTEN_KEEPALIVE int native_dashboard_lifecycle(int cycles) { using namespace ui::menu::dashboard; auto* parent = ui::menu_layout::menuPanel(); - lv_obj_t* grid = nullptr; - for (uint32_t index = 0; index < lv_obj_get_child_count(parent); ++index) - { - auto* child = lv_obj_get_child(parent, index); - if (child != dashboard_state().panel && lv_obj_get_style_flex_flow(child, LV_PART_MAIN) == LV_FLEX_FLOW_ROW_WRAP) - { - grid = child; - break; - } - } - if (!grid) return 3; + auto* grid = dashboard_state()->menu_grid; const auto expected_children = lv_obj_get_child_count(parent); for (int cycle = 0; cycle < cycles; ++cycle) { - auto* panel = dashboard_state().panel; - lv_obj_delete(panel); - if (dashboard_state().timer || dashboard_state().panel) return 4; + lv_obj_delete(dashboard_state()->panel); + if (dashboard_state()) return 4; + if (native_widget_leases() != 0) return 9; lv_tick_inc(1100); lv_timer_handler(); init(parent, grid, {}); - init(parent, grid, {}); // Repeated initialization must replace its owner. + init(parent, grid, {}); lv_obj_update_layout(parent); if (lv_obj_get_child_count(parent) != expected_children) return 8; if (native_dashboard_geometry() != 0) return 5;