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 00000000..556d09e8 Binary files /dev/null and b/docs/images/menu-widgets/p4-menu.png differ diff --git a/docs/images/menu-widgets/pager-agenda.png b/docs/images/menu-widgets/pager-agenda.png new file mode 100644 index 00000000..de1a740e Binary files /dev/null and b/docs/images/menu-widgets/pager-agenda.png differ diff --git a/docs/images/menu-widgets/pager-menu.png b/docs/images/menu-widgets/pager-menu.png new file mode 100644 index 00000000..550c65e8 Binary files /dev/null and b/docs/images/menu-widgets/pager-menu.png differ diff --git a/docs/images/menu-widgets/tdeck-menu.png b/docs/images/menu-widgets/tdeck-menu.png new file mode 100644 index 00000000..0f0aca02 Binary files /dev/null and b/docs/images/menu-widgets/tdeck-menu.png differ 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;