From d88aee147db88134aae6df054164b70530e6be93 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Fri, 30 Oct 2026 21:14:27 +0100 Subject: [PATCH 01/13] Theming, mesh, setup and tweaks --- Buildscripts/release-sdk-esp32.py | 3 + Devices/simulator/Source/module.cpp | 3 +- Documentation/ideas.md | 7 +- .../include/location/location.h | 12 +- .../location-module/source/ip_provider.cpp | 13 +- .../source/location_service.cpp | 27 +- Modules/location-module/source/module.cpp | 1 + .../tests/source/service_test.cpp | 106 +++++++- Modules/lvgl-module/CMakeLists.txt | 19 -- Modules/lvgl-module/include/lvgl/lvgl.h | 4 +- Modules/lvgl-module/include/lvgl/theme.h | 36 ++- Modules/lvgl-module/source/lvgl.c | 2 +- Modules/lvgl-module/source/symbols.c | 3 +- Modules/lvgl-module/source/theme.cpp | 36 ++- .../source/themes/lv_theme_material.c | 150 +++++------ .../source/themes/lv_theme_material.h | 3 +- .../source/themes/lv_theme_material_mono.c | 118 ++++----- .../source/themes/lv_theme_material_mono.h | 3 +- .../lvgl-module/source/widgets/statusbar.cpp | 9 +- .../lvgl-module/source/widgets/toolbar.cpp | 32 +-- .../lvgl-module/tests/source/theme_test.cpp | 22 ++ Modules/lvgl-module/themes/material.lss | 145 ++++++----- Modules/lvgl-module/themes/material_mono.lss | 113 ++++---- Modules/mesh-module/private/mesh/mesh_store.h | 5 + Modules/mesh-module/source/mesh_service.cpp | 211 +++++++++++++-- Modules/mesh-module/source/mesh_store.cpp | 10 + .../mesh-module/tests/source/service_test.cpp | 140 ++++++++++ .../mesh-module/tests/source/store_test.cpp | 23 ++ Tactility/Include/Tactility/network/Ntp.h | 3 + .../service/gpstime/GpsTimeSyncService.h | 53 ++++ .../Tactility/settings/AppearanceSettings.h | 13 +- Tactility/Include/Tactility/settings/Time.h | 5 + Tactility/Kconfig | 12 + .../Private/Tactility/app/boot/BootScreen.h | 14 + Tactility/Private/Tactility/lvgl/SystemBars.h | 6 + Tactility/Source/InitServices.cpp | 6 + Tactility/Source/app/TileGrid.cpp | 4 +- .../appearancesettings/AppearanceSettings.cpp | 71 ++--- Tactility/Source/app/boot/BootInit.cpp | 54 +++- Tactility/Source/app/boot/BootScreen.cpp | 120 +++++++++ .../app/locationsettings/LocationSettings.cpp | 56 +++- Tactility/Source/app/setup/Setup.cpp | 246 ++++++++++++------ .../Source/app/systeminfo/SystemInfo.cpp | 6 +- .../app/timedatesettings/TimeDateSettings.cpp | 46 ++++ Tactility/Source/lvgl/SystemBars.cpp | 6 + Tactility/Source/lvgl/SystemMenu.cpp | 2 +- Tactility/Source/lvgl/Theme.cpp | 7 +- Tactility/Source/network/Ntp.cpp | 13 +- .../service/gpstime/GpsTimeSyncService.cpp | 125 +++++++++ .../Source/settings/AppearanceSettings.cpp | 44 +++- Tactility/Source/settings/time.cpp | 28 ++ .../Tests/Source/AppearanceSettingsTest.cpp | 101 +++++++ Tactility/Tests/Source/GpsTimeSyncTest.cpp | 30 +++ TactilityKernel/include/tactility/network.h | 18 ++ .../include/tactility/system_event.h | 7 + TactilityKernel/source/network.c | 23 ++ TactilityKernel/source/symbols.c | 3 + TactilityKernel/tests/source/network_test.cpp | 56 ++++ device.py | 7 + 59 files changed, 1927 insertions(+), 514 deletions(-) create mode 100644 Modules/lvgl-module/tests/source/theme_test.cpp create mode 100644 Tactility/Include/Tactility/service/gpstime/GpsTimeSyncService.h create mode 100644 Tactility/Source/service/gpstime/GpsTimeSyncService.cpp create mode 100644 Tactility/Tests/Source/AppearanceSettingsTest.cpp create mode 100644 Tactility/Tests/Source/GpsTimeSyncTest.cpp create mode 100644 TactilityKernel/include/tactility/network.h create mode 100644 TactilityKernel/source/network.c create mode 100644 TactilityKernel/tests/source/network_test.cpp diff --git a/Buildscripts/release-sdk-esp32.py b/Buildscripts/release-sdk-esp32.py index 29b306c5d..1fe794de5 100644 --- a/Buildscripts/release-sdk-esp32.py +++ b/Buildscripts/release-sdk-esp32.py @@ -92,6 +92,9 @@ def main(): # Mapping logic mappings = [ {'src': 'version.txt', 'dst': ''}, + # platform-esp32 + {'src': 'build/esp-idf/platform-esp32/libplatform-esp32.a', 'dst': 'Modules/platform-esp32/binary/'}, + {'src': 'Platforms/platform-esp32/include/**/*.h', 'dst': 'Modules/platform-esp32/include/'}, # TactilityFreeRtos {'src': 'TactilityFreeRtos/Include/**', 'dst': 'Libraries/TactilityFreeRtos/Include/'}, {'src': 'TactilityFreeRtos/CMakeLists.txt', 'dst': 'Libraries/TactilityFreeRtos/'}, diff --git a/Devices/simulator/Source/module.cpp b/Devices/simulator/Source/module.cpp index d33e768a5..c07d5f8c9 100644 --- a/Devices/simulator/Source/module.cpp +++ b/Devices/simulator/Source/module.cpp @@ -112,7 +112,8 @@ extern "C" { static error_t start() { simulator::initActiveProfile(); - lvgl_theme_set_default_compact(simulator::getProfile(simulator::getActiveProfileIndex()).compact); + const uint8_t density = simulator::getProfile(simulator::getActiveProfileIndex()).compact ? 0 : 100; + lvgl_theme_set_default_density(density, density); error_t error = app_manager_add(&simulator::profileAppManifest); if (error != ERROR_NONE) { return error; diff --git a/Documentation/ideas.md b/Documentation/ideas.md index 6d6bbf6a3..9871f7505 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -7,7 +7,7 @@ ## Higher Priority -- Fix system icon selection on mono display +- Export platform-esp32 with TactilitySDK - Find out why the first build of an app with a clean build cache folder always fails - Improve posix support: use user home path and system /tmp folder. - Drivers/audio-codec-module is not a module. Move it somewhere else. Or make it an actual module. @@ -20,10 +20,13 @@ ## Medium Priority +- Improve watch interface. Consider adding some sort of device classification so UI in apps can choose to optimize for the watch. +- Support for running scripts at boot - Update timezones.csv wrt 2026e, write tests for related code +- Notification support +- Low/critical memory warning test/rework - Try https://github.com/bootdsc/Fastwave-Eink-Driver with CL-32 e-paper. - Pick different font to improve monochrome screen readability (or change the weight). -- Use GPS time to set/update the current time: when enabled, at boot, when wifi is not enabled, start the gps device and wait until a lock is acquired, then get the time and sync it. Then stop the GPS device. - Inspect code for memory improvements: replace `std::*` with `tt::std::*` - Tactility/Source/app/imageviewer/ImageViewer.cpp: Limit memory used to open an image. loadFile() now resizes fileData to the entire file size before displaying the image. start accepts a file path without a size limit, so opening a large image can exhaust memory on an embedded device before LVGL decodes it. Reject files above a supported limit or retain a bounded file-backed decoding path. diff --git a/Modules/location-module/include/location/location.h b/Modules/location-module/include/location/location.h index 4a18631d6..845f32e66 100644 --- a/Modules/location-module/include/location/location.h +++ b/Modules/location-module/include/location/location.h @@ -89,6 +89,8 @@ enum LocationPrecision location_get_precision(void); */ error_t location_get(struct Location* location); +#define LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH 24 + /** * Caller-owned subscription: the service signals @a bit on the event group whenever a new current fix is reported. * Read the fix with location_get(). @@ -98,6 +100,7 @@ struct LocationSubscription { uint32_t bit; struct { + char description[LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH + 1]; struct TaskEventGroup* event_group; struct LocationSubscription* next; } internal; @@ -108,14 +111,21 @@ struct LocationSubscription { * Providers start and stop in the background: subscribing and unsubscribing don't wait for them. * @param[in,out] sub subscription to register, must stay alive until unsubscribed * @param[in] event_group caller-owned group to wait on, must outlive @a sub + * @param[in] description what the subscriber is, shown to the user. Truncated to LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH characters. * @retval ERROR_RESOURCE @a event_group has no free bits left * @retval ERROR_INVALID_STATE @a sub is already registered */ -error_t location_subscribe(struct LocationSubscription* sub, struct TaskEventGroup* event_group); +error_t location_subscribe(struct LocationSubscription* sub, struct TaskEventGroup* event_group, const char* description); /** @retval ERROR_NOT_FOUND when @a sub is not registered */ error_t location_unsubscribe(struct LocationSubscription* sub); +/** + * Calls @a callback for each subscription, in subscription order. + * The service lock is held during the callback, so it must only copy the description. + */ +void location_for_each_subscriber(void* context, void (*callback)(const char* description, void* context)); + /** @return true when location services are enabled and there is at least one subscription */ bool location_is_in_use(void); diff --git a/Modules/location-module/source/ip_provider.cpp b/Modules/location-module/source/ip_provider.cpp index 7c030c947..59588e59e 100644 --- a/Modules/location-module/source/ip_provider.cpp +++ b/Modules/location-module/source/ip_provider.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -216,8 +217,8 @@ int32_t threadMain(void* context) { } // The public IP address can change with the network - if (system_event_poll(&provider.networkSubscription) == ERROR_NONE && attempted) { - nextAttemptMs = lastAttemptMs + MIN_INTERVAL_MS; + if (system_event_poll(&provider.networkSubscription) == ERROR_NONE) { + nextAttemptMs = attempted ? lastAttemptMs + MIN_INTERVAL_MS : 0; } if (!location_host_needs_fallback()) { @@ -235,6 +236,11 @@ int32_t threadMain(void* context) { continue; } + if (!network_is_available()) { + nextAttemptMs = getUptimeMs() + CHECK_INTERVAL_MS; + continue; + } + const uint64_t attemptMs = getUptimeMs(); if (attemptMs < nextAttemptMs) { continue; @@ -326,6 +332,9 @@ error_t location_lookup_time_zone(char* out_name, size_t out_size) { if (!location_is_enabled()) { return ERROR_INVALID_STATE; } + if (!network_is_available()) { + return ERROR_RESOURCE; + } TaskEventGroup group {}; task_event_group_construct(&group); const std::atomic running = true; diff --git a/Modules/location-module/source/location_service.cpp b/Modules/location-module/source/location_service.cpp index 7ba26a101..a9dca1955 100644 --- a/Modules/location-module/source/location_service.cpp +++ b/Modules/location-module/source/location_service.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -48,6 +49,7 @@ namespace { /** A fix of a provider that isn't a fallback is preferred over fallback fixes for this long */ constexpr uint64_t FRESH_MS = 120000; +constexpr uint64_t TIME_EVENT_INTERVAL_MS = 60000; // About 2 km north to south constexpr double APPROXIMATE_GRID_DEGREES = 0.02; // The worker waits this long for a stopping provider before it handles the other providers @@ -72,6 +74,8 @@ struct HostStorage { HostStorage* hostStorage = nullptr; bool running = false; LocationSubscription* subscriptions = nullptr; +bool hasEmittedTime = false; +uint64_t lastTimeEventUptimeMs = 0; // Starts and stops providers. It only exists while a provider isn't in its desired state. Thread* worker = nullptr; bool workerActive = false; @@ -265,6 +269,7 @@ void destroyService(const ServiceManifest*, void*) {} error_t onStart(ServiceInstance*, void*) { mutex_lock(&mutex.handle); running = true; + hasEmittedTime = false; reconcile(); mutex_unlock(&mutex.handle); return ERROR_NONE; @@ -445,7 +450,18 @@ void location_host_report(LocationHost* host, const Location* location) { task_event_group_signal(sub->internal.event_group, sub->bit); } } + const uint64_t now = host->location.uptime_ms; + const bool emitTime = !host->api->fallback && host->location.time_ms > 0 && + (!hasEmittedTime || now - lastTimeEventUptimeMs >= TIME_EVENT_INTERVAL_MS); + if (emitTime) { + hasEmittedTime = true; + lastTimeEventUptimeMs = now; + } + const LocationTimeEvent timeEvent = { .time_ms = host->location.time_ms }; mutex_unlock(&mutex.handle); + if (emitTime) { + system_event_emit(KERNEL_EVENT_LOCATION_TIME, &timeEvent, sizeof(timeEvent)); + } } bool location_host_needs_fallback(void) { @@ -472,7 +488,7 @@ error_t location_get(Location* location) { return best != nullptr ? ERROR_NONE : ERROR_NOT_FOUND; } -error_t location_subscribe(LocationSubscription* sub, TaskEventGroup* event_group) { +error_t location_subscribe(LocationSubscription* sub, TaskEventGroup* event_group, const char* description) { uint32_t bit; const error_t claimed = task_event_group_claim_bit(event_group, &bit); if (claimed != ERROR_NONE) { @@ -488,6 +504,8 @@ error_t location_subscribe(LocationSubscription* sub, TaskEventGroup* event_grou } } sub->bit = bit; + strncpy(sub->internal.description, description != nullptr ? description : "", LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH); + sub->internal.description[LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH] = '\0'; sub->internal.event_group = event_group; sub->internal.next = subscriptions; subscriptions = sub; @@ -515,6 +533,13 @@ error_t location_unsubscribe(LocationSubscription* sub) { return result; } +void location_for_each_subscriber(void* context, void (*callback)(const char* description, void* context)) { + mutex_lock(&mutex.handle); + for (auto* sub = subscriptions; sub != nullptr; sub = sub->internal.next) { + callback(sub->internal.description, context); + } + mutex_unlock(&mutex.handle); +} bool location_is_in_use(void) { mutex_lock(&mutex.handle); diff --git a/Modules/location-module/source/module.cpp b/Modules/location-module/source/module.cpp index 62c956733..31501d40d 100644 --- a/Modules/location-module/source/module.cpp +++ b/Modules/location-module/source/module.cpp @@ -50,6 +50,7 @@ static const ModuleSymbol location_module_symbols[] = { DEFINE_MODULE_SYMBOL(location_get), DEFINE_MODULE_SYMBOL(location_subscribe), DEFINE_MODULE_SYMBOL(location_unsubscribe), + DEFINE_MODULE_SYMBOL(location_for_each_subscriber), DEFINE_MODULE_SYMBOL(location_is_in_use), DEFINE_MODULE_SYMBOL(location_lookup_time_zone), MODULE_SYMBOL_TERMINATOR diff --git a/Modules/location-module/tests/source/service_test.cpp b/Modules/location-module/tests/source/service_test.cpp index fcc27f84a..8b3d99018 100644 --- a/Modules/location-module/tests/source/service_test.cpp +++ b/Modules/location-module/tests/source/service_test.cpp @@ -8,12 +8,14 @@ #include #include +#include #include #include #include #include #include +#include namespace { @@ -76,6 +78,36 @@ Location makeLocation(double latitude, double longitude) { } +TEST_CASE("location service lists the descriptions of its subscribers") { + TaskEventGroup group; + task_event_group_construct(&group); + LocationSubscription first {}; + LocationSubscription second {}; + REQUIRE(location_subscribe(&first, &group, "First") == ERROR_NONE); + REQUIRE(location_subscribe(&second, &group, "A description that is too long to fit") == ERROR_NONE); + + std::vector descriptions; + location_for_each_subscriber(&descriptions, [](const char* description, void* context) { + static_cast*>(context)->push_back(description); + }); + // The most recent subscription is first + REQUIRE(descriptions.size() == 2); + CHECK(descriptions[0] == "A description that is to"); + CHECK(descriptions[0].size() == LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH); + CHECK(descriptions[1] == "First"); + + CHECK(location_unsubscribe(&second) == ERROR_NONE); + descriptions.clear(); + location_for_each_subscriber(&descriptions, [](const char* description, void* context) { + static_cast*>(context)->push_back(description); + }); + REQUIRE(descriptions.size() == 1); + CHECK(descriptions[0] == "First"); + + CHECK(location_unsubscribe(&first) == ERROR_NONE); + task_event_group_destruct(&group); +} + TEST_CASE("location service reports fixes of its providers") { createCount = 0; destroyCount = 0; @@ -93,8 +125,8 @@ TEST_CASE("location service reports fixes of its providers") { TaskEventGroup group; task_event_group_construct(&group); LocationSubscription sub {}; - REQUIRE(location_subscribe(&sub, &group) == ERROR_NONE); - CHECK(location_subscribe(&sub, &group) == ERROR_INVALID_STATE); + REQUIRE(location_subscribe(&sub, &group, "test") == ERROR_NONE); + CHECK(location_subscribe(&sub, &group, "test") == ERROR_INVALID_STATE); CHECK(location_is_in_use()); REQUIRE(waitUntil([] { return fakeHost != nullptr; })); CHECK(createCount == 1); @@ -120,7 +152,7 @@ TEST_CASE("location service reports fixes of its providers") { CHECK(waitUntil([] { return destroyCount == 1; })); CHECK(location_get(&location) == ERROR_NONE); - REQUIRE(location_subscribe(&sub, &group) == ERROR_NONE); + REQUIRE(location_subscribe(&sub, &group, "test") == ERROR_NONE); CHECK(waitUntil([] { return createCount == 2; })); REQUIRE(service_manager_stop(location_service_manifest.id) == ERROR_NONE); @@ -192,7 +224,7 @@ TEST_CASE("location service prefers fixes of providers that aren't a fallback") TaskEventGroup group; task_event_group_construct(&group); LocationSubscription sub {}; - REQUIRE(location_subscribe(&sub, &group) == ERROR_NONE); + REQUIRE(location_subscribe(&sub, &group, "test") == ERROR_NONE); REQUIRE(waitUntil([] { return fakeHost != nullptr && fallbackHost != nullptr; })); CHECK(location_host_needs_fallback()); @@ -230,6 +262,62 @@ TEST_CASE("location service prefers fixes of providers that aren't a fallback") REQUIRE(service_manager_remove(location_service_manifest.id) == ERROR_NONE); } +namespace { + +std::atomic timeEventCount = 0; +std::atomic lastTimeEventMs = 0; + +void onLocationTime(SystemEvent* event, void*) { + LocationTimeEvent data; + memcpy(&data, event->data, sizeof(data)); + lastTimeEventMs = data.time_ms; + timeEventCount++; +} + +} + +TEST_CASE("location service announces the time of satellite fixes once per minute") { + timeEventCount = 0; + REQUIRE(location_provider_register(&fakeProvider) == ERROR_NONE); + REQUIRE(location_provider_register(&fakeFallbackProvider) == ERROR_NONE); + REQUIRE(service_manager_add(&location_service_manifest, true) == ERROR_NONE); + REQUIRE(system_event_callback_add(KERNEL_EVENT_LOCATION_TIME, onLocationTime, nullptr) == ERROR_NONE); + + TaskEventGroup group; + task_event_group_construct(&group); + LocationSubscription sub {}; + REQUIRE(location_subscribe(&sub, &group, "test") == ERROR_NONE); + REQUIRE(waitUntil([] { return fakeHost != nullptr && fallbackHost != nullptr; })); + + // Fallback providers don't know the time + Location fix = makeLocation(51.05, 3.72); + location_host_report(fallbackHost, &fix); + CHECK(timeEventCount == 0); + + // Fixes without a time are ignored + fix.time_ms = 0; + location_host_report(fakeHost, &fix); + CHECK(timeEventCount == 0); + + fix.time_ms = 1700000000123; + location_host_report(fakeHost, &fix); + CHECK(timeEventCount == 1); + CHECK(lastTimeEventMs == 1700000000123); + + // The next fix is within the same minute + fix.time_ms += 1000; + location_host_report(fakeHost, &fix); + CHECK(timeEventCount == 1); + + CHECK(system_event_callback_remove(KERNEL_EVENT_LOCATION_TIME, onLocationTime) == ERROR_NONE); + CHECK(location_unsubscribe(&sub) == ERROR_NONE); + task_event_group_destruct(&group); + REQUIRE(service_manager_stop(location_service_manifest.id) == ERROR_NONE); + CHECK(location_provider_unregister(&fakeProvider) == ERROR_NONE); + CHECK(location_provider_unregister(&fakeFallbackProvider) == ERROR_NONE); + REQUIRE(service_manager_remove(location_service_manifest.id) == ERROR_NONE); +} + TEST_CASE("location_make_approximate keeps only a coarse position") { Location location = makeLocation(51.054321, 3.721234); location.altitude = 12.0f; @@ -293,7 +381,7 @@ TEST_CASE("location service doesn't wait for a provider that is slow to stop") { TaskEventGroup group; task_event_group_construct(&group); LocationSubscription sub {}; - REQUIRE(location_subscribe(&sub, &group) == ERROR_NONE); + REQUIRE(location_subscribe(&sub, &group, "test") == ERROR_NONE); REQUIRE(waitUntil([] { return fakeHost != nullptr && stuckHost != nullptr; })); LocationHost* stoppingHost = stuckHost; @@ -308,7 +396,7 @@ TEST_CASE("location service doesn't wait for a provider that is slow to stop") { Location location; CHECK(location_get(&location) == ERROR_NOT_FOUND); - REQUIRE(location_subscribe(&sub, &group) == ERROR_NONE); + REQUIRE(location_subscribe(&sub, &group, "test") == ERROR_NONE); CHECK(waitUntil([] { return createCount == 2; })); CHECK(stuckCreateCount == 1); @@ -350,7 +438,7 @@ TEST_CASE("location service has no background task without subscriptions") { TaskEventGroup group; task_event_group_construct(&group); LocationSubscription sub {}; - REQUIRE(location_subscribe(&sub, &group) == ERROR_NONE); + REQUIRE(location_subscribe(&sub, &group, "test") == ERROR_NONE); REQUIRE(waitUntil([] { return fakeHost != nullptr; })); CHECK(location_unsubscribe(&sub) == ERROR_NONE); REQUIRE(waitUntil([] { return fakeHost == nullptr; })); @@ -374,7 +462,7 @@ TEST_CASE("the gps provider has no task without subscriptions") { TaskEventGroup group; task_event_group_construct(&group); LocationSubscription sub {}; - REQUIRE(location_subscribe(&sub, &group) == ERROR_NONE); + REQUIRE(location_subscribe(&sub, &group, "test") == ERROR_NONE); CHECK(waitUntil([] { return getLocationTasks().find("location_gps") != std::string::npos; })); CHECK(location_unsubscribe(&sub) == ERROR_NONE); @@ -400,7 +488,7 @@ TEST_CASE("selecting approximate precision rounds coarse fixes and drops precise TaskEventGroup group; task_event_group_construct(&group); LocationSubscription sub {}; - REQUIRE(location_subscribe(&sub, &group) == ERROR_NONE); + REQUIRE(location_subscribe(&sub, &group, "test") == ERROR_NONE); REQUIRE(waitUntil([] { return fakeHost != nullptr && fallbackHost != nullptr; })); Location coarse = makeLocation(51.054321, 3.721234); diff --git a/Modules/lvgl-module/CMakeLists.txt b/Modules/lvgl-module/CMakeLists.txt index 22ca21bd9..6b3752f8f 100644 --- a/Modules/lvgl-module/CMakeLists.txt +++ b/Modules/lvgl-module/CMakeLists.txt @@ -12,23 +12,6 @@ READ_PROPERTIES_TO_MAP( "${CMAKE_CURRENT_LIST_DIR}/../../Devices/${TACTILITY_DEVICE_ID}/device.properties" device_properties ) -# Read UI density -GET_VALUE_FROM_MAP(device_properties "lvgl.uiDensity" ui_density) -# Define UiDensity enum value -if (ui_density) - if (ui_density STREQUAL "default") - set(ui_density_variable "LVGL_UI_DENSITY_DEFAULT") - elseif (ui_density STREQUAL "compact") - set(ui_density_variable "LVGL_UI_DENSITY_COMPACT") - else () - message(FATAL_ERROR "Invalid lvgl.uiDensity: '${ui_density}'. Must be either 'default' or 'compact'") - endif () - message("UI density set to '${ui_density}' via properties") -else () - set(ui_density "default") - set(ui_density_variable "LVGL_UI_DENSITY_DEFAULT") - message("UI density set to default: ${ui_density}") -endif () # Read the theme: whether the device uses dark mode by default GET_VALUE_FROM_MAP(device_properties "lvgl.theme" lvgl_theme) if (NOT lvgl_theme OR lvgl_theme STREQUAL "DefaultDark") @@ -82,8 +65,6 @@ tactility_get_module_name("lvgl-module" MODULE_NAME) target_compile_definitions(${MODULE_NAME} PUBLIC # Ensure it loads "-DLV_LVGL_H_INCLUDE_SIMPLE" - # UiDensity - "-DTT_LVGL_UI_DENSITY=${ui_density_variable}" # Default theme mode: 1 for dark, 0 for light "-DTT_LVGL_THEME_DARK=${theme_dark}" ) diff --git a/Modules/lvgl-module/include/lvgl/lvgl.h b/Modules/lvgl-module/include/lvgl/lvgl.h index b083a0599..02db29b54 100644 --- a/Modules/lvgl-module/include/lvgl/lvgl.h +++ b/Modules/lvgl-module/include/lvgl/lvgl.h @@ -59,10 +59,10 @@ bool lvgl_is_running(void); /** * @brief Gets the UI density of the theme settings. - * @deprecated Use lvgl_theme_is_compact() from + * @deprecated Use lvgl_theme_get_size() and lvgl_theme_get_spacing() from * @return the UI density */ -__attribute__((deprecated("Use lvgl_theme_is_compact()"))) +__attribute__((deprecated("Use lvgl_theme_get_size() and lvgl_theme_get_spacing()"))) enum UiDensity lvgl_get_ui_density(void); #ifdef __cplusplus diff --git a/Modules/lvgl-module/include/lvgl/theme.h b/Modules/lvgl-module/include/lvgl/theme.h index 2234d2ebc..051ea8461 100644 --- a/Modules/lvgl-module/include/lvgl/theme.h +++ b/Modules/lvgl-module/include/lvgl/theme.h @@ -17,8 +17,10 @@ typedef struct { lv_color_t color_primary; lv_color_t color_secondary; lv_color_t color_error; - /** Smaller spacing and controls, for small screens (see lvgl_theme_is_compact()) */ - bool is_compact; + /** Padding and gaps from 0 (compact) to 100 (regular) */ + uint8_t spacing; + /** Control sizes, corner radii and borders from 0 (compact) to 100 (regular) */ + uint8_t size; /** Use color_surface as the surface (screen background) colour, instead of the theme's own. Not used by the monochrome theme. */ bool surface_override; lv_color_t color_surface; @@ -39,9 +41,10 @@ void lvgl_theme_get_default_settings(LvglThemeSettings* settings); /** * @brief Overrides the compile-time default UI density, for devices that only know it at runtime. * Call before the theme settings are read. - * @param[in] is_compact true for the compact density + * @param[in] spacing 0 (compact) to 100 (regular) + * @param[in] size 0 (compact) to 100 (regular) */ -void lvgl_theme_set_default_compact(bool is_compact); +void lvgl_theme_set_default_density(uint8_t spacing, uint8_t size); /** * @brief Checks whether the active theme is the monochrome theme: on monochrome and greyscale displays, or when selected in the settings. @@ -50,11 +53,28 @@ void lvgl_theme_set_default_compact(bool is_compact); bool lvgl_theme_is_mono(void); /** - * @brief Checks whether the theme settings use the compact UI density: smaller spacing and controls, for small screens. - * The device's default density is defined in its `device.properties`. - * @return true when the UI is compact + * @brief Gets the spacing of the theme settings: padding and gaps. + * The device's default is defined by the UI density in its `device.properties`. + * @return 0 (compact) to 100 (regular) */ -bool lvgl_theme_is_compact(void); +uint8_t lvgl_theme_get_spacing(void); + +/** + * @brief Gets the size of the theme settings: control sizes, corner radii and borders. + * The device's default is defined by the UI density in its `device.properties`. + * @return 0 (compact) to 100 (regular) + */ +uint8_t lvgl_theme_get_size(void); + +/** + * @brief Interpolates between a compact and a regular value. + * @param[in] compact the value at 0 + * @param[in] regular the value at 100 + * @param[in] percent 0 to 100, e.g. from lvgl_theme_get_spacing() or lvgl_theme_get_size() + */ +static inline int32_t lvgl_theme_lerp(int32_t compact, int32_t regular, uint8_t percent) { + return compact + (regular - compact) * (int32_t)percent / 100; +} /** * @brief Sets the theme settings. They are used for displays that are added afterwards, e.g. when LVGL (re)starts. diff --git a/Modules/lvgl-module/source/lvgl.c b/Modules/lvgl-module/source/lvgl.c index 19310983b..a3d85ae05 100644 --- a/Modules/lvgl-module/source/lvgl.c +++ b/Modules/lvgl-module/source/lvgl.c @@ -3,5 +3,5 @@ #include enum UiDensity lvgl_get_ui_density(void) { - return lvgl_theme_is_compact() ? LVGL_UI_DENSITY_COMPACT : LVGL_UI_DENSITY_DEFAULT; + return lvgl_theme_get_size() < 50 ? LVGL_UI_DENSITY_COMPACT : LVGL_UI_DENSITY_DEFAULT; } diff --git a/Modules/lvgl-module/source/symbols.c b/Modules/lvgl-module/source/symbols.c index 8635f180e..f1dab2b55 100644 --- a/Modules/lvgl-module/source/symbols.c +++ b/Modules/lvgl-module/source/symbols.c @@ -117,7 +117,8 @@ const struct ModuleSymbol lvgl_module_symbols[] = { DEFINE_MODULE_SYMBOL(lvgl_theme_get_default_settings), DEFINE_MODULE_SYMBOL(lvgl_theme_set_settings), DEFINE_MODULE_SYMBOL(lvgl_theme_is_mono), - DEFINE_MODULE_SYMBOL(lvgl_theme_is_compact), + DEFINE_MODULE_SYMBOL(lvgl_theme_get_spacing), + DEFINE_MODULE_SYMBOL(lvgl_theme_get_size), // lvgl_badge DEFINE_MODULE_SYMBOL(lvgl_badge_create), // lvgl_focus_container diff --git a/Modules/lvgl-module/source/theme.cpp b/Modules/lvgl-module/source/theme.cpp index 521a929f8..799ced3b3 100644 --- a/Modules/lvgl-module/source/theme.cpp +++ b/Modules/lvgl-module/source/theme.cpp @@ -18,7 +18,16 @@ enum class ActiveTheme { static LvglThemeSettings theme_settings; static bool theme_settings_set = false; -static bool default_compact = TT_LVGL_UI_DENSITY == LVGL_UI_DENSITY_COMPACT; +#ifdef CONFIG_TT_UI_SPACING +static uint8_t default_spacing = CONFIG_TT_UI_SPACING; +#else +static uint8_t default_spacing = 100; +#endif +#ifdef CONFIG_TT_UI_SIZE +static uint8_t default_size = CONFIG_TT_UI_SIZE; +#else +static uint8_t default_size = 100; +#endif static ActiveTheme active_theme = ActiveTheme::None; void lvgl_theme_get_default_settings(LvglThemeSettings* settings) { @@ -30,13 +39,15 @@ void lvgl_theme_get_default_settings(LvglThemeSettings* settings) { settings->color_primary = material_config.color_primary; settings->color_secondary = material_config.color_secondary; settings->color_error = material_config.color_error; - settings->is_compact = default_compact; + settings->spacing = default_spacing; + settings->size = default_size; settings->surface_override = material_config.surface_override; settings->color_surface = material_config.color_surface; } -void lvgl_theme_set_default_compact(bool is_compact) { - default_compact = is_compact; +void lvgl_theme_set_default_density(uint8_t spacing, uint8_t size) { + default_spacing = spacing; + default_size = size; } void lvgl_theme_get_settings(LvglThemeSettings* settings) { @@ -56,10 +67,16 @@ bool lvgl_theme_is_mono() { return active_theme == ActiveTheme::MaterialMono; } -bool lvgl_theme_is_compact() { +uint8_t lvgl_theme_get_spacing() { LvglThemeSettings settings; lvgl_theme_get_settings(&settings); - return settings.is_compact; + return settings.spacing; +} + +uint8_t lvgl_theme_get_size() { + LvglThemeSettings settings; + lvgl_theme_get_settings(&settings); + return settings.size; } static lv_theme_t* get_active_theme() { @@ -90,7 +107,6 @@ void lvgl_theme_deinit() { lv_theme_t* lvgl_theme_init_for_display(lv_display_t* display, bool isMonoDisplay, bool supportsAnimations) { LvglThemeSettings settings; lvgl_theme_get_settings(&settings); - const bool is_compact = settings.is_compact; const bool animations_enabled = settings.animations_enabled && supportsAnimations; const ActiveTheme required_theme = (isMonoDisplay || settings.is_mono) ? ActiveTheme::MaterialMono : ActiveTheme::Material; @@ -106,14 +122,16 @@ lv_theme_t* lvgl_theme_init_for_display(lv_display_t* display, bool isMonoDispla lv_theme_material_mono_config_t config; lv_theme_material_mono_config_init(&config); config.is_dark = settings.is_dark; - config.is_compact = is_compact; + config.spacing = settings.spacing; + config.size = settings.size; config.animations_enabled = animations_enabled; theme = lv_theme_material_mono_init(display, &config); } else { lv_theme_material_config_t config; lv_theme_material_config_init(&config); config.is_dark = settings.is_dark; - config.is_compact = is_compact; + config.spacing = settings.spacing; + config.size = settings.size; config.animations_enabled = animations_enabled; config.color_primary = settings.color_primary; config.color_secondary = settings.color_secondary; diff --git a/Modules/lvgl-module/source/themes/lv_theme_material.c b/Modules/lvgl-module/source/themes/lv_theme_material.c index 07893d0db..e63e94f53 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material.c +++ b/Modules/lvgl-module/source/themes/lv_theme_material.c @@ -94,30 +94,30 @@ extern const lv_obj_class_t lvgl_text_button_class; #define COLOR_INVERSE_SURFACE (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_90 : NEUTRAL_20, TINT_NEUTRAL)) #define COLOR_INVERSE_ON_SURFACE (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_20 : NEUTRAL_95, TINT_NEUTRAL)) #define COLOR_SCRIM lv_color_black() -#define SHAPE_CONTROL (theme->config.is_compact ? CORNER_COMPACT : CORNER_FULL) -#define SHAPE_CONTAINER (theme->config.is_compact ? CORNER_COMPACT : CORNER_MEDIUM) -#define SHAPE_FIELD (theme->config.is_compact ? CORNER_COMPACT : CORNER_EXTRA_SMALL) -#define SHAPE_KEY (theme->config.is_compact ? CORNER_COMPACT : CORNER_SMALL) -#define SHAPE_DIALOG (theme->config.is_compact ? CORNER_COMPACT : CORNER_EXTRA_LARGE) -#define SHAPE_MENU (theme->config.is_compact ? CORNER_COMPACT : CORNER_EXTRA_SMALL) -#define DENSITY_CONTAINER_PAD (theme->config.is_compact ? 2 : SPACE_4) -#define DENSITY_CONTAINER_GAP (theme->config.is_compact ? 2 : SPACE_3) -#define DENSITY_CONTAINER_BORDER (theme->config.is_compact ? 1 : 0) -#define DENSITY_CONTROL_PAD_HOR (theme->config.is_compact ? 3 : SPACE_6) -#define DENSITY_CONTROL_PAD_VER (theme->config.is_compact ? 3 : LV_DPX_CALC(theme->disp_dpi, 10)) -#define DENSITY_CONTROL_GAP (theme->config.is_compact ? 2 : SPACE_2) -#define DENSITY_FIELD_PAD_HOR (theme->config.is_compact ? 2 : SPACE_4) -#define DENSITY_FIELD_PAD_VER (theme->config.is_compact ? 2 : SPACE_2) -#define DENSITY_LIST_PAD (theme->config.is_compact ? 2 : SPACE_2) -#define DENSITY_LIST_ITEM_PAD_HOR (theme->config.is_compact ? 2 : SPACE_4) -#define DENSITY_LIST_ITEM_PAD_VER (theme->config.is_compact ? 2 : SPACE_3) -#define DENSITY_DIALOG_PAD (theme->config.is_compact ? 2 : SPACE_6) -#define DENSITY_KEY_GAP (theme->config.is_compact ? 2 : SPACE_1) +#define SHAPE_CONTROL (theme->config.size == 100 ? CORNER_FULL : CORNER_COMPACT + (((CORNER_EXTRA_LARGE - CORNER_COMPACT) * theme->config.size) / 100)) +#define SHAPE_CONTAINER (CORNER_COMPACT + (((CORNER_MEDIUM - CORNER_COMPACT) * theme->config.size) / 100)) +#define SHAPE_FIELD (CORNER_COMPACT + (((CORNER_EXTRA_SMALL - CORNER_COMPACT) * theme->config.size) / 100)) +#define SHAPE_KEY (CORNER_COMPACT + (((CORNER_SMALL - CORNER_COMPACT) * theme->config.size) / 100)) +#define SHAPE_DIALOG (CORNER_COMPACT + (((CORNER_EXTRA_LARGE - CORNER_COMPACT) * theme->config.size) / 100)) +#define SHAPE_MENU (CORNER_COMPACT + (((CORNER_EXTRA_SMALL - CORNER_COMPACT) * theme->config.size) / 100)) +#define DENSITY_CONTAINER_PAD (2 + (((SPACE_4 - 2) * theme->config.spacing) / 100)) +#define DENSITY_CONTAINER_GAP (2 + (((SPACE_3 - 2) * theme->config.spacing) / 100)) +#define DENSITY_CONTAINER_BORDER (1 + (((0 - 1) * theme->config.size) / 100)) +#define DENSITY_CONTROL_PAD_HOR (2 + (((SPACE_6 - 2) * theme->config.size) / 100)) +#define DENSITY_CONTROL_PAD_VER (1 + (((LV_DPX_CALC(theme->disp_dpi, 10) - 1) * theme->config.size) / 100)) +#define DENSITY_CONTROL_GAP (2 + (((SPACE_2 - 2) * theme->config.spacing) / 100)) +#define DENSITY_FIELD_PAD_HOR (2 + (((SPACE_4 - 2) * theme->config.spacing) / 100)) +#define DENSITY_FIELD_PAD_VER (2 + (((SPACE_2 - 2) * theme->config.spacing) / 100)) +#define DENSITY_LIST_PAD (2 + (((SPACE_2 - 2) * theme->config.spacing) / 100)) +#define DENSITY_LIST_ITEM_PAD_HOR (2 + (((SPACE_4 - 2) * theme->config.spacing) / 100)) +#define DENSITY_LIST_ITEM_PAD_VER (2 + (((SPACE_3 - 2) * theme->config.spacing) / 100)) +#define DENSITY_DIALOG_PAD (2 + (((SPACE_6 - 2) * theme->config.spacing) / 100)) +#define DENSITY_KEY_GAP (2 + (((SPACE_1 - 2) * theme->config.spacing) / 100)) #define DENSITY_BORDER (LV_DPX_CALC(theme->disp_dpi, 1)) #define FOCUS_COLOR COLOR_SECONDARY -#define FOCUS_WIDTH (theme->config.is_compact ? 1 : LV_DPX_CALC(theme->disp_dpi, 3)) -#define FOCUS_OFFSET (theme->config.is_compact ? 1 : LV_DPX_CALC(theme->disp_dpi, 2)) -#define FOCUS_INSET_WIDTH (theme->config.is_compact ? 1 : LV_DPX_CALC(theme->disp_dpi, 2)) +#define FOCUS_WIDTH (1 + (((LV_DPX_CALC(theme->disp_dpi, 3) - 1) * theme->config.size) / 100)) +#define FOCUS_OFFSET (1 + (((LV_DPX_CALC(theme->disp_dpi, 2) - 1) * theme->config.size) / 100)) +#define FOCUS_INSET_WIDTH (1 + (((LV_DPX_CALC(theme->disp_dpi, 2) - 1) * theme->config.size) / 100)) #define EDIT_COLOR COLOR_PRIMARY #define STATE_PRESSED_ON_PRIMARY (lv_color_mix(COLOR_ON_PRIMARY, COLOR_PRIMARY, OPACITY_STATE_PRESSED)) #define STATE_PRESSED_ON_SECONDARY (lv_color_mix(COLOR_ON_SECONDARY, COLOR_SECONDARY, OPACITY_STATE_PRESSED)) @@ -146,24 +146,24 @@ extern const lv_obj_class_t lvgl_text_button_class; #define CHIP_BORDER_COLOR COLOR_OUTLINE #define CHIP_SELECTED_COLOR COLOR_SECONDARY_CONTAINER #define CHIP_SELECTED_LABEL_COLOR COLOR_ON_SECONDARY_CONTAINER -#define CHIP_SHAPE (theme->config.is_compact ? CORNER_COMPACT : CORNER_SMALL) -#define CHIP_PAD_HOR (theme->config.is_compact ? 3 : SPACE_4) -#define CHIP_PAD_VER (theme->config.is_compact ? 2 : LV_DPX_CALC(theme->disp_dpi, 6)) +#define CHIP_SHAPE (CORNER_COMPACT + (((CORNER_SMALL - CORNER_COMPACT) * theme->config.size) / 100)) +#define CHIP_PAD_HOR (3 + (((SPACE_4 - 3) * theme->config.spacing) / 100)) +#define CHIP_PAD_VER (2 + (((LV_DPX_CALC(theme->disp_dpi, 6) - 2) * theme->config.spacing) / 100)) #define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE_VARIANT -#define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 6 : LV_DPX_CALC(theme->disp_dpi, 8)) -#define PAGE_INDICATOR_GAP (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) +#define PAGE_INDICATOR_DOT_SIZE (6 + (((LV_DPX_CALC(theme->disp_dpi, 8) - 6) * theme->config.size) / 100)) +#define PAGE_INDICATOR_GAP (4 + (((LV_DPX_CALC(theme->disp_dpi, 6) - 4) * theme->config.spacing) / 100)) #define STEP_INDICATOR_FINISHED_COLOR COLOR_SECONDARY #define STEP_INDICATOR_UNFINISHED_COLOR (lv_color_mix(lv_color_black(), COLOR_SECONDARY, LV_OPA_50)) #define STEP_INDICATOR_THICKNESS (LV_DPX_CALC(theme->disp_dpi, 6)) #define STEP_INDICATOR_MARGIN (LV_DPX_CALC(theme->disp_dpi, 6)) #define STEP_INDICATOR_GAP STEP_INDICATOR_MARGIN #define BADGE_COLOR COLOR_SECONDARY -#define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) +#define BADGE_SIZE (4 + (((LV_DPX_CALC(theme->disp_dpi, 6) - 4) * theme->config.size) / 100)) #define SCROLLBAR_COLOR COLOR_OUTLINE #define SCROLLBAR_OPACITY (LV_OPA_40) #define SCROLLBAR_SCROLLED_OPACITY (LV_OPA_70) -#define SCROLLBAR_WIDTH (theme->config.is_compact ? 2 : SPACE_1) -#define SCROLLBAR_PAD (theme->config.is_compact ? 1 : SPACE_1) +#define SCROLLBAR_WIDTH (2 + (((SPACE_1 - 2) * theme->config.size) / 100)) +#define SCROLLBAR_PAD (1 + (((SPACE_1 - 1) * theme->config.spacing) / 100)) #define BUTTON_CONTAINER_COLOR COLOR_PRIMARY #define BUTTON_LABEL_COLOR COLOR_ON_PRIMARY #define BUTTON_PRESSED_CONTAINER_COLOR STATE_PRESSED_ON_PRIMARY @@ -180,9 +180,9 @@ extern const lv_obj_class_t lvgl_text_button_class; #define BUTTON_GAP DENSITY_CONTROL_GAP #define TEXT_BUTTON_LABEL_COLOR COLOR_PRIMARY #define TEXT_BUTTON_PRESSED_CONTAINER_COLOR COLOR_PRIMARY -#define TEXT_BUTTON_PAD_HOR (theme->config.is_compact ? 3 : SPACE_3) +#define TEXT_BUTTON_PAD_HOR (2 + (((SPACE_3 - 2) * theme->config.size) / 100)) #define ICON_BUTTON_COLOR COLOR_ON_SURFACE -#define ICON_BUTTON_CONTAINER_SHAPE (theme->config.is_compact ? CORNER_COMPACT : CORNER_LARGE) +#define ICON_BUTTON_CONTAINER_SHAPE (CORNER_COMPACT + (((CORNER_LARGE - CORNER_COMPACT) * theme->config.size) / 100)) #define ICON_BUTTON_FILLED_COLOR COLOR_PRIMARY #define ICON_BUTTON_FILLED_ICON_COLOR COLOR_ON_PRIMARY #define ICON_BUTTON_FILLED_PRESSED_COLOR STATE_PRESSED_ON_PRIMARY @@ -192,60 +192,60 @@ extern const lv_obj_class_t lvgl_text_button_class; #define APP_BAR_COLOR COLOR_SURFACE_CONTAINER_LOW #define APP_BAR_CONTENT_COLOR COLOR_ON_SURFACE #define APP_BAR_PRESSED_OPACITY (LV_OPA_20) -#define APP_BAR_PAD_HOR (theme->config.is_compact ? 0 : SPACE_1) -#define APP_BAR_PAD_RIGHT (theme->config.is_compact ? APP_BAR_PAD_HOR + 2 : APP_BAR_PAD_HOR) -#define APP_BAR_GAP (theme->config.is_compact ? 2 : SPACE_1) +#define APP_BAR_PAD_HOR (0 + (((SPACE_1 - 0) * theme->config.spacing) / 100)) +#define APP_BAR_PAD_RIGHT ((APP_BAR_PAD_HOR + 2) + (((APP_BAR_PAD_HOR - (APP_BAR_PAD_HOR + 2)) * theme->config.spacing) / 100)) +#define APP_BAR_GAP (2 + (((SPACE_1 - 2) * theme->config.spacing) / 100)) #define APP_BAR_FOCUS_WIDTH FOCUS_INSET_WIDTH #define FOCUS_RING_COLOR FOCUS_COLOR #define FOCUS_RING_WIDTH FOCUS_WIDTH #define FOCUS_RING_OFFSET FOCUS_OFFSET #define EDIT_RING_COLOR EDIT_COLOR -#define SWITCH_WIDTH (theme->config.is_compact ? 25 : LV_DPX_CALC(theme->disp_dpi, 52)) -#define SWITCH_HEIGHT (theme->config.is_compact ? 15 : LV_DPX_CALC(theme->disp_dpi, 32)) +#define SWITCH_WIDTH (25 + (((LV_DPX_CALC(theme->disp_dpi, 52) - 25) * theme->config.size) / 100)) +#define SWITCH_HEIGHT (15 + (((LV_DPX_CALC(theme->disp_dpi, 32) - 15) * theme->config.size) / 100)) #define SWITCH_TRACK_COLOR COLOR_SURFACE_CONTAINER_HIGHEST #define SWITCH_TRACK_BORDER_COLOR COLOR_OUTLINE -#define SWITCH_TRACK_BORDER_WIDTH (theme->config.is_compact ? 1 : LV_DPX_CALC(theme->disp_dpi, 2)) +#define SWITCH_TRACK_BORDER_WIDTH (1 + (((LV_DPX_CALC(theme->disp_dpi, 2) - 1) * theme->config.size) / 100)) #define SWITCH_TRACK_SELECTED_COLOR COLOR_PRIMARY #define SWITCH_HANDLE_COLOR COLOR_OUTLINE #define SWITCH_HANDLE_SELECTED_COLOR COLOR_ON_PRIMARY -#define SWITCH_HANDLE_PAD (theme->config.is_compact ? -3 : -LV_DPX_CALC(theme->disp_dpi, 8)) -#define SWITCH_HANDLE_SELECTED_PAD (theme->config.is_compact ? -2 : -LV_DPX_CALC(theme->disp_dpi, 4)) -#define SWITCH_HANDLE_PRESSED_PAD (theme->config.is_compact ? -1 : -LV_DPX_CALC(theme->disp_dpi, 2)) -#define CHECKBOX_GAP (theme->config.is_compact ? 3 : SPACE_3) +#define SWITCH_HANDLE_PAD ((-3) + ((((-LV_DPX_CALC(theme->disp_dpi, 8)) - (-3)) * theme->config.size) / 100)) +#define SWITCH_HANDLE_SELECTED_PAD ((-2) + ((((-LV_DPX_CALC(theme->disp_dpi, 4)) - (-2)) * theme->config.size) / 100)) +#define SWITCH_HANDLE_PRESSED_PAD ((-1) + ((((-LV_DPX_CALC(theme->disp_dpi, 2)) - (-1)) * theme->config.size) / 100)) +#define CHECKBOX_GAP (3 + (((SPACE_3 - 3) * theme->config.spacing) / 100)) #define CHECKBOX_BOX_COLOR COLOR_SURFACE #define CHECKBOX_BOX_OPACITY LV_OPA_TRANSP #define CHECKBOX_BOX_BORDER_COLOR COLOR_ON_SURFACE_VARIANT -#define CHECKBOX_BOX_BORDER_WIDTH (theme->config.is_compact ? 1 : LV_DPX_CALC(theme->disp_dpi, 2)) -#define CHECKBOX_BOX_SHAPE (theme->config.is_compact ? 2 : LV_DPX_CALC(theme->disp_dpi, 2)) -#define CHECKBOX_BOX_PAD (theme->config.is_compact ? 1 : LV_DPX_CALC(theme->disp_dpi, 2)) +#define CHECKBOX_BOX_BORDER_WIDTH (1 + (((LV_DPX_CALC(theme->disp_dpi, 2) - 1) * theme->config.size) / 100)) +#define CHECKBOX_BOX_SHAPE (2 + (((LV_DPX_CALC(theme->disp_dpi, 2) - 2) * theme->config.size) / 100)) +#define CHECKBOX_BOX_PAD (1 + (((LV_DPX_CALC(theme->disp_dpi, 2) - 1) * theme->config.size) / 100)) #define CHECKBOX_BOX_SELECTED_COLOR COLOR_PRIMARY #define CHECKBOX_ICON_COLOR COLOR_ON_PRIMARY #define SLIDER_TRACK_COLOR COLOR_SECONDARY_CONTAINER #define SLIDER_ACTIVE_TRACK_COLOR COLOR_PRIMARY #define SLIDER_HANDLE_COLOR COLOR_PRIMARY #define SLIDER_TRACK_HEIGHT (LV_DPX_CALC(theme->disp_dpi, 8)) -#define SLIDER_HANDLE_PAD ((theme->config.is_compact ? 2 : LV_DPX_CALC(theme->disp_dpi, 6)) + LV_DPX_CALC(theme->disp_dpi, 4)) -#define SLIDERBOX_GAP (theme->config.is_compact ? 4 : ((SLIDER_TRACK_HEIGHT / 2) + SLIDER_HANDLE_PAD) + SPACE_2) +#define SLIDER_HANDLE_PAD ((2 + (((LV_DPX_CALC(theme->disp_dpi, 6) - 2) * theme->config.size) / 100)) + LV_DPX_CALC(theme->disp_dpi, 4)) +#define SLIDERBOX_GAP (4 + ((((((SLIDER_TRACK_HEIGHT / 2) + SLIDER_HANDLE_PAD) + SPACE_2) - 4) * theme->config.spacing) / 100)) #define PROGRESS_TRACK_COLOR COLOR_SECONDARY_CONTAINER #define PROGRESS_INDICATOR_COLOR COLOR_PRIMARY #define ARC_TRACK_COLOR COLOR_SECONDARY_CONTAINER #define ARC_INDICATOR_COLOR COLOR_PRIMARY -#define ARC_WIDTH (theme->config.is_compact ? 3 : SPACE_1) +#define ARC_WIDTH (3 + (((SPACE_1 - 3) * theme->config.size) / 100)) #define ARC_HANDLE_COLOR COLOR_PRIMARY -#define ARC_HANDLE_PAD (theme->config.is_compact ? 2 : SPACE_1) +#define ARC_HANDLE_PAD (2 + (((SPACE_1 - 2) * theme->config.size) / 100)) #define FIELD_COLOR COLOR_SURFACE #define FIELD_OPACITY LV_OPA_TRANSP #define FIELD_TEXT_COLOR COLOR_ON_SURFACE #define FIELD_BORDER_COLOR COLOR_OUTLINE #define FIELD_BORDER_WIDTH DENSITY_BORDER #define FIELD_FOCUSED_BORDER_COLOR COLOR_PRIMARY -#define FIELD_FOCUSED_BORDER_WIDTH (theme->config.is_compact ? 1 : LV_DPX_CALC(theme->disp_dpi, 2)) +#define FIELD_FOCUSED_BORDER_WIDTH (1 + (((LV_DPX_CALC(theme->disp_dpi, 2) - 1) * theme->config.size) / 100)) #define FIELD_SHAPE SHAPE_FIELD #define FIELD_PAD_HOR DENSITY_FIELD_PAD_HOR #define FIELD_PAD_VER DENSITY_FIELD_PAD_VER #define FIELD_PLACEHOLDER_COLOR COLOR_ON_SURFACE_VARIANT #define FIELD_CURSOR_COLOR COLOR_PRIMARY -#define FIELD_CURSOR_WIDTH (theme->config.is_compact ? 1 : LV_DPX_CALC(theme->disp_dpi, 2)) +#define FIELD_CURSOR_WIDTH (1 + (((LV_DPX_CALC(theme->disp_dpi, 2) - 1) * theme->config.size) / 100)) #define FIELD_SELECTION_COLOR COLOR_PRIMARY_CONTAINER #define FIELD_SELECTION_TEXT_COLOR COLOR_ON_PRIMARY_CONTAINER #define SPINBOX_CURSOR_COLOR COLOR_PRIMARY @@ -253,16 +253,16 @@ extern const lv_obj_class_t lvgl_text_button_class; #define MENU_COLOR COLOR_SURFACE_CONTAINER #define MENU_TEXT_COLOR COLOR_ON_SURFACE #define MENU_SHAPE SHAPE_MENU -#define MENU_PAD (theme->config.is_compact ? 1 : SPACE_2) +#define MENU_PAD (1 + (((SPACE_2 - 1) * theme->config.spacing) / 100)) #define MENU_MAX_HEIGHT (LV_DPI_DEF * 2) #define MENU_ITEM_SELECTED_COLOR COLOR_SECONDARY_CONTAINER #define MENU_ITEM_SELECTED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER -#define MENU_ITEM_LINE_SPACE (theme->config.is_compact ? 4 : SPACE_4) +#define MENU_ITEM_LINE_SPACE (4 + (((SPACE_4 - 4) * theme->config.spacing) / 100)) #define ROLLER_COLOR COLOR_SURFACE_CONTAINER #define ROLLER_TEXT_COLOR COLOR_ON_SURFACE_VARIANT #define ROLLER_SELECTED_COLOR COLOR_SECONDARY_CONTAINER #define ROLLER_SELECTED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER -#define ROLLER_LINE_SPACE (theme->config.is_compact ? 4 : SPACE_4) +#define ROLLER_LINE_SPACE (4 + (((SPACE_4 - 4) * theme->config.spacing) / 100)) #define KEYS_COLOR COLOR_SURFACE_CONTAINER #define KEYS_PAD DENSITY_KEY_GAP #define KEYS_GAP DENSITY_KEY_GAP @@ -280,7 +280,7 @@ extern const lv_obj_class_t lvgl_text_button_class; #define LIST_ITEM_TEXT_COLOR COLOR_ON_SURFACE #define LIST_ITEM_PAD_HOR DENSITY_LIST_ITEM_PAD_HOR #define LIST_ITEM_PAD_VER DENSITY_LIST_ITEM_PAD_VER -#define LIST_ITEM_GAP (theme->config.is_compact ? 3 : SPACE_4) +#define LIST_ITEM_GAP (3 + (((SPACE_4 - 3) * theme->config.spacing) / 100)) #define LIST_ITEM_FOCUSED_COLOR COLOR_SECONDARY_CONTAINER #define LIST_ITEM_FOCUSED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER #define LIST_ITEM_SELECTED_COLOR COLOR_SECONDARY_CONTAINER @@ -289,10 +289,10 @@ extern const lv_obj_class_t lvgl_text_button_class; #define LIST_DIVIDER_WIDTH DENSITY_BORDER #define LIST_SUBHEADER_TEXT_COLOR COLOR_ON_SURFACE_VARIANT #define TABS_COLOR CARD_COLOR -#define TABS_PAD (theme->config.is_compact ? 2 : SPACE_1) -#define TABS_GAP (theme->config.is_compact ? 2 : SPACE_1) +#define TABS_PAD (2 + (((SPACE_1 - 2) * theme->config.spacing) / 100)) +#define TABS_GAP (2 + (((SPACE_1 - 2) * theme->config.spacing) / 100)) #define TAB_LABEL_COLOR COLOR_ON_SURFACE_VARIANT -#define TAB_PAD (theme->config.is_compact ? 2 : SPACE_1) +#define TAB_PAD (2 + (((SPACE_1 - 2) * theme->config.spacing) / 100)) #define TAB_SHAPE SHAPE_CONTROL #define TAB_SELECTED_COLOR COLOR_SECONDARY_CONTAINER #define TAB_SELECTED_LABEL_COLOR COLOR_ON_SECONDARY_CONTAINER @@ -302,7 +302,7 @@ extern const lv_obj_class_t lvgl_text_button_class; #define DIALOG_TITLE_COLOR COLOR_ON_SURFACE #define DIALOG_SHAPE SHAPE_DIALOG #define DIALOG_PAD DENSITY_DIALOG_PAD -#define DIALOG_GAP (theme->config.is_compact ? 2 : SPACE_2) +#define DIALOG_GAP (2 + (((SPACE_2 - 2) * theme->config.spacing) / 100)) #define DIALOG_SCRIM_COLOR COLOR_SCRIM #define DIALOG_SCRIM_OPACITY OPACITY_SCRIM #define SETTINGS_COLOR COLOR_SURFACE @@ -315,29 +315,29 @@ extern const lv_obj_class_t lvgl_text_button_class; #define SETTINGS_ITEM_SELECTED_COLOR COLOR_SECONDARY_CONTAINER #define SETTINGS_ITEM_SELECTED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER #define WINDOW_HEADER_COLOR COLOR_SURFACE_CONTAINER -#define WINDOW_HEADER_PAD (theme->config.is_compact ? 2 : SPACE_2) +#define WINDOW_HEADER_PAD (2 + (((SPACE_2 - 2) * theme->config.spacing) / 100)) #define WINDOW_CONTENT_PAD DENSITY_CONTAINER_PAD #define TABLE_COLOR COLOR_SURFACE #define TABLE_CELL_TEXT_COLOR COLOR_ON_SURFACE -#define TABLE_CELL_PAD (theme->config.is_compact ? 2 : SPACE_3) +#define TABLE_CELL_PAD (2 + (((SPACE_3 - 2) * theme->config.spacing) / 100)) #define TABLE_DIVIDER_COLOR COLOR_OUTLINE_VARIANT #define CHART_COLOR COLOR_SURFACE_CONTAINER_LOW #define CHART_GRID_COLOR COLOR_OUTLINE_VARIANT -#define CHART_SERIES_WIDTH (theme->config.is_compact ? 1 : LV_DPX_CALC(theme->disp_dpi, 2)) -#define CHART_POINT_SIZE (theme->config.is_compact ? 3 : LV_DPX_CALC(theme->disp_dpi, 6)) +#define CHART_SERIES_WIDTH (1 + (((LV_DPX_CALC(theme->disp_dpi, 2) - 1) * theme->config.size) / 100)) +#define CHART_POINT_SIZE (3 + (((LV_DPX_CALC(theme->disp_dpi, 6) - 3) * theme->config.size) / 100)) #define CHART_INDICATOR_COLOR COLOR_PRIMARY #define CALENDAR_COLOR COLOR_SURFACE_CONTAINER_HIGH -#define CALENDAR_SHAPE (theme->config.is_compact ? CORNER_COMPACT : CORNER_LARGE) -#define CALENDAR_PAD (theme->config.is_compact ? 2 : SPACE_2) +#define CALENDAR_SHAPE (CORNER_COMPACT + (((CORNER_LARGE - CORNER_COMPACT) * theme->config.size) / 100)) +#define CALENDAR_PAD (2 + (((SPACE_2 - 2) * theme->config.spacing) / 100)) #define CALENDAR_DAY_TEXT_COLOR COLOR_ON_SURFACE -#define CALENDAR_HEADER_PAD (theme->config.is_compact ? 2 : SPACE_3) +#define CALENDAR_HEADER_PAD (2 + (((SPACE_3 - 2) * theme->config.spacing) / 100)) #define SCALE_COLOR COLOR_ON_SURFACE_VARIANT #define SCALE_WIDTH DENSITY_BORDER -#define SCALE_TICK_LENGTH (theme->config.is_compact ? 3 : LV_DPX_CALC(theme->disp_dpi, 6)) +#define SCALE_TICK_LENGTH (3 + (((LV_DPX_CALC(theme->disp_dpi, 6) - 3) * theme->config.size) / 100)) #define DIVIDER_COLOR COLOR_OUTLINE_VARIANT #define DIVIDER_WIDTH DENSITY_BORDER -#define LED_GLOW_WIDTH (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 15)) -#define LED_GLOW_SPREAD (theme->config.is_compact ? 1 : LV_DPX_CALC(theme->disp_dpi, 5)) +#define LED_GLOW_WIDTH (4 + (((LV_DPX_CALC(theme->disp_dpi, 15) - 4) * theme->config.size) / 100)) +#define LED_GLOW_SPREAD (1 + (((LV_DPX_CALC(theme->disp_dpi, 5) - 1) * theme->config.size) / 100)) /********************** * TYPEDEFS @@ -670,7 +670,8 @@ static inline bool lss_is_child(lv_obj_t * parent, int32_t index, lv_obj_t * chi static bool config_equals(const lv_theme_material_config_t * a, const lv_theme_material_config_t * b) { return a->is_dark == b->is_dark && - a->is_compact == b->is_compact && + a->spacing == b->spacing && + a->size == b->size && a->animations_enabled == b->animations_enabled && lv_color_eq(a->color_primary, b->color_primary) && lv_color_eq(a->color_secondary, b->color_secondary) && @@ -942,12 +943,12 @@ static void style_init(my_theme_t * theme) lv_style_set_pad_gap(&theme->styles.app_bar, APP_BAR_GAP); style_init_reset(&theme->styles.app_bar_nav_button); - if(theme->config.is_compact) { - lv_style_set_margin_left(&theme->styles.app_bar_nav_button, 4); + if(theme->config.spacing != 100) { + lv_style_set_margin_left(&theme->styles.app_bar_nav_button, 4 - ((4 * theme->config.spacing) / 100)); } style_init_reset(&theme->styles.app_bar_button_focus_ring); - if(theme->config.is_compact) { + if((theme->config.size / 50) == 0) { lv_style_set_outline_pad(&theme->styles.app_bar_button_focus_ring, 0); } @@ -1541,7 +1542,8 @@ void lv_theme_material_config_init(lv_theme_material_config_t * config) { lv_memzero(config, sizeof(*config)); config->is_dark = false; - config->is_compact = false; + config->spacing = 100; + config->size = 100; config->animations_enabled = true; config->color_primary = lv_color_hex(0x00BBFF); config->color_secondary = lv_color_hex(0xEE81C3); diff --git a/Modules/lvgl-module/source/themes/lv_theme_material.h b/Modules/lvgl-module/source/themes/lv_theme_material.h index 182d2bbcf..470dc4a0c 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material.h +++ b/Modules/lvgl-module/source/themes/lv_theme_material.h @@ -11,7 +11,8 @@ extern "C" { typedef struct { bool is_dark; - bool is_compact; + int32_t spacing; + int32_t size; bool animations_enabled; lv_color_t color_primary; lv_color_t color_secondary; diff --git a/Modules/lvgl-module/source/themes/lv_theme_material_mono.c b/Modules/lvgl-module/source/themes/lv_theme_material_mono.c index 869d96b92..8fd09cbd9 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material_mono.c +++ b/Modules/lvgl-module/source/themes/lv_theme_material_mono.c @@ -52,28 +52,28 @@ extern const lv_obj_class_t lvgl_text_button_class; #define COLOR_SURFACE PAPER #define COLOR_ON_SURFACE INK #define COLOR_OUTLINE INK -#define SHAPE_CONTROL (theme->config.is_compact ? CORNER_COMPACT : CORNER_FULL) -#define SHAPE_CONTAINER (theme->config.is_compact ? CORNER_COMPACT : CORNER_MEDIUM) -#define SHAPE_FIELD (theme->config.is_compact ? CORNER_COMPACT : CORNER_SMALL) -#define SHAPE_KEY (theme->config.is_compact ? CORNER_COMPACT : CORNER_SMALL) -#define SHAPE_DIALOG (theme->config.is_compact ? CORNER_COMPACT : CORNER_LARGE) -#define DENSITY_CONTAINER_PAD (theme->config.is_compact ? 2 : SPACE_4) -#define DENSITY_CONTAINER_GAP (theme->config.is_compact ? 2 : SPACE_3) -#define DENSITY_CONTROL_PAD_HOR (theme->config.is_compact ? 3 : SPACE_6) -#define DENSITY_CONTROL_PAD_VER (theme->config.is_compact ? 2 : SPACE_3) -#define DENSITY_CONTROL_GAP (theme->config.is_compact ? 2 : SPACE_2) -#define DENSITY_FIELD_PAD (theme->config.is_compact ? 2 : SPACE_3) -#define DENSITY_LIST_PAD (theme->config.is_compact ? 1 : SPACE_2) -#define DENSITY_LIST_ITEM_PAD_HOR (theme->config.is_compact ? 2 : SPACE_4) -#define DENSITY_LIST_ITEM_PAD_VER (theme->config.is_compact ? 2 : SPACE_3) -#define DENSITY_DIALOG_PAD (theme->config.is_compact ? 2 : SPACE_6) -#define DENSITY_KEY_GAP (theme->config.is_compact ? 1 : SPACE_1) +#define SHAPE_CONTROL (theme->config.size == 100 ? CORNER_FULL : CORNER_COMPACT + (((CORNER_LARGE - CORNER_COMPACT) * theme->config.size) / 100)) +#define SHAPE_CONTAINER (CORNER_COMPACT + (((CORNER_MEDIUM - CORNER_COMPACT) * theme->config.size) / 100)) +#define SHAPE_FIELD (CORNER_COMPACT + (((CORNER_SMALL - CORNER_COMPACT) * theme->config.size) / 100)) +#define SHAPE_KEY (CORNER_COMPACT + (((CORNER_SMALL - CORNER_COMPACT) * theme->config.size) / 100)) +#define SHAPE_DIALOG (CORNER_COMPACT + (((CORNER_LARGE - CORNER_COMPACT) * theme->config.size) / 100)) +#define DENSITY_CONTAINER_PAD (2 + (((SPACE_4 - 2) * theme->config.spacing) / 100)) +#define DENSITY_CONTAINER_GAP (2 + (((SPACE_3 - 2) * theme->config.spacing) / 100)) +#define DENSITY_CONTROL_PAD_HOR (2 + (((SPACE_6 - 2) * theme->config.size) / 100)) +#define DENSITY_CONTROL_PAD_VER (1 + (((SPACE_3 - 1) * theme->config.size) / 100)) +#define DENSITY_CONTROL_GAP (2 + (((SPACE_2 - 2) * theme->config.spacing) / 100)) +#define DENSITY_FIELD_PAD (2 + (((SPACE_3 - 2) * theme->config.spacing) / 100)) +#define DENSITY_LIST_PAD (1 + (((SPACE_2 - 1) * theme->config.spacing) / 100)) +#define DENSITY_LIST_ITEM_PAD_HOR (2 + (((SPACE_4 - 2) * theme->config.spacing) / 100)) +#define DENSITY_LIST_ITEM_PAD_VER (2 + (((SPACE_3 - 2) * theme->config.spacing) / 100)) +#define DENSITY_DIALOG_PAD (2 + (((SPACE_6 - 2) * theme->config.spacing) / 100)) +#define DENSITY_KEY_GAP (1 + (((SPACE_1 - 1) * theme->config.spacing) / 100)) #define BORDER LINE_THIN -#define FOCUS_WIDTH (theme->config.is_compact ? LINE_THIN : LINE_MEDIUM) -#define FOCUS_OFFSET (theme->config.is_compact ? LINE_THIN : LINE_MEDIUM) -#define EDIT_WIDTH (theme->config.is_compact ? LINE_MEDIUM : LINE_THICK) -#define FOCUS_INSET_WIDTH (theme->config.is_compact ? LINE_THIN : LINE_MEDIUM) -#define FOCUS_INVERTED_GAP (theme->config.is_compact ? 1 : LV_DPX_CALC(theme->disp_dpi, 2)) +#define FOCUS_WIDTH (LINE_THIN + (((LINE_MEDIUM - LINE_THIN) * theme->config.size) / 100)) +#define FOCUS_OFFSET (LINE_THIN + (((LINE_MEDIUM - LINE_THIN) * theme->config.size) / 100)) +#define EDIT_WIDTH (LINE_MEDIUM + (((LINE_THICK - LINE_MEDIUM) * theme->config.size) / 100)) +#define FOCUS_INSET_WIDTH (LINE_THIN + (((LINE_MEDIUM - LINE_THIN) * theme->config.size) / 100)) +#define FOCUS_INVERTED_GAP (1 + (((LV_DPX_CALC(theme->disp_dpi, 2) - 1) * theme->config.size) / 100)) #define MOTION_DURATION_SHORT (theme->config.animations_enabled ? DURATION_SHORT : 0) #define MOTION_DURATION_MEDIUM (theme->config.animations_enabled ? DURATION_MEDIUM : 0) #define MOTION_DURATION_LONG (theme->config.animations_enabled ? DURATION_LONG : 0) @@ -94,20 +94,20 @@ extern const lv_obj_class_t lvgl_text_button_class; #define CARD_SHAPE SHAPE_CONTAINER #define CARD_PAD DENSITY_CONTAINER_PAD #define CARD_GAP DENSITY_CONTAINER_GAP -#define CHIP_SHAPE (theme->config.is_compact ? CORNER_COMPACT : CORNER_SMALL) -#define CHIP_PAD_HOR (theme->config.is_compact ? 3 : SPACE_4) -#define CHIP_PAD_VER (theme->config.is_compact ? 2 : SPACE_2) +#define CHIP_SHAPE (CORNER_COMPACT + (((CORNER_SMALL - CORNER_COMPACT) * theme->config.size) / 100)) +#define CHIP_PAD_HOR (3 + (((SPACE_4 - 3) * theme->config.spacing) / 100)) +#define CHIP_PAD_VER (2 + (((SPACE_2 - 2) * theme->config.spacing) / 100)) #define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE -#define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 6 : LV_DPX_CALC(theme->disp_dpi, 8)) -#define PAGE_INDICATOR_GAP (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) +#define PAGE_INDICATOR_DOT_SIZE (6 + (((LV_DPX_CALC(theme->disp_dpi, 8) - 6) * theme->config.size) / 100)) +#define PAGE_INDICATOR_GAP (4 + (((LV_DPX_CALC(theme->disp_dpi, 6) - 4) * theme->config.spacing) / 100)) #define STEP_INDICATOR_COLOR COLOR_ON_SURFACE #define STEP_INDICATOR_THICKNESS (LV_DPX_CALC(theme->disp_dpi, 6)) #define STEP_INDICATOR_MARGIN (LV_DPX_CALC(theme->disp_dpi, 6)) #define STEP_INDICATOR_GAP STEP_INDICATOR_MARGIN #define BADGE_COLOR COLOR_ON_SURFACE -#define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) +#define BADGE_SIZE (4 + (((LV_DPX_CALC(theme->disp_dpi, 6) - 4) * theme->config.size) / 100)) #define SCROLLBAR_COLOR COLOR_ON_SURFACE -#define SCROLLBAR_WIDTH (theme->config.is_compact ? 2 : LV_DPX_CALC(theme->disp_dpi, 3)) +#define SCROLLBAR_WIDTH (2 + (((LV_DPX_CALC(theme->disp_dpi, 3) - 2) * theme->config.size) / 100)) #define SCROLLBAR_PAD 1 #define BUTTON_CONTAINER_COLOR COLOR_SURFACE #define BUTTON_LABEL_COLOR COLOR_ON_SURFACE @@ -119,32 +119,32 @@ extern const lv_obj_class_t lvgl_text_button_class; #define BUTTON_PAD_HOR DENSITY_CONTROL_PAD_HOR #define BUTTON_PAD_VER DENSITY_CONTROL_PAD_VER #define BUTTON_GAP DENSITY_CONTROL_GAP -#define ICON_BUTTON_CONTAINER_SHAPE (theme->config.is_compact ? CORNER_COMPACT : CORNER_LARGE) +#define ICON_BUTTON_CONTAINER_SHAPE (CORNER_COMPACT + (((CORNER_LARGE - CORNER_COMPACT) * theme->config.size) / 100)) #define ICON_BUTTON_FILLED_COLOR COLOR_PRIMARY #define ICON_BUTTON_FILLED_ICON_COLOR COLOR_ON_PRIMARY #define ICON_BUTTON_TONAL_COLOR COLOR_SURFACE #define ICON_BUTTON_TONAL_ICON_COLOR COLOR_ON_SURFACE #define ICON_BUTTON_TONAL_BORDER_COLOR COLOR_OUTLINE #define APP_BAR_TITLE_COLOR COLOR_ON_SURFACE -#define APP_BAR_PAD_HOR (theme->config.is_compact ? 0 : SPACE_1) -#define APP_BAR_PAD_RIGHT (theme->config.is_compact ? APP_BAR_PAD_HOR + 2 : APP_BAR_PAD_HOR) -#define APP_BAR_GAP (theme->config.is_compact ? 2 : SPACE_1) +#define APP_BAR_PAD_HOR (0 + (((SPACE_1 - 0) * theme->config.spacing) / 100)) +#define APP_BAR_PAD_RIGHT ((APP_BAR_PAD_HOR + 2) + (((APP_BAR_PAD_HOR - (APP_BAR_PAD_HOR + 2)) * theme->config.spacing) / 100)) +#define APP_BAR_GAP (2 + (((SPACE_1 - 2) * theme->config.spacing) / 100)) #define APP_BAR_FOCUS_WIDTH FOCUS_INSET_WIDTH #define FOCUS_RING_COLOR COLOR_ON_SURFACE #define FOCUS_RING_WIDTH FOCUS_WIDTH #define FOCUS_RING_OFFSET FOCUS_OFFSET #define EDIT_RING_WIDTH EDIT_WIDTH -#define SWITCH_WIDTH (theme->config.is_compact ? 25 : LV_DPX_CALC(theme->disp_dpi, 40)) -#define SWITCH_HEIGHT (theme->config.is_compact ? 15 : LV_DPX_CALC(theme->disp_dpi, 24)) +#define SWITCH_WIDTH (25 + (((LV_DPX_CALC(theme->disp_dpi, 40) - 25) * theme->config.size) / 100)) +#define SWITCH_HEIGHT (15 + (((LV_DPX_CALC(theme->disp_dpi, 24) - 15) * theme->config.size) / 100)) #define SWITCH_TRACK_COLOR COLOR_SURFACE #define SWITCH_TRACK_BORDER_COLOR COLOR_OUTLINE #define SWITCH_TRACK_BORDER_WIDTH BORDER #define SWITCH_TRACK_SELECTED_COLOR COLOR_PRIMARY #define SWITCH_HANDLE_COLOR COLOR_ON_SURFACE #define SWITCH_HANDLE_SELECTED_COLOR COLOR_ON_PRIMARY -#define SWITCH_HANDLE_PAD (theme->config.is_compact ? -3 : -LV_DPX_CALC(theme->disp_dpi, 5)) -#define SWITCH_HANDLE_SELECTED_PAD (theme->config.is_compact ? -2 : -LV_DPX_CALC(theme->disp_dpi, 3)) -#define CHECKBOX_GAP (theme->config.is_compact ? 3 : SPACE_4) +#define SWITCH_HANDLE_PAD ((-3) + ((((-LV_DPX_CALC(theme->disp_dpi, 5)) - (-3)) * theme->config.size) / 100)) +#define SWITCH_HANDLE_SELECTED_PAD ((-2) + ((((-LV_DPX_CALC(theme->disp_dpi, 3)) - (-2)) * theme->config.size) / 100)) +#define CHECKBOX_GAP (3 + (((SPACE_4 - 3) * theme->config.spacing) / 100)) #define CHECKBOX_BOX_COLOR COLOR_SURFACE #define CHECKBOX_BOX_BORDER_COLOR COLOR_OUTLINE #define CHECKBOX_BOX_BORDER_WIDTH BORDER @@ -157,14 +157,14 @@ extern const lv_obj_class_t lvgl_text_button_class; #define SLIDER_ACTIVE_TRACK_COLOR COLOR_PRIMARY #define SLIDER_HANDLE_COLOR COLOR_PRIMARY #define SLIDER_TRACK_HEIGHT (LV_DPX_CALC(theme->disp_dpi, 8)) -#define SLIDER_HANDLE_PAD ((theme->config.is_compact ? 1 : LV_DPX_CALC(theme->disp_dpi, 3)) + LV_DPX_CALC(theme->disp_dpi, 4)) -#define SLIDERBOX_GAP (theme->config.is_compact ? 4 : ((SLIDER_TRACK_HEIGHT / 2) + SLIDER_HANDLE_PAD) + SPACE_2) +#define SLIDER_HANDLE_PAD ((1 + (((LV_DPX_CALC(theme->disp_dpi, 3) - 1) * theme->config.size) / 100)) + LV_DPX_CALC(theme->disp_dpi, 4)) +#define SLIDERBOX_GAP (4 + ((((((SLIDER_TRACK_HEIGHT / 2) + SLIDER_HANDLE_PAD) + SPACE_2) - 4) * theme->config.spacing) / 100)) #define ARC_TRACK_COLOR COLOR_ON_SURFACE #define ARC_TRACK_WIDTH LINE_THIN #define ARC_INDICATOR_COLOR COLOR_PRIMARY -#define ARC_INDICATOR_WIDTH (theme->config.is_compact ? LINE_MEDIUM : LV_DPX_CALC(theme->disp_dpi, 4)) +#define ARC_INDICATOR_WIDTH (LINE_MEDIUM + (((LV_DPX_CALC(theme->disp_dpi, 4) - LINE_MEDIUM) * theme->config.size) / 100)) #define ARC_HANDLE_COLOR COLOR_PRIMARY -#define ARC_HANDLE_PAD (theme->config.is_compact ? 1 : LV_DPX_CALC(theme->disp_dpi, 3)) +#define ARC_HANDLE_PAD (1 + (((LV_DPX_CALC(theme->disp_dpi, 3) - 1) * theme->config.size) / 100)) #define FIELD_COLOR COLOR_SURFACE #define FIELD_TEXT_COLOR COLOR_ON_SURFACE #define FIELD_BORDER_COLOR COLOR_OUTLINE @@ -180,8 +180,8 @@ extern const lv_obj_class_t lvgl_text_button_class; #define MENU_BORDER_COLOR COLOR_OUTLINE #define MENU_SHAPE SHAPE_FIELD #define MENU_MAX_HEIGHT (LV_DPI_DEF * 2) -#define MENU_ITEM_LINE_SPACE (theme->config.is_compact ? 2 : SPACE_3) -#define ROLLER_LINE_SPACE (theme->config.is_compact ? 2 : SPACE_3) +#define MENU_ITEM_LINE_SPACE (2 + (((SPACE_3 - 2) * theme->config.spacing) / 100)) +#define ROLLER_LINE_SPACE (2 + (((SPACE_3 - 2) * theme->config.spacing) / 100)) #define KEYS_PAD DENSITY_KEY_GAP #define KEYS_GAP DENSITY_KEY_GAP #define KEY_COLOR COLOR_SURFACE @@ -191,11 +191,11 @@ extern const lv_obj_class_t lvgl_text_button_class; #define LIST_PAD DENSITY_LIST_PAD #define LIST_ITEM_PAD_HOR DENSITY_LIST_ITEM_PAD_HOR #define LIST_ITEM_PAD_VER DENSITY_LIST_ITEM_PAD_VER -#define LIST_ITEM_GAP (theme->config.is_compact ? 3 : SPACE_4) +#define LIST_ITEM_GAP (3 + (((SPACE_4 - 3) * theme->config.spacing) / 100)) #define LIST_DIVIDER_COLOR COLOR_OUTLINE -#define TABS_PAD (theme->config.is_compact ? 2 : SPACE_1) -#define TABS_GAP (theme->config.is_compact ? 2 : SPACE_1) -#define TAB_PAD (theme->config.is_compact ? 2 : SPACE_1) +#define TABS_PAD (2 + (((SPACE_1 - 2) * theme->config.spacing) / 100)) +#define TABS_GAP (2 + (((SPACE_1 - 2) * theme->config.spacing) / 100)) +#define TAB_PAD (2 + (((SPACE_1 - 2) * theme->config.spacing) / 100)) #define TAB_SHAPE SHAPE_CONTROL #define TAB_PAGE_PAD DENSITY_CONTAINER_PAD #define DIALOG_COLOR COLOR_SURFACE @@ -203,17 +203,17 @@ extern const lv_obj_class_t lvgl_text_button_class; #define DIALOG_BORDER_WIDTH LINE_MEDIUM #define DIALOG_SHAPE SHAPE_DIALOG #define DIALOG_PAD DENSITY_DIALOG_PAD -#define DIALOG_GAP (theme->config.is_compact ? 2 : SPACE_2) +#define DIALOG_GAP (2 + (((SPACE_2 - 2) * theme->config.spacing) / 100)) #define WINDOW_HEADER_BORDER_COLOR COLOR_OUTLINE -#define WINDOW_HEADER_PAD (theme->config.is_compact ? 2 : SPACE_2) +#define WINDOW_HEADER_PAD (2 + (((SPACE_2 - 2) * theme->config.spacing) / 100)) #define WINDOW_CONTENT_PAD DENSITY_CONTAINER_PAD -#define TABLE_CELL_PAD (theme->config.is_compact ? 2 : SPACE_3) +#define TABLE_CELL_PAD (2 + (((SPACE_3 - 2) * theme->config.spacing) / 100)) #define TABLE_DIVIDER_COLOR COLOR_OUTLINE #define CHART_GRID_COLOR COLOR_ON_SURFACE -#define CHART_POINT_SIZE (theme->config.is_compact ? 3 : LV_DPX_CALC(theme->disp_dpi, 6)) -#define CALENDAR_PAD (theme->config.is_compact ? 2 : SPACE_2) +#define CHART_POINT_SIZE (3 + (((LV_DPX_CALC(theme->disp_dpi, 6) - 3) * theme->config.size) / 100)) +#define CALENDAR_PAD (2 + (((SPACE_2 - 2) * theme->config.spacing) / 100)) #define SCALE_COLOR COLOR_ON_SURFACE -#define SCALE_TICK_LENGTH (theme->config.is_compact ? 3 : LV_DPX_CALC(theme->disp_dpi, 6)) +#define SCALE_TICK_LENGTH (3 + (((LV_DPX_CALC(theme->disp_dpi, 6) - 3) * theme->config.size) / 100)) #define DIVIDER_COLOR COLOR_OUTLINE /********************** @@ -475,7 +475,8 @@ static inline bool lss_is_child(lv_obj_t * parent, int32_t index, lv_obj_t * chi static bool config_equals(const lv_theme_material_mono_config_t * a, const lv_theme_material_mono_config_t * b) { return a->is_dark == b->is_dark && - a->is_compact == b->is_compact && + a->spacing == b->spacing && + a->size == b->size && a->animations_enabled == b->animations_enabled && lv_color_eq(a->color_primary, b->color_primary) && lv_color_eq(a->color_secondary, b->color_secondary) && @@ -698,12 +699,12 @@ static void style_init(my_theme_t * theme) lv_style_set_pad_gap(&theme->styles.app_bar, APP_BAR_GAP); style_init_reset(&theme->styles.app_bar_nav_button); - if(theme->config.is_compact) { - lv_style_set_margin_left(&theme->styles.app_bar_nav_button, 4); + if(theme->config.spacing != 100) { + lv_style_set_margin_left(&theme->styles.app_bar_nav_button, 4 - ((4 * theme->config.spacing) / 100)); } style_init_reset(&theme->styles.app_bar_button_focus_ring); - if(theme->config.is_compact) { + if((theme->config.size / 50) == 0) { lv_style_set_outline_pad(&theme->styles.app_bar_button_focus_ring, 0); } @@ -1187,7 +1188,8 @@ void lv_theme_material_mono_config_init(lv_theme_material_mono_config_t * config { lv_memzero(config, sizeof(*config)); config->is_dark = false; - config->is_compact = false; + config->spacing = 100; + config->size = 100; config->animations_enabled = true; config->color_primary = lv_color_black(); config->color_secondary = lv_color_black(); diff --git a/Modules/lvgl-module/source/themes/lv_theme_material_mono.h b/Modules/lvgl-module/source/themes/lv_theme_material_mono.h index 3da37e557..8599e0bf9 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material_mono.h +++ b/Modules/lvgl-module/source/themes/lv_theme_material_mono.h @@ -11,7 +11,8 @@ extern "C" { typedef struct { bool is_dark; - bool is_compact; + int32_t spacing; + int32_t size; bool animations_enabled; lv_color_t color_primary; lv_color_t color_secondary; diff --git a/Modules/lvgl-module/source/widgets/statusbar.cpp b/Modules/lvgl-module/source/widgets/statusbar.cpp index 9f340ed45..7a16bf2bb 100644 --- a/Modules/lvgl-module/source/widgets/statusbar.cpp +++ b/Modules/lvgl-module/source/widgets/statusbar.cpp @@ -288,8 +288,7 @@ lv_obj_t* lvgl_statusbar_create(lv_obj_t* parent) { lv_obj_set_style_pad_hor(obj, 2, LV_STATE_DEFAULT); lv_obj_center(obj); auto icon_size = lvgl_get_statusbar_icon_font_height(); - const bool is_compact = lvgl_theme_is_compact(); - auto icon_padding = !is_compact ? static_cast(icon_size * 0.2f) : 2; + auto icon_padding = lvgl_theme_lerp(2, static_cast(icon_size * 0.2f), lvgl_theme_get_spacing()); lv_display_t* display = lv_obj_get_display(obj); DisplayShape shape; @@ -626,9 +625,9 @@ void lvgl_statusbar_set_side(lv_obj_t* obj, bool side) { const int32_t content_width = std::max(get_action_button_width(statusbar->action_container), time_width); lv_obj_set_size(obj, content_width + 2 * 2, LV_PCT(100)); // Keeps the action buttons away from the screen's bottom edge. The compact layout has no room to spare. - const bool compact = lvgl_theme_is_compact(); - lv_obj_set_style_pad_top(obj, compact ? 0 : 4, LV_STATE_DEFAULT); - lv_obj_set_style_pad_bottom(obj, compact ? 0 : 8, LV_STATE_DEFAULT); + const uint8_t spacing = lvgl_theme_get_spacing(); + lv_obj_set_style_pad_top(obj, lvgl_theme_lerp(0, 4, spacing), LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, lvgl_theme_lerp(0, 8, spacing), LV_STATE_DEFAULT); // Slightly darker than the colour behind it, so the strip stands out from the app next to it const lv_color_t background = get_visible_bg_color(obj); lv_obj_set_style_bg_color(obj, lv_color_darken(background, LV_OPA_20), LV_STATE_DEFAULT); diff --git a/Modules/lvgl-module/source/widgets/toolbar.cpp b/Modules/lvgl-module/source/widgets/toolbar.cpp index 3961cd4f9..895b60dbd 100644 --- a/Modules/lvgl-module/source/widgets/toolbar.cpp +++ b/Modules/lvgl-module/source/widgets/toolbar.cpp @@ -20,16 +20,12 @@ constexpr auto* TAG = "lvgl_toolbar"; -static uint32_t getToolbarHeight(bool isCompact) { - if (isCompact) { - return lvgl_get_text_font_height(FONT_SIZE_DEFAULT) * 1.6f; - } else { - return lvgl_get_text_font_height(FONT_SIZE_LARGE) * 2.2f; - } +static uint32_t getToolbarHeight(uint8_t size) { + return lvgl_theme_lerp(lvgl_get_text_font_height(FONT_SIZE_DEFAULT) * 1.6f, lvgl_get_text_font_height(FONT_SIZE_LARGE) * 2.2f, size); } -static const _lv_font_t* getToolbarFont(bool isCompact) { - if (isCompact) { +static const _lv_font_t* getToolbarFont(uint8_t size) { + if (size < 50) { return lvgl_get_text_font(FONT_SIZE_DEFAULT); } else { return lvgl_get_text_font(FONT_SIZE_LARGE); @@ -37,9 +33,9 @@ static const _lv_font_t* getToolbarFont(bool isCompact) { } /** The size of icon buttons, which leaves room for the theme's focus indicator */ -static uint32_t getActionButtonSize(bool isCompact) { - auto toolbar_height = getToolbarHeight(isCompact); - auto padding = !isCompact ? (uint32_t)(toolbar_height * 0.2f) : 8; +static uint32_t getActionButtonSize(uint8_t size) { + auto toolbar_height = getToolbarHeight(size); + auto padding = lvgl_theme_lerp(8, (uint32_t)(toolbar_height * 0.2f), size); return toolbar_height - padding; } @@ -88,18 +84,18 @@ static void toolbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) { lv_obj_set_scroll_on_focus(obj, true); } -static lv_obj_t* create_action_button(lv_obj_t* parent, bool is_compact) { - auto button_size = getActionButtonSize(is_compact); +static lv_obj_t* create_action_button(lv_obj_t* parent, uint8_t size) { + auto button_size = getActionButtonSize(size); lv_obj_t* button = lvgl_icon_button_create(parent); lv_obj_set_size(button, button_size, button_size); return button; } lv_obj_t* lvgl_toolbar_create(lv_obj_t* parent, const char* title) { - const bool is_compact = lvgl_theme_is_compact(); + const uint8_t size = lvgl_theme_get_size(); lv_obj_t* obj = lv_obj_class_create_obj(&lvgl_toolbar_class, parent); lv_obj_class_init_obj(obj); - lv_obj_set_height(obj, getToolbarHeight(is_compact)); + lv_obj_set_height(obj, getToolbarHeight(size)); auto* toolbar = reinterpret_cast(obj); toolbar->nav_action_callback = nullptr; @@ -113,12 +109,12 @@ lv_obj_t* lvgl_toolbar_create(lv_obj_t* parent, const char* title) { // The arrow keys move between the close button and the actions, and up and down leave the toolbar lvgl_grid_navigation_add(obj); - toolbar->close_button = create_action_button(obj, is_compact); + toolbar->close_button = create_action_button(obj, size); toolbar->close_button_image = lv_image_create(toolbar->close_button); lv_obj_align(toolbar->close_button_image, LV_ALIGN_CENTER, 0, 0); toolbar->title_label = lv_label_create(obj); - lv_obj_set_style_text_font(toolbar->title_label, getToolbarFont(is_compact), LV_STATE_DEFAULT); + lv_obj_set_style_text_font(toolbar->title_label, getToolbarFont(size), LV_STATE_DEFAULT); lv_label_set_text(toolbar->title_label, title); lv_label_set_long_mode(toolbar->title_label, LV_LABEL_LONG_MODE_SCROLL); lv_obj_set_flex_grow(toolbar->title_label, 1); @@ -159,7 +155,7 @@ static lv_obj_t* toolbar_add_button_action(lv_obj_t* obj, const char* imageOrBut check(toolbar->action_count < TOOLBAR_ACTION_LIMIT, "max actions reached"); toolbar->action_count++; - lv_obj_t* action_button = create_action_button(obj, lvgl_theme_is_compact()); + lv_obj_t* action_button = create_action_button(obj, lvgl_theme_get_size()); lv_obj_add_event_cb(action_button, callback, LV_EVENT_SHORT_CLICKED, user_data); lv_obj_t* button_content; if (isImage) { diff --git a/Modules/lvgl-module/tests/source/theme_test.cpp b/Modules/lvgl-module/tests/source/theme_test.cpp new file mode 100644 index 000000000..44e01c8b8 --- /dev/null +++ b/Modules/lvgl-module/tests/source/theme_test.cpp @@ -0,0 +1,22 @@ +#include "doctest.h" + +#include + +TEST_CASE("lvgl_theme_lerp returns the compact value at 0 and the regular value at 100") { + CHECK_EQ(lvgl_theme_lerp(2, 16, 0), 2); + CHECK_EQ(lvgl_theme_lerp(2, 16, 100), 16); + CHECK_EQ(lvgl_theme_lerp(2, 16, 50), 9); +} + +TEST_CASE("lvgl_theme_lerp interpolates in steps of 20 percent") { + CHECK_EQ(lvgl_theme_lerp(0, 100, 20), 20); + CHECK_EQ(lvgl_theme_lerp(0, 100, 40), 40); + CHECK_EQ(lvgl_theme_lerp(0, 100, 60), 60); + CHECK_EQ(lvgl_theme_lerp(0, 100, 80), 80); +} + +TEST_CASE("lvgl_theme_lerp interpolates downwards and with negative values") { + CHECK_EQ(lvgl_theme_lerp(16, 2, 50), 9); + CHECK_EQ(lvgl_theme_lerp(-3, -8, 0), -3); + CHECK_EQ(lvgl_theme_lerp(-3, -8, 100), -8); +} diff --git a/Modules/lvgl-module/themes/material.lss b/Modules/lvgl-module/themes/material.lss index a55faff42..4a26a885a 100644 --- a/Modules/lvgl-module/themes/material.lss +++ b/Modules/lvgl-module/themes/material.lss @@ -10,7 +10,8 @@ @config { isDark: bool = false; - isCompact: bool = false; + spacing: int = 100; + size: int = 100; animationsEnabled: bool = true; colorPrimary: color = #00bbff; colorSecondary: color = #ee81c3; @@ -139,35 +140,35 @@ @color-scrim: black; // Shapes -@shape-control: if($isCompact, @corner-compact, @corner-full); -@shape-container: if($isCompact, @corner-compact, @corner-medium); -@shape-field: if($isCompact, @corner-compact, @corner-extra-small); -@shape-key: if($isCompact, @corner-compact, @corner-small); -@shape-dialog: if($isCompact, @corner-compact, @corner-extra-large); -@shape-menu: if($isCompact, @corner-compact, @corner-extra-small); +@shape-control: if($size == 100, @corner-full, (@corner-compact) + (@corner-extra-large - (@corner-compact)) * $size / 100); +@shape-container: ((@corner-compact) + ((@corner-medium) - (@corner-compact)) * $size / 100); +@shape-field: ((@corner-compact) + ((@corner-extra-small) - (@corner-compact)) * $size / 100); +@shape-key: ((@corner-compact) + ((@corner-small) - (@corner-compact)) * $size / 100); +@shape-dialog: ((@corner-compact) + ((@corner-extra-large) - (@corner-compact)) * $size / 100); +@shape-menu: ((@corner-compact) + ((@corner-extra-small) - (@corner-compact)) * $size / 100); // Density -@density-container-pad: if($isCompact, 2, @space-4); -@density-container-gap: if($isCompact, 2, @space-3); -@density-container-border: if($isCompact, 1, 0); -@density-control-pad-hor: if($isCompact, 3, @space-6); -@density-control-pad-ver: if($isCompact, 3, 10dp); -@density-control-gap: if($isCompact, 2, @space-2); -@density-field-pad-hor: if($isCompact, 2, @space-4); -@density-field-pad-ver: if($isCompact, 2, @space-2); -@density-list-pad: if($isCompact, 2, @space-2); -@density-list-item-pad-hor: if($isCompact, 2, @space-4); -@density-list-item-pad-ver: if($isCompact, 2, @space-3); -@density-dialog-pad: if($isCompact, 2, @space-6); -@density-key-gap: if($isCompact, 2, @space-1); +@density-container-pad: ((2) + ((@space-4) - (2)) * $spacing / 100); +@density-container-gap: ((2) + ((@space-3) - (2)) * $spacing / 100); +@density-container-border: ((1) + ((0) - (1)) * $size / 100); +@density-control-pad-hor: ((2) + ((@space-6) - (2)) * $size / 100); +@density-control-pad-ver: ((1) + ((10dp) - (1)) * $size / 100); +@density-control-gap: ((2) + ((@space-2) - (2)) * $spacing / 100); +@density-field-pad-hor: ((2) + ((@space-4) - (2)) * $spacing / 100); +@density-field-pad-ver: ((2) + ((@space-2) - (2)) * $spacing / 100); +@density-list-pad: ((2) + ((@space-2) - (2)) * $spacing / 100); +@density-list-item-pad-hor: ((2) + ((@space-4) - (2)) * $spacing / 100); +@density-list-item-pad-ver: ((2) + ((@space-3) - (2)) * $spacing / 100); +@density-dialog-pad: ((2) + ((@space-6) - (2)) * $spacing / 100); +@density-key-gap: ((2) + ((@space-1) - (2)) * $spacing / 100); @density-border: 1dp; // Focus indicator @focus-color: @color-secondary; -@focus-width: if($isCompact, 1, 3dp); -@focus-offset: if($isCompact, 1, 2dp); +@focus-width: ((1) + ((3dp) - (1)) * $size / 100); +@focus-offset: ((1) + ((2dp) - (1)) * $size / 100); // Drawn inside the widget, for widgets with little room around them (icon buttons) -@focus-inset-width: if($isCompact, 1, 2dp); +@focus-inset-width: ((1) + ((2dp) - (1)) * $size / 100); @edit-color: @color-primary; // State layers: the state color blended over the container color @@ -212,14 +213,14 @@ @chip-border-color: @color-outline; @chip-selected-color: @color-secondary-container; @chip-selected-label-color: @color-on-secondary-container; -@chip-shape: if($isCompact, @corner-compact, @corner-small); -@chip-pad-hor: if($isCompact, 3, @space-4); -@chip-pad-ver: if($isCompact, 2, 6dp); +@chip-shape: ((@corner-compact) + ((@corner-small) - (@corner-compact)) * $size / 100); +@chip-pad-hor: ((3) + ((@space-4) - (3)) * $spacing / 100); +@chip-pad-ver: ((2) + ((6dp) - (2)) * $spacing / 100); // Page indicator (lvgl_page_indicator_create): a dot per page, the current page's dot is filled @page-indicator-color: @color-on-surface-variant; -@page-indicator-dot-size: if($isCompact, 6, 8dp); -@page-indicator-gap: if($isCompact, 4, 6dp); +@page-indicator-dot-size: ((6) + ((8dp) - (6)) * $size / 100); +@page-indicator-gap: ((4) + ((6dp) - (4)) * $spacing / 100); // Step indicator (lvgl_step_indicator_create): a line segment per step, finished steps in the secondary colour @step-indicator-finished-color: @color-secondary; @@ -230,14 +231,14 @@ // Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite @badge-color: @color-secondary; -@badge-size: if($isCompact, 4, 6dp); +@badge-size: ((4) + ((6dp) - (4)) * $size / 100); // Scrollbar @scrollbar-color: @color-outline; @scrollbar-opacity: 40%; @scrollbar-scrolled-opacity: 70%; -@scrollbar-width: if($isCompact, 2, @space-1); -@scrollbar-pad: if($isCompact, 1, @space-1); +@scrollbar-width: ((2) + ((@space-1) - (2)) * $size / 100); +@scrollbar-pad: ((1) + ((@space-1) - (1)) * $spacing / 100); // Button: Material 3 filled button @button-container-color: @color-primary; @@ -258,13 +259,13 @@ // Text button (dialog actions, menu header buttons) @text-button-label-color: @color-primary; @text-button-pressed-container-color: @color-primary; -@text-button-pad-hor: if($isCompact, 3, @space-3); +@text-button-pad-hor: ((2) + ((@space-3) - (2)) * $size / 100); // Icon button (dialog and window header buttons) @icon-button-color: @color-on-surface; // Filled and tonal icon buttons: Material 3 large icon buttons with a container -@icon-button-container-shape: if($isCompact, @corner-compact, @corner-large); +@icon-button-container-shape: ((@corner-compact) + ((@corner-large) - (@corner-compact)) * $size / 100); @icon-button-filled-color: @color-primary; @icon-button-filled-icon-color: @color-on-primary; @icon-button-filled-pressed-color: @state-pressed-on-primary; @@ -276,10 +277,10 @@ @app-bar-color: @color-surface-container-low; @app-bar-content-color: @color-on-surface; @app-bar-pressed-opacity: 20%; -@app-bar-pad-hor: if($isCompact, 0, @space-1); +@app-bar-pad-hor: ((0) + ((@space-1) - (0)) * $spacing / 100); // Keeps the action buttons off the screen's edge in the compact layout -@app-bar-pad-right: if($isCompact, @app-bar-pad-hor + 2, @app-bar-pad-hor); -@app-bar-gap: if($isCompact, 2, @space-1); +@app-bar-pad-right: ((@app-bar-pad-hor + 2) + ((@app-bar-pad-hor) - (@app-bar-pad-hor + 2)) * $spacing / 100); +@app-bar-gap: ((2) + ((@space-1) - (2)) * $spacing / 100); // The focus ring is drawn inside toolbar buttons, as there's little room around them @app-bar-focus-width: @focus-inset-width; @@ -290,26 +291,26 @@ @edit-ring-color: @edit-color; // Switch -@switch-width: if($isCompact, 25, 52dp); -@switch-height: if($isCompact, 15, 32dp); +@switch-width: ((25) + ((52dp) - (25)) * $size / 100); +@switch-height: ((15) + ((32dp) - (15)) * $size / 100); @switch-track-color: @color-surface-container-highest; @switch-track-border-color: @color-outline; -@switch-track-border-width: if($isCompact, 1, 2dp); +@switch-track-border-width: ((1) + ((2dp) - (1)) * $size / 100); @switch-track-selected-color: @color-primary; @switch-handle-color: @color-outline; @switch-handle-selected-color: @color-on-primary; -@switch-handle-pad: if($isCompact, -3, -8dp); -@switch-handle-selected-pad: if($isCompact, -2, -4dp); -@switch-handle-pressed-pad: if($isCompact, -1, -2dp); +@switch-handle-pad: ((-3) + ((-8dp) - (-3)) * $size / 100); +@switch-handle-selected-pad: ((-2) + ((-4dp) - (-2)) * $size / 100); +@switch-handle-pressed-pad: ((-1) + ((-2dp) - (-1)) * $size / 100); // Checkbox -@checkbox-gap: if($isCompact, 3, @space-3); +@checkbox-gap: ((3) + ((@space-3) - (3)) * $spacing / 100); @checkbox-box-color: @color-surface; @checkbox-box-opacity: transp; @checkbox-box-border-color: @color-on-surface-variant; -@checkbox-box-border-width: if($isCompact, 1, 2dp); -@checkbox-box-shape: if($isCompact, 2, 2dp); -@checkbox-box-pad: if($isCompact, 1, 2dp); +@checkbox-box-border-width: ((1) + ((2dp) - (1)) * $size / 100); +@checkbox-box-shape: ((2) + ((2dp) - (2)) * $size / 100); +@checkbox-box-pad: ((1) + ((2dp) - (1)) * $size / 100); @checkbox-box-selected-color: @color-primary; @checkbox-icon-color: @color-on-primary; @@ -320,18 +321,18 @@ // The track is half of LVGL's default thickness (LV_DPI_DEF / 10). The knob is the track height plus its padding on both // sides, so the padding includes the removed thickness to keep the knob's size. @slider-track-height: 8dp; -@slider-handle-pad: if($isCompact, 2, 6dp) + 4dp; +@slider-handle-pad: ((2) + ((6dp) - (2)) * $size / 100) + 4dp; // Sliderbox: in the normal layout, the gap between the slider and its buttons leaves room for the knob, which sticks out at the slider's ends -@sliderbox-gap: if($isCompact, 4, (@slider-track-height / 2) + @slider-handle-pad + @space-2); +@sliderbox-gap: ((4) + (((@slider-track-height / 2) + @slider-handle-pad + @space-2) - (4)) * $spacing / 100); @progress-track-color: @color-secondary-container; @progress-indicator-color: @color-primary; // Arc, spinner (circular progress) @arc-track-color: @color-secondary-container; @arc-indicator-color: @color-primary; -@arc-width: if($isCompact, 3, @space-1); +@arc-width: ((3) + ((@space-1) - (3)) * $size / 100); @arc-handle-color: @color-primary; -@arc-handle-pad: if($isCompact, 2, @space-1); +@arc-handle-pad: ((2) + ((@space-1) - (2)) * $size / 100); // Text field: Material 3 outlined text field (textarea, spinbox, dropdown) @field-color: @color-surface; @@ -340,13 +341,13 @@ @field-border-color: @color-outline; @field-border-width: @density-border; @field-focused-border-color: @color-primary; -@field-focused-border-width: if($isCompact, 1, 2dp); +@field-focused-border-width: ((1) + ((2dp) - (1)) * $size / 100); @field-shape: @shape-field; @field-pad-hor: @density-field-pad-hor; @field-pad-ver: @density-field-pad-ver; @field-placeholder-color: @color-on-surface-variant; @field-cursor-color: @color-primary; -@field-cursor-width: if($isCompact, 1, 2dp); +@field-cursor-width: ((1) + ((2dp) - (1)) * $size / 100); @field-selection-color: @color-primary-container; @field-selection-text-color: @color-on-primary-container; @spinbox-cursor-color: @color-primary; @@ -356,18 +357,18 @@ @menu-color: @color-surface-container; @menu-text-color: @color-on-surface; @menu-shape: @shape-menu; -@menu-pad: if($isCompact, 1, @space-2); +@menu-pad: ((1) + ((@space-2) - (1)) * $spacing / 100); @menu-max-height: dpi-def * 2; @menu-item-selected-color: @color-secondary-container; @menu-item-selected-text-color: @color-on-secondary-container; -@menu-item-line-space: if($isCompact, 4, @space-4); +@menu-item-line-space: ((4) + ((@space-4) - (4)) * $spacing / 100); // Roller @roller-color: @color-surface-container; @roller-text-color: @color-on-surface-variant; @roller-selected-color: @color-secondary-container; @roller-selected-text-color: @color-on-secondary-container; -@roller-line-space: if($isCompact, 4, @space-4); +@roller-line-space: ((4) + ((@space-4) - (4)) * $spacing / 100); // Button matrix and keyboard: keys are tonal buttons @keys-color: @color-surface-container; @@ -389,7 +390,7 @@ @list-item-text-color: @color-on-surface; @list-item-pad-hor: @density-list-item-pad-hor; @list-item-pad-ver: @density-list-item-pad-ver; -@list-item-gap: if($isCompact, 3, @space-4); +@list-item-gap: ((3) + ((@space-4) - (3)) * $spacing / 100); @list-item-focused-color: @color-secondary-container; @list-item-focused-text-color: @color-on-secondary-container; @list-item-selected-color: @color-secondary-container; @@ -401,10 +402,10 @@ // Tabs: the tab bar is a Material 3 navigation bar with a pill-shaped active indicator @tabs-color: @card-color; -@tabs-pad: if($isCompact, 2, @space-1); -@tabs-gap: if($isCompact, 2, @space-1); +@tabs-pad: ((2) + ((@space-1) - (2)) * $spacing / 100); +@tabs-gap: ((2) + ((@space-1) - (2)) * $spacing / 100); @tab-label-color: @color-on-surface-variant; -@tab-pad: if($isCompact, 2, @space-1); +@tab-pad: ((2) + ((@space-1) - (2)) * $spacing / 100); @tab-shape: @shape-control; @tab-selected-color: @color-secondary-container; @tab-selected-label-color: @color-on-secondary-container; @@ -416,7 +417,7 @@ @dialog-title-color: @color-on-surface; @dialog-shape: @shape-dialog; @dialog-pad: @density-dialog-pad; -@dialog-gap: if($isCompact, 2, @space-2); +@dialog-gap: ((2) + ((@space-2) - (2)) * $spacing / 100); @dialog-scrim-color: @color-scrim; @dialog-scrim-opacity: @opacity-scrim; @@ -433,41 +434,41 @@ // Window @window-header-color: @color-surface-container; -@window-header-pad: if($isCompact, 2, @space-2); +@window-header-pad: ((2) + ((@space-2) - (2)) * $spacing / 100); @window-content-pad: @density-container-pad; // Table @table-color: @color-surface; @table-cell-text-color: @color-on-surface; -@table-cell-pad: if($isCompact, 2, @space-3); +@table-cell-pad: ((2) + ((@space-3) - (2)) * $spacing / 100); @table-divider-color: @color-outline-variant; // Chart @chart-color: @color-surface-container-low; @chart-grid-color: @color-outline-variant; -@chart-series-width: if($isCompact, 1, 2dp); -@chart-point-size: if($isCompact, 3, 6dp); +@chart-series-width: ((1) + ((2dp) - (1)) * $size / 100); +@chart-point-size: ((3) + ((6dp) - (3)) * $size / 100); @chart-indicator-color: @color-primary; // Calendar @calendar-color: @color-surface-container-high; -@calendar-shape: if($isCompact, @corner-compact, @corner-large); -@calendar-pad: if($isCompact, 2, @space-2); +@calendar-shape: ((@corner-compact) + ((@corner-large) - (@corner-compact)) * $size / 100); +@calendar-pad: ((2) + ((@space-2) - (2)) * $spacing / 100); @calendar-day-text-color: @color-on-surface; -@calendar-header-pad: if($isCompact, 2, @space-3); +@calendar-header-pad: ((2) + ((@space-3) - (2)) * $spacing / 100); // Scale @scale-color: @color-on-surface-variant; @scale-width: @density-border; -@scale-tick-length: if($isCompact, 3, 6dp); +@scale-tick-length: ((3) + ((6dp) - (3)) * $size / 100); // Divider (lv_line) @divider-color: @color-outline-variant; @divider-width: @density-border; // LED -@led-glow-width: if($isCompact, 4, 15dp); -@led-glow-spread: if($isCompact, 1, 5dp); +@led-glow-width: ((4) + ((15dp) - (4)) * $size / 100); +@led-glow-spread: ((1) + ((5dp) - (1)) * $size / 100); // ============================================================ // Transitions @@ -775,12 +776,12 @@ app_bar { // The toolbar's first button: the close (navigation) button app_bar_nav_button { - @if ($isCompact) { marginLeft: 4; } + @if ($spacing != 100) { marginLeft: 4 - 4 * $spacing / 100; } } // Compact toolbar buttons are as big as their icon, so their focus ring goes around the outside instead of the inside app_bar_button_focus_ring { - @if ($isCompact) { outlinePad: 0; } + @if ($size / 50 == 0) { outlinePad: 0; } } app_bar_button { diff --git a/Modules/lvgl-module/themes/material_mono.lss b/Modules/lvgl-module/themes/material_mono.lss index ab6b33ee2..cb70e57d2 100644 --- a/Modules/lvgl-module/themes/material_mono.lss +++ b/Modules/lvgl-module/themes/material_mono.lss @@ -12,7 +12,8 @@ @config { isDark: bool = false; - isCompact: bool = false; + spacing: int = 100; + size: int = 100; animationsEnabled: bool = true; colorPrimary: color = black; colorSecondary: color = black; @@ -87,33 +88,33 @@ @color-outline: @ink; // Shapes -@shape-control: if($isCompact, @corner-compact, @corner-full); -@shape-container: if($isCompact, @corner-compact, @corner-medium); -@shape-field: if($isCompact, @corner-compact, @corner-small); -@shape-key: if($isCompact, @corner-compact, @corner-small); -@shape-dialog: if($isCompact, @corner-compact, @corner-large); +@shape-control: if($size == 100, @corner-full, (@corner-compact) + (@corner-large - (@corner-compact)) * $size / 100); +@shape-container: ((@corner-compact) + ((@corner-medium) - (@corner-compact)) * $size / 100); +@shape-field: ((@corner-compact) + ((@corner-small) - (@corner-compact)) * $size / 100); +@shape-key: ((@corner-compact) + ((@corner-small) - (@corner-compact)) * $size / 100); +@shape-dialog: ((@corner-compact) + ((@corner-large) - (@corner-compact)) * $size / 100); // Density -@density-container-pad: if($isCompact, 2, @space-4); -@density-container-gap: if($isCompact, 2, @space-3); -@density-control-pad-hor: if($isCompact, 3, @space-6); -@density-control-pad-ver: if($isCompact, 2, @space-3); -@density-control-gap: if($isCompact, 2, @space-2); -@density-field-pad: if($isCompact, 2, @space-3); -@density-list-pad: if($isCompact, 1, @space-2); -@density-list-item-pad-hor: if($isCompact, 2, @space-4); -@density-list-item-pad-ver: if($isCompact, 2, @space-3); -@density-dialog-pad: if($isCompact, 2, @space-6); -@density-key-gap: if($isCompact, 1, @space-1); +@density-container-pad: ((2) + ((@space-4) - (2)) * $spacing / 100); +@density-container-gap: ((2) + ((@space-3) - (2)) * $spacing / 100); +@density-control-pad-hor: ((2) + ((@space-6) - (2)) * $size / 100); +@density-control-pad-ver: ((1) + ((@space-3) - (1)) * $size / 100); +@density-control-gap: ((2) + ((@space-2) - (2)) * $spacing / 100); +@density-field-pad: ((2) + ((@space-3) - (2)) * $spacing / 100); +@density-list-pad: ((1) + ((@space-2) - (1)) * $spacing / 100); +@density-list-item-pad-hor: ((2) + ((@space-4) - (2)) * $spacing / 100); +@density-list-item-pad-ver: ((2) + ((@space-3) - (2)) * $spacing / 100); +@density-dialog-pad: ((2) + ((@space-6) - (2)) * $spacing / 100); +@density-key-gap: ((1) + ((@space-1) - (1)) * $spacing / 100); // Borders and indicators @border: @line-thin; -@focus-width: if($isCompact, @line-thin, @line-medium); -@focus-offset: if($isCompact, @line-thin, @line-medium); -@edit-width: if($isCompact, @line-medium, @line-thick); +@focus-width: ((@line-thin) + ((@line-medium) - (@line-thin)) * $size / 100); +@focus-offset: ((@line-thin) + ((@line-medium) - (@line-thin)) * $size / 100); +@edit-width: ((@line-medium) + ((@line-thick) - (@line-medium)) * $size / 100); // Drawn inside the widget, for widgets with little room around them (icon buttons) -@focus-inset-width: if($isCompact, @line-thin, @line-medium); -@focus-inverted-gap: if($isCompact, 1, 2dp); +@focus-inset-width: ((@line-thin) + ((@line-medium) - (@line-thin)) * $size / 100); +@focus-inverted-gap: ((1) + ((2dp) - (1)) * $size / 100); // Motion @motion-duration-short: if($animationsEnabled, @duration-short, 0); @@ -148,14 +149,14 @@ @card-gap: @density-container-gap; // Chip (lvgl_chip_create): outlined, inverted when selected -@chip-shape: if($isCompact, @corner-compact, @corner-small); -@chip-pad-hor: if($isCompact, 3, @space-4); -@chip-pad-ver: if($isCompact, 2, @space-2); +@chip-shape: ((@corner-compact) + ((@corner-small) - (@corner-compact)) * $size / 100); +@chip-pad-hor: ((3) + ((@space-4) - (3)) * $spacing / 100); +@chip-pad-ver: ((2) + ((@space-2) - (2)) * $spacing / 100); // Page indicator (lvgl_page_indicator_create): a dot per page, the current page's dot is filled @page-indicator-color: @color-on-surface; -@page-indicator-dot-size: if($isCompact, 6, 8dp); -@page-indicator-gap: if($isCompact, 4, 6dp); +@page-indicator-dot-size: ((6) + ((8dp) - (6)) * $size / 100); +@page-indicator-gap: ((4) + ((6dp) - (4)) * $spacing / 100); // Step indicator (lvgl_step_indicator_create): a line segment per step, finished steps filled, unfinished steps outlined @step-indicator-color: @color-on-surface; @@ -165,11 +166,11 @@ // Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite @badge-color: @color-on-surface; -@badge-size: if($isCompact, 4, 6dp); +@badge-size: ((4) + ((6dp) - (4)) * $size / 100); // Scrollbar @scrollbar-color: @color-on-surface; -@scrollbar-width: if($isCompact, 2, 3dp); +@scrollbar-width: ((2) + ((3dp) - (2)) * $size / 100); @scrollbar-pad: 1; // Button: Material 3 outlined button, inverted when pressed or checked @@ -185,7 +186,7 @@ @button-gap: @density-control-gap; // Filled and tonal icon buttons: filled is ink, tonal is outlined -@icon-button-container-shape: if($isCompact, @corner-compact, @corner-large); +@icon-button-container-shape: ((@corner-compact) + ((@corner-large) - (@corner-compact)) * $size / 100); @icon-button-filled-color: @color-primary; @icon-button-filled-icon-color: @color-on-primary; @icon-button-tonal-color: @color-surface; @@ -194,10 +195,10 @@ // Toolbar: top app bar with a divider, which shows the background like the statusbar does @app-bar-title-color: @color-on-surface; -@app-bar-pad-hor: if($isCompact, 0, @space-1); +@app-bar-pad-hor: ((0) + ((@space-1) - (0)) * $spacing / 100); // Keeps the action buttons off the screen's edge in the compact layout -@app-bar-pad-right: if($isCompact, @app-bar-pad-hor + 2, @app-bar-pad-hor); -@app-bar-gap: if($isCompact, 2, @space-1); +@app-bar-pad-right: ((@app-bar-pad-hor + 2) + ((@app-bar-pad-hor) - (@app-bar-pad-hor + 2)) * $spacing / 100); +@app-bar-gap: ((2) + ((@space-1) - (2)) * $spacing / 100); // The focus ring is drawn inside toolbar buttons, as there's little room around them @app-bar-focus-width: @focus-inset-width; @@ -208,19 +209,19 @@ @edit-ring-width: @edit-width; // Switch -@switch-width: if($isCompact, 25, 40dp); -@switch-height: if($isCompact, 15, 24dp); +@switch-width: ((25) + ((40dp) - (25)) * $size / 100); +@switch-height: ((15) + ((24dp) - (15)) * $size / 100); @switch-track-color: @color-surface; @switch-track-border-color: @color-outline; @switch-track-border-width: @border; @switch-track-selected-color: @color-primary; @switch-handle-color: @color-on-surface; @switch-handle-selected-color: @color-on-primary; -@switch-handle-pad: if($isCompact, -3, -5dp); -@switch-handle-selected-pad: if($isCompact, -2, -3dp); +@switch-handle-pad: ((-3) + ((-5dp) - (-3)) * $size / 100); +@switch-handle-selected-pad: ((-2) + ((-3dp) - (-2)) * $size / 100); // Checkbox -@checkbox-gap: if($isCompact, 3, @space-4); +@checkbox-gap: ((3) + ((@space-4) - (3)) * $spacing / 100); @checkbox-box-color: @color-surface; @checkbox-box-border-color: @color-outline; @checkbox-box-border-width: @border; @@ -237,15 +238,15 @@ // The track is half of LVGL's default thickness (LV_DPI_DEF / 10). The knob is the track height plus its padding on both // sides, so the padding includes the removed thickness to keep the knob's size. @slider-track-height: 8dp; -@slider-handle-pad: if($isCompact, 1, 3dp) + 4dp; +@slider-handle-pad: ((1) + ((3dp) - (1)) * $size / 100) + 4dp; // Sliderbox: in the normal layout, the gap between the slider and its buttons leaves room for the knob, which sticks out at the slider's ends -@sliderbox-gap: if($isCompact, 4, (@slider-track-height / 2) + @slider-handle-pad + @space-2); +@sliderbox-gap: ((4) + (((@slider-track-height / 2) + @slider-handle-pad + @space-2) - (4)) * $spacing / 100); @arc-track-color: @color-on-surface; @arc-track-width: @line-thin; @arc-indicator-color: @color-primary; -@arc-indicator-width: if($isCompact, @line-medium, 4dp); +@arc-indicator-width: ((@line-medium) + ((4dp) - (@line-medium)) * $size / 100); @arc-handle-color: @color-primary; -@arc-handle-pad: if($isCompact, 1, 3dp); +@arc-handle-pad: ((1) + ((3dp) - (1)) * $size / 100); // Text field (textarea, spinbox, dropdown) @field-color: @color-surface; @@ -265,8 +266,8 @@ @menu-border-color: @color-outline; @menu-shape: @shape-field; @menu-max-height: dpi-def * 2; -@menu-item-line-space: if($isCompact, 2, @space-3); -@roller-line-space: if($isCompact, 2, @space-3); +@menu-item-line-space: ((2) + ((@space-3) - (2)) * $spacing / 100); +@roller-line-space: ((2) + ((@space-3) - (2)) * $spacing / 100); // Button matrix and keyboard @keys-pad: @density-key-gap; @@ -280,13 +281,13 @@ @list-pad: @density-list-pad; @list-item-pad-hor: @density-list-item-pad-hor; @list-item-pad-ver: @density-list-item-pad-ver; -@list-item-gap: if($isCompact, 3, @space-4); +@list-item-gap: ((3) + ((@space-4) - (3)) * $spacing / 100); @list-divider-color: @color-outline; // Tabs: the tab bar is a navigation bar, the selected tab is an inverted pill -@tabs-pad: if($isCompact, 2, @space-1); -@tabs-gap: if($isCompact, 2, @space-1); -@tab-pad: if($isCompact, 2, @space-1); +@tabs-pad: ((2) + ((@space-1) - (2)) * $spacing / 100); +@tabs-gap: ((2) + ((@space-1) - (2)) * $spacing / 100); +@tab-pad: ((2) + ((@space-1) - (2)) * $spacing / 100); @tab-shape: @shape-control; @tab-page-pad: @density-container-pad; @@ -296,21 +297,21 @@ @dialog-border-width: @line-medium; @dialog-shape: @shape-dialog; @dialog-pad: @density-dialog-pad; -@dialog-gap: if($isCompact, 2, @space-2); +@dialog-gap: ((2) + ((@space-2) - (2)) * $spacing / 100); // Window @window-header-border-color: @color-outline; -@window-header-pad: if($isCompact, 2, @space-2); +@window-header-pad: ((2) + ((@space-2) - (2)) * $spacing / 100); @window-content-pad: @density-container-pad; // Table, chart, calendar, scale, divider -@table-cell-pad: if($isCompact, 2, @space-3); +@table-cell-pad: ((2) + ((@space-3) - (2)) * $spacing / 100); @table-divider-color: @color-outline; @chart-grid-color: @color-on-surface; -@chart-point-size: if($isCompact, 3, 6dp); -@calendar-pad: if($isCompact, 2, @space-2); +@chart-point-size: ((3) + ((6dp) - (3)) * $size / 100); +@calendar-pad: ((2) + ((@space-2) - (2)) * $spacing / 100); @scale-color: @color-on-surface; -@scale-tick-length: if($isCompact, 3, 6dp); +@scale-tick-length: ((3) + ((6dp) - (3)) * $size / 100); @divider-color: @color-outline; // ============================================================ @@ -563,12 +564,12 @@ app_bar { // The toolbar's first button: the close (navigation) button app_bar_nav_button { - @if ($isCompact) { marginLeft: 4; } + @if ($spacing != 100) { marginLeft: 4 - 4 * $spacing / 100; } } // Compact toolbar buttons are as big as their icon, so their focus ring goes around the outside instead of the inside app_bar_button_focus_ring { - @if ($isCompact) { outlinePad: 0; } + @if ($size / 50 == 0) { outlinePad: 0; } } app_bar_button { diff --git a/Modules/mesh-module/private/mesh/mesh_store.h b/Modules/mesh-module/private/mesh/mesh_store.h index 2b5a35f19..0a7b290de 100644 --- a/Modules/mesh-module/private/mesh/mesh_store.h +++ b/Modules/mesh-module/private/mesh/mesh_store.h @@ -82,6 +82,11 @@ class MeshStore final { error_t get(uint32_t seq, MeshMessage* message) const; + bool isOpen() const { return buffer != nullptr; } + + /** Oldest first: the messages with a seq above @a afterSeq */ + size_t getAfter(uint32_t afterSeq, MeshMessage* messages, size_t maxCount) const; + /** Newest first, see mesh_history_get() */ size_t getHistory(uint32_t beforeSeq, MeshMessage* messages, size_t maxCount) const; diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index 04917519e..c03d93ff7 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -315,9 +316,11 @@ struct MeshHost { MeshNetworkSettings network {}; MeshStore store; tt::std::vector nodes; - /** The saved contacts of every backend */ + /** The saved contacts of the backend that owns the open storage */ tt::std::vector contacts; char activeBackend[MESH_BACKEND_ID_MAX_LENGTH + 1] = {}; + /** The backend whose messages and contacts are open. It's the selected backend, whether or not it is running. */ + char storageBackend[MESH_BACKEND_ID_MAX_LENGTH + 1] = {}; tt::std::deque> calls; // thread @@ -372,24 +375,44 @@ void setStateLocked(MeshHost* host, MeshState state) { constexpr size_t MAX_CONTACTS = 64; constexpr auto* CONTACTS_FILE_NAME = "contacts.bin"; +constexpr auto* STORE_DIRECTORY_NAME = "store"; bool isSameAddress(const MeshAddress& a, const MeshAddress& b) { return a.length == b.length && memcmp(a.bytes, b.bytes, a.length) == 0; } +/** A path in the folder of a backend, where it keeps its messages, contacts and own files. Creates the folder when @a create is set. */ +bool getBackendPath(const char* backend, const char* child, char* path, size_t size, bool create) { + char directory[FILE_MAX_PATH_STRING_LENGTH]; + char backendDirectory[16 + MESH_BACKEND_ID_MAX_LENGTH]; + snprintf(backendDirectory, sizeof(backendDirectory), "backends/%s", backend); + if (service_paths_get_user_data_path(MESH_SERVICE_ID, backendDirectory, directory, sizeof(directory)) != ERROR_NONE) { + return false; + } + if (create) { + const error_t made = directory_make(directory, true); + if (made != ERROR_NONE && made != ERROR_ALREADY_EXISTS) { + LOG_E(TAG, "Failed to create %s", directory); + return false; + } + } + const int written = snprintf(path, size, "%s/%s", directory, child); + return written >= 0 && static_cast(written) < size; +} + /** Requires the global lock */ MeshContact* findContact(MeshHost* host, const MeshAddress& address) { for (auto& contact : host->contacts) { - if (strncmp(contact.backend, host->activeBackend, MESH_BACKEND_ID_MAX_LENGTH) == 0 && isSameAddress(contact.address, address)) { + if (strncmp(contact.backend, host->storageBackend, MESH_BACKEND_ID_MAX_LENGTH) == 0 && isSameAddress(contact.address, address)) { return &contact; } } return nullptr; } -void loadContacts(MeshHost* host) { +void loadContacts(MeshHost* host, const char* backend) { char path[FILE_MAX_PATH_STRING_LENGTH]; - if (service_paths_get_user_data_path(MESH_SERVICE_ID, CONTACTS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { + if (!getBackendPath(backend, CONTACTS_FILE_NAME, path, sizeof(path), false)) { return; } FILE* file = fopen(path, "rb"); @@ -409,15 +432,8 @@ void loadContacts(MeshHost* host) { /** Requires the global lock */ void saveContacts(MeshHost* host) { - char directory[FILE_MAX_PATH_STRING_LENGTH]; char path[FILE_MAX_PATH_STRING_LENGTH]; - if (service_paths_get_user_data_directory(MESH_SERVICE_ID, directory, sizeof(directory)) != ERROR_NONE || - service_paths_get_user_data_path(MESH_SERVICE_ID, CONTACTS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { - return; - } - const error_t made = directory_make(directory, true); - if (made != ERROR_NONE && made != ERROR_ALREADY_EXISTS) { - LOG_E(TAG, "Failed to create %s", directory); + if (host->storageBackend[0] == '\0' || !getBackendPath(host->storageBackend, CONTACTS_FILE_NAME, path, sizeof(path), true)) { return; } FILE* file = fopen(path, "wb"); @@ -431,6 +447,34 @@ void saveContacts(MeshHost* host) { fclose(file); } +/** + * Opens the messages and contacts of @a api, and closes those of the backend that was open. Without a backend, nothing is open. + * Every backend has its own, so the data of one backend never shows up in another. + * Requires the global lock. + * @retval false when the message store couldn't be opened + */ +bool switchStorage(MeshHost* host, const MeshBackendApi* api) { + const char* id = (api != nullptr) ? api->id : ""; + if (strncmp(host->storageBackend, id, MESH_BACKEND_ID_MAX_LENGTH) == 0 && (api == nullptr || host->store.isOpen())) { + return true; + } + host->store.close(); + host->contacts.clear(); + host->storageBackend[0] = '\0'; + if (api == nullptr) { + return true; + } + char storePath[FILE_MAX_PATH_STRING_LENGTH]; + if (!getBackendPath(id, STORE_DIRECTORY_NAME, storePath, sizeof(storePath), true) || host->store.open(storePath) != ERROR_NONE) { + LOG_E(TAG, "Failed to open the message store of %s", id); + host->store.close(); + return false; + } + strncpy(host->storageBackend, id, MESH_BACKEND_ID_MAX_LENGTH); + loadContacts(host, id); + return true; +} + /** * Save a contact of the active backend. An existing contact is renamed when @a rename is set. * Requires the global lock. @@ -470,7 +514,7 @@ error_t addContact(MeshHost* host, const MeshAddress& address, const char* name, return ERROR_RESOURCE; } MeshContact contact {}; - strncpy(contact.backend, host->activeBackend, MESH_BACKEND_ID_MAX_LENGTH); + strncpy(contact.backend, host->storageBackend, MESH_BACKEND_ID_MAX_LENGTH); contact.address = address; strncpy(contact.name, resolvedName, MESH_NAME_MAX_LENGTH); host->contacts.push_back(contact); @@ -683,6 +727,15 @@ MeshNetworkSettings getNetwork(const MeshBackendApi* api, size_t preset, const M void configure(MeshHost* host) { deactivateBackend(host); + // The messages and contacts of the selected backend are available when its radio is off, and no other backend's are + lock(); + const bool storageReady = switchStorage(host, getSelectedBackend()); + unlock(); + if (!storageReady) { + setStateLocked(host, MESH_STATE_ERROR); + return; + } + if (host->radio == nullptr) { setStateLocked(host, MESH_STATE_NO_RADIO); return; @@ -1007,6 +1060,122 @@ void releaseResources(MeshHost* host) { event_group_destruct(&host->callEvents); } +bool isValidBackendId(const char* id) { + if (id[0] == '\0') { + return false; + } + for (const char* c = id; *c != '\0'; c++) { + if (!isalnum(static_cast(*c)) && *c != '_' && *c != '-') { + return false; + } + } + return true; +} + +/** A message store of a backend that is open while messages are moved to it */ +struct MigrationTarget { + char id[MESH_BACKEND_ID_MAX_LENGTH + 1]; + MeshStore* store; +}; + +/** @return false when messages couldn't be moved, in which case the shared store is kept to try again */ +bool migrateSharedMessages(const char* sharedPath) { + MeshStore shared; + if (shared.open(sharedPath) != ERROR_NONE) { + return false; + } + MigrationTarget targets[MAX_BACKENDS] = {}; + size_t targetCount = 0; + bool ok = true; + tt::std::vector batch(8); + uint32_t afterSeq = 0; + for (size_t count = shared.getAfter(afterSeq, batch.data(), batch.size()); count > 0; count = shared.getAfter(afterSeq, batch.data(), batch.size())) { + for (size_t i = 0; i < count; i++) { + MeshMessage& message = batch[i]; + afterSeq = message.seq; + message.backend[MESH_BACKEND_ID_MAX_LENGTH] = '\0'; + if (!isValidBackendId(message.backend)) { + continue; + } + MigrationTarget* target = nullptr; + for (size_t t = 0; t < targetCount; t++) { + if (strcmp(targets[t].id, message.backend) == 0) { + target = &targets[t]; + } + } + if (target == nullptr && targetCount < MAX_BACKENDS) { + char path[FILE_MAX_PATH_STRING_LENGTH]; + auto* store = tt::newObject(); + if (store != nullptr && getBackendPath(message.backend, STORE_DIRECTORY_NAME, path, sizeof(path), true) && store->open(path) == ERROR_NONE) { + target = &targets[targetCount++]; + strncpy(target->id, message.backend, MESH_BACKEND_ID_MAX_LENGTH); + target->store = store; + } else { + tt::deleteObject(store); + } + } + if (target == nullptr || target->store->add(&message) != ERROR_NONE) { + ok = false; + } + } + } + for (size_t t = 0; t < targetCount; t++) { + targets[t].store->close(); + tt::deleteObject(targets[t].store); + } + shared.close(); + return ok; +} + +/** @return false when contacts couldn't be moved, in which case the shared file is kept to try again */ +bool migrateSharedContacts(const char* sharedPath) { + FILE* file = fopen(sharedPath, "rb"); + if (file == nullptr) { + return false; + } + bool ok = true; + MeshContact contact; + while (fread(&contact, sizeof(contact), 1, file) == 1) { + contact.backend[MESH_BACKEND_ID_MAX_LENGTH] = '\0'; + if (!isValidBackendId(contact.backend)) { + continue; + } + char path[FILE_MAX_PATH_STRING_LENGTH]; + FILE* target = getBackendPath(contact.backend, CONTACTS_FILE_NAME, path, sizeof(path), true) ? fopen(path, "ab") : nullptr; + if (target == nullptr || fwrite(&contact, sizeof(contact), 1, target) != 1) { + ok = false; + } + if (target != nullptr) { + fclose(target); + } + } + fclose(file); + return ok; +} + +/** + * Messages and contacts used to be kept together for all backends. They are moved to the folders of their backends once, + * and the shared ones are removed when that worked. + */ +void migrateSharedStorage() { + char storePath[FILE_MAX_PATH_STRING_LENGTH]; + char contactsPath[FILE_MAX_PATH_STRING_LENGTH]; + if (service_paths_get_user_data_path(MESH_SERVICE_ID, STORE_DIRECTORY_NAME, storePath, sizeof(storePath)) != ERROR_NONE || + service_paths_get_user_data_path(MESH_SERVICE_ID, CONTACTS_FILE_NAME, contactsPath, sizeof(contactsPath)) != ERROR_NONE) { + return; + } + if (directory_exists(storePath) && migrateSharedMessages(storePath)) { + directory_remove_tree(storePath); + } + FILE* file = fopen(contactsPath, "rb"); + if (file != nullptr) { + fclose(file); + if (migrateSharedContacts(contactsPath)) { + file_remove(contactsPath); + } + } +} + error_t onStart(ServiceInstance*, void* data) { auto* host = static_cast(data); @@ -1018,14 +1187,7 @@ error_t onStart(ServiceInstance*, void* data) { return ERROR_RESOURCE; } - char storePath[FILE_MAX_PATH_STRING_LENGTH]; - if (service_paths_get_user_data_path(MESH_SERVICE_ID, "store", storePath, sizeof(storePath)) != ERROR_NONE || - host->store.open(storePath) != ERROR_NONE) { - LOG_E(TAG, "Failed to open the message store"); - releaseResources(host); - return ERROR_RESOURCE; - } - loadContacts(host); + migrateSharedStorage(); if (!attachRadio(host)) { LOG_W(TAG, "No mesh radio found yet"); @@ -1052,7 +1214,6 @@ error_t onStart(ServiceInstance*, void* data) { lock(); service = nullptr; unlock(); - host->store.close(); releaseResources(host); return ERROR_RESOURCE; } @@ -1074,7 +1235,7 @@ void onStop(ServiceInstance*, void* data) { host->thread = nullptr; lock(); - host->store.close(); + switchStorage(host, nullptr); setState(host, MESH_STATE_OFF); unlock(); @@ -1888,7 +2049,7 @@ error_t mesh_conversation_get(const MeshAddress* peer, uint8_t channel, uint32_t lock(); error_t result = ERROR_INVALID_STATE; if (service != nullptr) { - *count = service->store.getConversation(service->activeBackend, peer, channel, before_seq, messages, max_count); + *count = service->store.getConversation(service->storageBackend, peer, channel, before_seq, messages, max_count); result = ERROR_NONE; } unlock(); @@ -1901,7 +2062,7 @@ error_t mesh_get_contacts(MeshContact* contacts, size_t max_count, size_t* count if (service != nullptr) { *count = 0; for (const auto& contact : service->contacts) { - if (*count < max_count && strncmp(contact.backend, service->activeBackend, MESH_BACKEND_ID_MAX_LENGTH) == 0) { + if (*count < max_count && strncmp(contact.backend, service->storageBackend, MESH_BACKEND_ID_MAX_LENGTH) == 0) { contacts[(*count)++] = contact; } } diff --git a/Modules/mesh-module/source/mesh_store.cpp b/Modules/mesh-module/source/mesh_store.cpp index 675fd7434..690109dab 100644 --- a/Modules/mesh-module/source/mesh_store.cpp +++ b/Modules/mesh-module/source/mesh_store.cpp @@ -450,6 +450,16 @@ size_t MeshStore::getConversation(const char* backend, const MeshAddress* peer, return count; } +size_t MeshStore::getAfter(uint32_t afterSeq, MeshMessage* messages, size_t maxCount) const { + size_t count = 0; + for (auto iterator = index.begin(); iterator != index.end() && count < maxCount; ++iterator) { + if (iterator->seq > afterSeq && readEntry(*iterator, &messages[count]) == ERROR_NONE) { + count++; + } + } + return count; +} + size_t MeshStore::getHistory(uint32_t beforeSeq, MeshMessage* messages, size_t maxCount) const { size_t count = 0; for (auto iterator = index.rbegin(); iterator != index.rend() && count < maxCount; ++iterator) { diff --git a/Modules/mesh-module/tests/source/service_test.cpp b/Modules/mesh-module/tests/source/service_test.cpp index 2e6c3a1c7..1ff8f8a24 100644 --- a/Modules/mesh-module/tests/source/service_test.cpp +++ b/Modules/mesh-module/tests/source/service_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -25,6 +26,8 @@ #include #include +#include + namespace { // region Fake data filesystem, so the service can store messages in a temporary directory @@ -270,6 +273,81 @@ TEST_CASE("mesh service sends and receives through the active backend") { CHECK_EQ(mesh_backend_register(&loopbackApi), ERROR_ALREADY_EXISTS); REQUIRE_EQ(service_manager_add(&mesh_service_manifest, false), ERROR_NONE); + SUBCASE("messages and contacts that all backends shared move to their backends") { + MeshBackendApi spareApi = loopbackApi; + spareApi.id = "spare"; + REQUIRE_EQ(mesh_backend_register(&spareApi), ERROR_NONE); + + // The layout of before: one store and one contacts file, with the backend in each record + char storePath[FILE_MAX_PATH_STRING_LENGTH]; + char contactsPath[FILE_MAX_PATH_STRING_LENGTH]; + REQUIRE_EQ(service_paths_get_user_data_path(MESH_SERVICE_ID, "store", storePath, sizeof(storePath)), ERROR_NONE); + REQUIRE_EQ(service_paths_get_user_data_path(MESH_SERVICE_ID, "contacts.bin", contactsPath, sizeof(contactsPath)), ERROR_NONE); + { + MeshStore shared; + REQUIRE_EQ(shared.open(storePath), ERROR_NONE); + for (const char* backend : { "loopback", "spare", "loopback", "../escape" }) { + MeshMessage message = {}; + strcpy(message.backend, backend); + strcpy(message.text, backend); + REQUIRE_EQ(shared.add(&message), ERROR_NONE); + } + shared.close(); + } + { + FILE* file = fopen(contactsPath, "wb"); + REQUIRE_NE(file, nullptr); + for (const char* backend : { "loopback", "spare" }) { + MeshContact contact = {}; + strcpy(contact.backend, backend); + contact.address = { .length = 1, .bytes = { 0x42 } }; + strcpy(contact.name, backend); + REQUIRE_EQ(fwrite(&contact, sizeof(contact), 1, file), 1); + } + fclose(file); + } + + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + REQUIRE_EQ(mesh_set_backend("loopback"), ERROR_NONE); + REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + + // The shared files are gone, and each backend has its own + CHECK_FALSE(directory_exists(storePath)); + CHECK_EQ(access(contactsPath, F_OK), -1); + + MeshMessage messages[4]; + size_t count = 0; + REQUIRE_EQ(mesh_history_get(0, messages, 4, &count), ERROR_NONE); + CHECK_EQ(count, 2); + for (size_t i = 0; i < count; i++) { + CHECK_EQ(std::string(messages[i].backend), "loopback"); + } + MeshContact contacts[4]; + REQUIRE_EQ(mesh_get_contacts(contacts, 4, &count), ERROR_NONE); + REQUIRE_EQ(count, 1); + CHECK_EQ(std::string(contacts[0].name), "loopback"); + + REQUIRE_EQ(mesh_set_backend("spare"), ERROR_NONE); + REQUIRE(waitFor([] { + char id[MESH_BACKEND_ID_MAX_LENGTH + 1]; + return mesh_get_state() == MESH_STATE_ON && mesh_get_backend(id, sizeof(id)) == ERROR_NONE && std::string(id) == "spare"; + })); + REQUIRE_EQ(mesh_history_get(0, messages, 4, &count), ERROR_NONE); + CHECK_EQ(count, 1); + CHECK_EQ(std::string(messages[0].text), "spare"); + REQUIRE_EQ(mesh_get_contacts(contacts, 4, &count), ERROR_NONE); + REQUIRE_EQ(count, 1); + CHECK_EQ(std::string(contacts[0].name), "spare"); + + REQUIRE_EQ(mesh_set_backend("loopback"), ERROR_NONE); + REQUIRE(waitFor([] { + char id[MESH_BACKEND_ID_MAX_LENGTH + 1]; + return mesh_get_state() == MESH_STATE_ON && mesh_get_backend(id, sizeof(id)) == ERROR_NONE && std::string(id) == "loopback"; + })); + REQUIRE_EQ(mesh_backend_unregister(&spareApi), ERROR_NONE); + } + SUBCASE("without a region the radio stays off") { REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); CHECK(waitFor([] { return mesh_get_state() == MESH_STATE_NO_REGION; })); @@ -317,6 +395,68 @@ TEST_CASE("mesh service sends and receives through the active backend") { REQUIRE_EQ(mesh_set_preset(nullptr, "Default"), ERROR_NONE); } + SUBCASE("each backend has its own messages and contacts") { + MeshBackendApi spareApi = loopbackApi; + spareApi.id = "spare"; + REQUIRE_EQ(mesh_backend_register(&spareApi), ERROR_NONE); + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + REQUIRE_EQ(mesh_set_backend("loopback"), ERROR_NONE); + REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + + const MeshAddress alice = { .length = 1, .bytes = { 0x42 } }; + uint32_t seq = 0; + REQUIRE_EQ(mesh_send_text(nullptr, 0, "from loopback", &seq), ERROR_NONE); + REQUIRE_EQ(mesh_add_contact(&alice, "alice"), ERROR_NONE); + + const auto countHistory = [] { + MeshMessage messages[4]; + size_t count = 0; + return mesh_history_get(0, messages, 4, &count) == ERROR_NONE ? count : 99; + }; + const auto countConversation = [] { + MeshMessage messages[4]; + size_t count = 0; + return mesh_conversation_get(nullptr, 0, 0, messages, 4, &count) == ERROR_NONE ? count : 99; + }; + const auto countContacts = [] { + MeshContact contacts[4]; + size_t count = 0; + return mesh_get_contacts(contacts, 4, &count) == ERROR_NONE ? count : 99; + }; + CHECK_EQ(countHistory(), 1); + CHECK_EQ(countConversation(), 1); + CHECK_EQ(countContacts(), 1); + + // Another backend starts with nothing, and what it stores stays with it + REQUIRE_EQ(mesh_set_backend("spare"), ERROR_NONE); + REQUIRE(waitFor([] { + char id[MESH_BACKEND_ID_MAX_LENGTH + 1]; + return mesh_get_state() == MESH_STATE_ON && mesh_get_backend(id, sizeof(id)) == ERROR_NONE && std::string(id) == "spare"; + })); + CHECK_EQ(countHistory(), 0); + CHECK_EQ(countConversation(), 0); + CHECK_EQ(countContacts(), 0); + REQUIRE_EQ(mesh_send_text(nullptr, 0, "from spare", &seq), ERROR_NONE); + CHECK_EQ(countHistory(), 1); + MeshMessage message; + size_t count = 0; + REQUIRE_EQ(mesh_history_get(0, &message, 1, &count), ERROR_NONE); + CHECK_EQ(std::string(message.text), "from spare"); + + REQUIRE_EQ(mesh_set_backend("loopback"), ERROR_NONE); + REQUIRE(waitFor([] { + char id[MESH_BACKEND_ID_MAX_LENGTH + 1]; + return mesh_get_state() == MESH_STATE_ON && mesh_get_backend(id, sizeof(id)) == ERROR_NONE && std::string(id) == "loopback"; + })); + CHECK_EQ(countHistory(), 1); + REQUIRE_EQ(mesh_history_get(0, &message, 1, &count), ERROR_NONE); + CHECK_EQ(std::string(message.text), "from loopback"); + CHECK_EQ(countContacts(), 1); + + REQUIRE_EQ(mesh_backend_unregister(&spareApi), ERROR_NONE); + } + SUBCASE("a backend that isn't selected can be configured") { MeshBackendApi spareApi = loopbackApi; spareApi.id = "spare"; diff --git a/Modules/mesh-module/tests/source/store_test.cpp b/Modules/mesh-module/tests/source/store_test.cpp index b875e967f..a41b956ec 100644 --- a/Modules/mesh-module/tests/source/store_test.cpp +++ b/Modules/mesh-module/tests/source/store_test.cpp @@ -268,3 +268,26 @@ TEST_CASE("MeshStore reads the messages of one conversation") { store.close(); } } + +TEST_CASE("MeshStore reads messages oldest first after a sequence number") { + const std::string directory = makeTempDirectory(); + MeshStore store; + REQUIRE_EQ(store.open(directory.c_str()), ERROR_NONE); + for (const char* text : { "one", "two", "three" }) { + MeshMessage message = makeMessage(text, false, MESH_DELIVERY_RECEIVED); + REQUIRE_EQ(store.add(&message), ERROR_NONE); + } + CHECK(store.isOpen()); + + MeshMessage messages[4]; + REQUIRE_EQ(store.getAfter(0, messages, 4), 3); + CHECK_EQ(std::string(messages[0].text), "one"); + CHECK_EQ(std::string(messages[2].text), "three"); + + REQUIRE_EQ(store.getAfter(messages[0].seq, messages, 1), 1); + CHECK_EQ(std::string(messages[0].text), "two"); + CHECK_EQ(store.getAfter(messages[0].seq + 1, messages, 4), 0); + + store.close(); + CHECK_FALSE(store.isOpen()); +} diff --git a/Tactility/Include/Tactility/network/Ntp.h b/Tactility/Include/Tactility/network/Ntp.h index 42bc199ba..f907ac449 100644 --- a/Tactility/Include/Tactility/network/Ntp.h +++ b/Tactility/Include/Tactility/network/Ntp.h @@ -4,4 +4,7 @@ namespace tt::network::ntp { bool isSynced(); +/** Persist the current time as the minimum time after the next boot */ +void storeTimeInNvs(); + } \ No newline at end of file diff --git a/Tactility/Include/Tactility/service/gpstime/GpsTimeSyncService.h b/Tactility/Include/Tactility/service/gpstime/GpsTimeSyncService.h new file mode 100644 index 000000000..37ce03aa1 --- /dev/null +++ b/Tactility/Include/Tactility/service/gpstime/GpsTimeSyncService.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +#include +#include + +#include +#include +#include +#include + +struct SystemEvent; + +namespace tt::service::gpstime { + +/** Largest difference between the system clock and GPS time that is left alone once NTP has synced */ +constexpr int64_t MAX_DRIFT_MS = 5000; + +/** @return true when the system clock should be set to GPS time */ +inline bool shouldApplyGpsTime(int64_t gpsTimeMs, int64_t systemTimeMs, bool ntpSynced) { + return !ntpSynced || std::llabs(gpsTimeMs - systemTimeMs) > MAX_DRIFT_MS; +} + +/** + * Sets the system clock from the first GPS time announced after boot. + * Only runs when the user enabled settings::isGpsTimeSyncAtBoot() and the device has a GPS. + */ +class GpsTimeSyncService final : public Service { + + TaskEventGroup eventGroup {}; + LocationSubscription subscription {}; + bool eventGroupConstructed = false; + bool eventSubscribed = false; + std::atomic subscribed = false; + std::atomic done = false; + std::unique_ptr timer; + + static bool setSystemTime(int64_t timeMs); + void onTimer(); + static void onLocationTimeTrampoline(struct SystemEvent* event, void* context); + +public: + + /** Handles the first announced time after boot, later calls are ignored */ + void onLocationTime(int64_t timeMs); + + bool onStart(ServiceContext& serviceContext) override; + void onStop(ServiceContext& serviceContext) override; +}; + +} // namespace tt::service::gpstime diff --git a/Tactility/Include/Tactility/settings/AppearanceSettings.h b/Tactility/Include/Tactility/settings/AppearanceSettings.h index 5da8a4ade..a6546502e 100644 --- a/Tactility/Include/Tactility/settings/AppearanceSettings.h +++ b/Tactility/Include/Tactility/settings/AppearanceSettings.h @@ -13,12 +13,8 @@ enum class ThemeMode { Dark }; -enum class DensityMode { - /** Regular or compact, as configured for the device */ - DeviceDefault, - Regular, - Compact -}; +/** The steps of the spacing and size sliders, in percent */ +constexpr uint8_t DENSITY_STEP = 20; struct AppearanceSettings { /** Default text font size in pixels, or 0 for the device's default size */ @@ -30,7 +26,10 @@ struct AppearanceSettings { ThemeMode themeMode = ThemeMode::DeviceDefault; /** Use the monochrome theme, also on colour displays */ bool monoTheme = false; - DensityMode densityMode = DensityMode::DeviceDefault; + /** Padding and gaps from 0 (compact) to 100 (regular), or empty for the device's default */ + std::optional spacing; + /** Control sizes, corner radii and borders from 0 (compact) to 100 (regular), or empty for the device's default */ + std::optional size; bool animationsEnabled = true; /** Theme colours as 0xRRGGBB, or empty for the theme's default colour */ std::optional primaryColor; diff --git a/Tactility/Include/Tactility/settings/Time.h b/Tactility/Include/Tactility/settings/Time.h index 2ea05f49d..4068d7030 100644 --- a/Tactility/Include/Tactility/settings/Time.h +++ b/Tactility/Include/Tactility/settings/Time.h @@ -32,4 +32,9 @@ bool isTimeFormat24Hour(); */ void setTimeFormat24Hour(bool show24Hour); +/** @return true when the clock should be set from GPS once after boot. */ +bool isGpsTimeSyncAtBoot(); + +void setGpsTimeSyncAtBoot(bool enabled); + } diff --git a/Tactility/Kconfig b/Tactility/Kconfig index 76cd114be..8a60a198b 100644 --- a/Tactility/Kconfig +++ b/Tactility/Kconfig @@ -27,6 +27,18 @@ menu "Tactility App" range 10 32 help Default text font size in pixels. Other text and icon font sizes are derived from it. + config TT_UI_SPACING + int "Default UI spacing (%)" + default 100 + range 0 100 + help + Default spacing of widgets, from 0 (compact) to 100 (regular). Users can change it in the Appearance app. + config TT_UI_SIZE + int "Default UI size (%)" + default 100 + range 0 100 + help + Default size of widgets, from 0 (compact) to 100 (regular). Users can change it in the Appearance app. config TT_AUTO_START_APP_ID string "Auto Start App ID" default "" diff --git a/Tactility/Private/Tactility/app/boot/BootScreen.h b/Tactility/Private/Tactility/app/boot/BootScreen.h index 81e6035d5..7d20ba9fa 100644 --- a/Tactility/Private/Tactility/app/boot/BootScreen.h +++ b/Tactility/Private/Tactility/app/boot/BootScreen.h @@ -86,4 +86,18 @@ std::string getInputPrompt(const std::string& action); /** Blocks until any key is pressed on a keyboard or the screen is touched. */ void waitForInput(); +enum class BootChoice { + Reboot, + PowerOff +}; + +/** + * Shows the screen with the options "Reboot" and "Power off" below the lines, and blocks until one is confirmed. + * Keyboards and encoders move the selection with the arrow keys, tab or the wheel, and confirm with enter. + * A touch screen moves the selection with a tap and confirms with a long press. + * @param[in] logo a PNG image, or empty for no logo + * @param[in] lines text lines above the options + */ +BootChoice waitForBootChoice(BootScreen& screen, const BootLogo& logo, const std::vector& lines); + } diff --git a/Tactility/Private/Tactility/lvgl/SystemBars.h b/Tactility/Private/Tactility/lvgl/SystemBars.h index df190bb74..2a5b2eca9 100644 --- a/Tactility/Private/Tactility/lvgl/SystemBars.h +++ b/Tactility/Private/Tactility/lvgl/SystemBars.h @@ -32,6 +32,12 @@ void systemBarsDetach(); */ void systemBarsRefresh(); +/** + * Shows or hides the statusbar, e.g. for a full screen app. A window must show it again when its widgets are destroyed. + * @warning Caller must hold the LVGL lock. + */ +void systemBarsSetStatusbarVisible(bool visible); + /** * @warning Caller must hold the LVGL lock. * @return the layout that is shown diff --git a/Tactility/Source/InitServices.cpp b/Tactility/Source/InitServices.cpp index c9e55601f..775bdd34b 100644 --- a/Tactility/Source/InitServices.cpp +++ b/Tactility/Source/InitServices.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include namespace tt { @@ -34,6 +35,7 @@ namespace tt { namespace service { // Primary namespace audio { extern const ServiceManifest manifest; } + namespace gpstime { extern const ServiceManifest manifest; } #ifdef ESP_PLATFORM namespace rtctime { extern const ServiceManifest manifest; } #endif @@ -62,6 +64,10 @@ void registerAndStartServices() { check(posix_services_start() == ERROR_NONE); #endif + if (device_exists_of_type(&GPS_TYPE)) { + addService(service::gpstime::manifest); + } + #if defined(ESP_PLATFORM) if (device_exists_of_type(&RTC_TYPE)) { addService(service::rtctime::manifest); diff --git a/Tactility/Source/app/TileGrid.cpp b/Tactility/Source/app/TileGrid.cpp index eb6d1b0bf..135f13a88 100644 --- a/Tactility/Source/app/TileGrid.cpp +++ b/Tactility/Source/app/TileGrid.cpp @@ -507,7 +507,7 @@ void TileGrid::createWidgets(lv_obj_t* parent) { pageIndicator = lvgl_page_indicator_create(parent); centerPageIndicator(pageIndicator); // Keeps the dots off the screen's bottom edge - lv_obj_set_style_margin_bottom(pageIndicator, lvgl_theme_is_compact() ? LV_DPX(2) : LV_DPX(8), LV_STATE_DEFAULT); + lv_obj_set_style_margin_bottom(pageIndicator, lvgl_theme_lerp(LV_DPX(2), LV_DPX(8), lvgl_theme_get_spacing()), LV_STATE_DEFAULT); finishCreate(parent); } @@ -590,7 +590,7 @@ void TileGrid::setBarSideContainer(lv_obj_t* sideContainer) { lv_obj_move_to_index(pageIndicator, lv_obj_get_index(grid) + 1); centerPageIndicator(pageIndicator); // Keeps the dots off the screen's bottom edge - lv_obj_set_style_margin_bottom(pageIndicator, lvgl_theme_is_compact() ? 1 : 2, LV_STATE_DEFAULT); + lv_obj_set_style_margin_bottom(pageIndicator, lvgl_theme_lerp(1, 2, lvgl_theme_get_spacing()), LV_STATE_DEFAULT); } else { lv_obj_set_parent(bottomBar, barParent); lv_obj_move_to_index(bottomBar, lv_obj_get_index(grid) + 1); diff --git a/Tactility/Source/app/appearancesettings/AppearanceSettings.cpp b/Tactility/Source/app/appearancesettings/AppearanceSettings.cpp index aae7a89b3..3fa215ca3 100644 --- a/Tactility/Source/app/appearancesettings/AppearanceSettings.cpp +++ b/Tactility/Source/app/appearancesettings/AppearanceSettings.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include @@ -138,8 +139,8 @@ struct Context { lv_obj_t* errorLabel = nullptr; lv_obj_t* lightChip = nullptr; lv_obj_t* darkChip = nullptr; - lv_obj_t* regularDensityChip = nullptr; - lv_obj_t* compactDensityChip = nullptr; + lv_obj_t* spacingSlider = nullptr; + lv_obj_t* sizeSlider = nullptr; lv_obj_t* regularThemeChip = nullptr; lv_obj_t* monoThemeChip = nullptr; // Shown for the regular theme: the "Theme Colors" title and card @@ -210,21 +211,16 @@ bool isDeviceDefaultDark() { return defaults.is_dark; } -bool isDeviceDefaultCompact() { +uint8_t getSpacing(const settings::appearance::AppearanceSettings& settings) { LvglThemeSettings defaults; lvgl_theme_get_default_settings(&defaults); - return defaults.is_compact; + return settings.spacing.value_or(defaults.spacing); } -bool isCompact(const settings::appearance::AppearanceSettings& settings) { - switch (settings.densityMode) { - case settings::appearance::DensityMode::Regular: - return false; - case settings::appearance::DensityMode::Compact: - return true; - default: - return isDeviceDefaultCompact(); - } +uint8_t getSize(const settings::appearance::AppearanceSettings& settings) { + LvglThemeSettings defaults; + lvgl_theme_get_default_settings(&defaults); + return settings.size.value_or(defaults.size); } bool isDark(const settings::appearance::AppearanceSettings& settings) { @@ -264,9 +260,10 @@ void updateThemeWidgets(Context* ctx) { const bool dark = isDark(ctx->pendingSettings); setChecked(ctx->lightChip, !dark); setChecked(ctx->darkChip, dark); - const bool compact = isCompact(ctx->pendingSettings); - setChecked(ctx->regularDensityChip, !compact); - setChecked(ctx->compactDensityChip, compact); + if (ctx->spacingSlider != nullptr) { + lvgl_sliderbox_set_value(ctx->spacingSlider, getSpacing(ctx->pendingSettings), LV_ANIM_OFF); + lvgl_sliderbox_set_value(ctx->sizeSlider, getSize(ctx->pendingSettings), LV_ANIM_OFF); + } if (ctx->regularThemeChip != nullptr) { setChecked(ctx->regularThemeChip, !ctx->pendingSettings.monoTheme); setChecked(ctx->monoThemeChip, ctx->pendingSettings.monoTheme); @@ -316,22 +313,23 @@ void onDarkPressed(lv_event_t* event) { setDark(static_cast(lv_event_get_user_data(event)), true); } -void setCompact(Context* ctx, bool compact) { +void onSpacingChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + LvglThemeSettings defaults; + lvgl_theme_get_default_settings(&defaults); + const auto value = static_cast(lvgl_sliderbox_get_value(ctx->spacingSlider)); // The device default is kept when it matches, so it doesn't count as a change - if (compact == isDeviceDefaultCompact()) { - ctx->pendingSettings.densityMode = settings::appearance::DensityMode::DeviceDefault; - } else { - ctx->pendingSettings.densityMode = compact ? settings::appearance::DensityMode::Compact : settings::appearance::DensityMode::Regular; - } + ctx->pendingSettings.spacing = value == defaults.spacing ? std::nullopt : std::optional(value); updateWidgets(ctx); } -void onRegularDensityPressed(lv_event_t* event) { - setCompact(static_cast(lv_event_get_user_data(event)), false); -} - -void onCompactDensityPressed(lv_event_t* event) { - setCompact(static_cast(lv_event_get_user_data(event)), true); +void onSizeChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + LvglThemeSettings defaults; + lvgl_theme_get_default_settings(&defaults); + const auto value = static_cast(lvgl_sliderbox_get_value(ctx->sizeSlider)); + ctx->pendingSettings.size = value == defaults.size ? std::nullopt : std::optional(value); + updateWidgets(ctx); } void onRegularThemePressed(lv_event_t* event) { @@ -556,9 +554,16 @@ void createThemeCard(lv_obj_t* parent, Context* ctx) { ctx->monoThemeChip = createChip(style_row, "Mono", onMonoThemePressed, ctx); } - auto* density_row = createLabeledRow(card, "Density"); - ctx->regularDensityChip = createChip(density_row, "Regular", onRegularDensityPressed, ctx); - ctx->compactDensityChip = createChip(density_row, "Compact", onCompactDensityPressed, ctx); + // 0% is compact and 100% is regular + lv_label_set_text(lv_label_create(card), "Spacing (%)"); + ctx->spacingSlider = lvgl_sliderbox_create(card, 0, 100, settings::appearance::DENSITY_STEP, getSpacing(ctx->pendingSettings)); + lv_obj_set_width(ctx->spacingSlider, LV_PCT(100)); + lvgl_sliderbox_add_value_changed_cb(ctx->spacingSlider, onSpacingChanged, ctx); + + lv_label_set_text(lv_label_create(card), "Size (%)"); + ctx->sizeSlider = lvgl_sliderbox_create(card, 0, 100, settings::appearance::DENSITY_STEP, getSize(ctx->pendingSettings)); + lv_obj_set_width(ctx->sizeSlider, LV_PCT(100)); + lvgl_sliderbox_add_value_changed_cb(ctx->sizeSlider, onSizeChanged, ctx); auto* animations_row = createLabeledRow(card, "Animations"); auto* animations_switch = lv_switch_create(animations_row); @@ -792,8 +797,8 @@ void destroyWidgets(void* userData) { ctx->errorLabel = nullptr; ctx->lightChip = nullptr; ctx->darkChip = nullptr; - ctx->regularDensityChip = nullptr; - ctx->compactDensityChip = nullptr; + ctx->spacingSlider = nullptr; + ctx->sizeSlider = nullptr; ctx->regularThemeChip = nullptr; ctx->monoThemeChip = nullptr; ctx->colorsTitle = nullptr; diff --git a/Tactility/Source/app/boot/BootInit.cpp b/Tactility/Source/app/boot/BootInit.cpp index e93034760..4cf2c1cab 100644 --- a/Tactility/Source/app/boot/BootInit.cpp +++ b/Tactility/Source/app/boot/BootInit.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -177,6 +178,56 @@ void reboot() { #endif } +bool isPowerOffSupported() { + bool supported = false; + device_for_each_of_type(&POWER_SUPPLY_TYPE, &supported, [](Device* device, void* context) { + if (device_is_ready(device) && power_supply_supports_power_off(device)) { + *static_cast(context) = true; + return false; + } + return true; + }); + return supported; +} + +/** @return false when no power supply could power off the system */ +bool powerOff() { + bool succeeded = false; + device_for_each_of_type(&POWER_SUPPLY_TYPE, &succeeded, [](Device* device, void* context) { + if (device_is_ready(device) && power_supply_supports_power_off(device) && power_supply_power_off(device) == ERROR_NONE) { + *static_cast(context) = true; + } + return true; + }); + return succeeded; +} + +/** Offers reboot and power off, or only a reboot when the device can't power off */ +void showMessageAndRebootOrPowerOff(BootScreen& screen, const std::vector& lines) { + if (!isPowerOffSupported()) { + auto with_prompt = lines; + with_prompt.push_back(getInputPrompt("reboot")); + screen.show({}, with_prompt); + waitForInput(); + reboot(); + return; + } + + auto message = lines; + while (waitForBootChoice(screen, {}, message) == BootChoice::PowerOff) { + if (powerOff()) { + // Waits for the power to go away + while (true) { + delay_millis(1000); + } + } + LOG_E(TAG, "Power off failed"); + message = lines; + message.push_back("Power off failed."); + } + reboot(); +} + void waitForInputAndReboot() { waitForInput(); reboot(); @@ -255,8 +306,7 @@ bool bootInit(TickType_t startTime) { registerApps(); if (sd_card_missing) { - screen.show({}, { "SD card not found.", "Please insert one and reboot.", getInputPrompt("reboot") }); - waitForInputAndReboot(); + showMessageAndRebootOrPowerOff(screen, { "SD card not found.", "Please insert one and reboot." }); return false; } diff --git a/Tactility/Source/app/boot/BootScreen.cpp b/Tactility/Source/app/boot/BootScreen.cpp index 2862f617a..ad202b620 100644 --- a/Tactility/Source/app/boot/BootScreen.cpp +++ b/Tactility/Source/app/boot/BootScreen.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -359,4 +360,123 @@ void waitForInput() { } } +namespace { + +constexpr size_t LONG_PRESS_MS = 800; + +std::string getOptionLine(const char* label, bool selected) { + // Padded to the same width, so the options line up when the lines are centered + char text[24]; + snprintf(text, sizeof(text), selected ? "[ %-9s ]" : " %-9s ", label); + return text; +} + +} + +BootChoice waitForBootChoice(BootScreen& screen, const BootLogo& logo, const std::vector& lines) { + auto keyboards = getReadyKeyboards(); + // Subscriptions must stay at the same address while subscribed + auto subscriptions = std::make_unique(keyboards.size()); + for (size_t i = 0; i < keyboards.size(); i++) { + keyboard_subscribe(keyboards[i], &subscriptions[i]); + } + + Device* pointer = nullptr; + if (device_get_first_active_by_type(&TOUCH_TYPE, &pointer) != ERROR_NONE) { + pointer = nullptr; + } + + std::vector hints; + if (!keyboards.empty()) { + hints.push_back("Enter to confirm"); + } + if (pointer != nullptr) { + hints.push_back("Tap to select, hold to confirm"); + } + + BootChoice selected = BootChoice::Reboot; + bool redraw = true; + bool confirmed = false; + // A touch only counts when it starts after the screen was seen untouched + bool pointer_released = false; + bool pointer_down = false; + size_t pointer_down_at = 0; + while (!confirmed) { + if (redraw) { + std::vector shown = lines; + shown.push_back(""); + shown.push_back(getOptionLine("Reboot", selected == BootChoice::Reboot)); + shown.push_back(getOptionLine("Power off", selected == BootChoice::PowerOff)); + shown.push_back(""); + shown.insert(shown.end(), hints.begin(), hints.end()); + screen.show(logo, shown); + redraw = false; + } + + for (size_t i = 0; i < keyboards.size() && !confirmed; i++) { + // Drivers that implement read_key only deliver events to subscribers when polled + KeyboardKeyData data; + while (keyboard_read_key(keyboards[i], &data) == ERROR_NONE && data.key != 0 && data.continue_reading) {} + + while (keyboard_poll(keyboards[i], &subscriptions[i], &data) == ERROR_NONE) { + if (!data.pressed) { + continue; + } + switch (data.key) { + case CODEPOINT_ENTER: + case CODEPOINT_YES: + confirmed = true; + break; + case CODEPOINT_ARROW_UP: + case CODEPOINT_ARROW_DOWN: + case CODEPOINT_ARROW_LEFT: + case CODEPOINT_ARROW_RIGHT: + case CODEPOINT_TAB: + case CODEPOINT_FOCUS_NEXT: + case CODEPOINT_FOCUS_PREVIOUS: + // Two options, so every direction toggles + selected = selected == BootChoice::Reboot ? BootChoice::PowerOff : BootChoice::Reboot; + redraw = true; + break; + default: + break; + } + } + } + + if (pointer != nullptr && !confirmed && touch_read_data(pointer, 0) == ERROR_NONE) { + uint16_t x, y, strength; + uint8_t point_count = 0; + const bool touched = touch_get_touched_points(pointer, &x, &y, &strength, &point_count, 1) && point_count > 0; + if (!touched) { + if (pointer_down) { + // A tap + selected = selected == BootChoice::Reboot ? BootChoice::PowerOff : BootChoice::Reboot; + redraw = true; + pointer_down = false; + } + pointer_released = true; + } else if (pointer_released && !pointer_down) { + pointer_down = true; + pointer_down_at = get_millis(); + } else if (pointer_down && get_millis() - pointer_down_at >= LONG_PRESS_MS) { + confirmed = true; + } + } + + if (!confirmed) { + delay_millis(20); + } + } + + for (size_t i = 0; i < keyboards.size(); i++) { + keyboard_unsubscribe(keyboards[i], &subscriptions[i]); + device_put(keyboards[i]); + } + if (pointer != nullptr) { + device_put(pointer); + } + return selected; +} + } diff --git a/Tactility/Source/app/locationsettings/LocationSettings.cpp b/Tactility/Source/app/locationsettings/LocationSettings.cpp index 3383da969..d2a8f479a 100644 --- a/Tactility/Source/app/locationsettings/LocationSettings.cpp +++ b/Tactility/Source/app/locationsettings/LocationSettings.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -57,6 +58,8 @@ constexpr PrecisionOption PRECISION_OPTIONS[] = { { LOCATION_PRECISION_PRECISE, "Precise" }, }; +constexpr uint32_t SUBSCRIBERS_REFRESH_MS = 1000; + /** A GPS device that is backed by a persisted configuration */ struct GpsConfigurationRow { Device* device; @@ -78,6 +81,11 @@ struct Context { lv_obj_t* statusLabel = nullptr; lv_obj_t* gpsDeviceList = nullptr; std::vector gpsConfigurationRows; + lv_obj_t* subscribersSection = nullptr; + lv_obj_t* subscribersList = nullptr; + // Filled by the app's task. The list shows shownSubscribers. + std::vector subscribers; + std::vector shownSubscribers; // Set while a delete confirmation is pending bool hasPendingDelete = false; size_t pendingDeleteIndex = 0; @@ -105,6 +113,26 @@ std::string getStatusText() { return text; } +void readSubscribers(Context* ctx) { + ctx->subscribers.clear(); + location_for_each_subscriber(&ctx->subscribers, [](const char* description, void* context) { + static_cast*>(context)->push_back(description); + }); +} + +void updateSubscribers(Context* ctx) { + if (ctx->subscribersSection == nullptr || ctx->subscribers == ctx->shownSubscribers) { + return; + } + ctx->shownSubscribers = ctx->subscribers; + lv_obj_clean(ctx->subscribersList); + for (const auto& subscriber : ctx->shownSubscribers) { + lvgl_list_add_button(ctx->subscribersList, nullptr, subscriber.c_str()); + } + lvgl_list_hide_last_divider(ctx->subscribersList); + lv_obj_set_flag(ctx->subscribersSection, LV_OBJ_FLAG_HIDDEN, ctx->shownSubscribers.empty()); +} + void updateWidgets(Context* ctx) { if (ctx->statusLabel == nullptr) { return; @@ -119,6 +147,7 @@ void updateWidgets(Context* ctx) { } lv_label_set_text(ctx->statusLabel, getStatusText().c_str()); + updateSubscribers(ctx); } void onBackPressed(lv_event_t* event) { @@ -288,6 +317,23 @@ void createGpsDevices(Context* ctx, lv_obj_t* parent) { rebuildGpsDeviceList(ctx); } +void createSubscribers(Context* ctx, lv_obj_t* parent) { + ctx->subscribersSection = createContainer(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_size(ctx->subscribersSection, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_add_flag(ctx->subscribersSection, LV_OBJ_FLAG_HIDDEN); + + auto* title = lv_label_create(ctx->subscribersSection); + lv_label_set_text(title, "Subscribers"); + + auto* card = lvgl_card_create(ctx->subscribersSection); + lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_left(title, lv_obj_get_style_pad_left(card, LV_PART_MAIN), LV_STATE_DEFAULT); + + ctx->subscribersList = lvgl_list_create(card); + ctx->shownSubscribers.clear(); +} + void createWidgets(lv_obj_t* parent, void* userData) { auto* ctx = static_cast(userData); @@ -350,6 +396,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { if (ctx->showGpsDevices) { createGpsDevices(ctx, wrapper); } + createSubscribers(ctx, wrapper); updateWidgets(ctx); } @@ -361,6 +408,8 @@ void destroyWidgets(void* userData) { ctx->statusLabel = nullptr; ctx->gpsDeviceList = nullptr; ctx->gpsConfigurationRows.clear(); + ctx->subscribersSection = nullptr; + ctx->subscribersList = nullptr; } int32_t appMain(int argc, char* argv[]) { @@ -378,14 +427,16 @@ int32_t appMain(int argc, char* argv[]) { AppEventSubscription appEvents {}; check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE); LocationSubscription locationEvents {}; - check(location_subscribe(&locationEvents, &eventGroup) == ERROR_NONE); + check(location_subscribe(&locationEvents, &eventGroup, "Location settings") == ERROR_NONE); + readSubscribers(&ctx); WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx); bool shouldClose = false; while (!shouldClose) { uint32_t flags = 0; - task_event_group_wait_any(&eventGroup, &flags, portMAX_DELAY); + // Subscribers come and go without signaling, so the list is refreshed regularly + task_event_group_wait_any(&eventGroup, &flags, pdMS_TO_TICKS(SUBSCRIBERS_REFRESH_MS)); if (flags & ctx.enableBit) { location_set_enabled(ctx.requestedEnabled); @@ -416,6 +467,7 @@ int32_t appMain(int argc, char* argv[]) { } lvgl_lock(); + readSubscribers(&ctx); updateWidgets(&ctx); lvgl_unlock(); } diff --git a/Tactility/Source/app/setup/Setup.cpp b/Tactility/Source/app/setup/Setup.cpp index 567d7b03b..cd4b4919b 100644 --- a/Tactility/Source/app/setup/Setup.cpp +++ b/Tactility/Source/app/setup/Setup.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -15,6 +16,7 @@ #include #include +#include #include #include @@ -30,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -55,6 +58,9 @@ constexpr auto* TAG = "setup"; namespace { +/** Increase when the setup changes, so users see it again */ +constexpr int SETUP_VERSION = 2; + bool getCompletedMarkerPath(std::string& outPath) { char path[128]; if (paths_get_data_path(path, sizeof(path)) != ERROR_NONE) { @@ -72,7 +78,16 @@ bool isCompleted() { LOG_E(TAG, "Setup path not found"); return false; } - return file::isFile(path); + if (!file::isFile(path)) { + return false; + } + // Markers from before versioning are empty and mean version 1 + int version = 1; + const auto content = file::readString(path); + if (content != nullptr && content[0] != '\0') { + version = atoi(reinterpret_cast(content.get())); + } + return version >= SETUP_VERSION; } namespace { @@ -82,7 +97,7 @@ void markCompleted() { if (!getCompletedMarkerPath(path)) { return; } - file::writeString(path, ""); + file::writeString(path, std::to_string(SETUP_VERSION)); } enum class Phase { @@ -100,16 +115,17 @@ struct StepConfiguration { /** What the page shows below its title */ enum class Content { Description, - /** The description, with the location choices below it */ - LocationChoice, + /** The description, with the choices below it */ + Choice, /** The time zone list instead of the description */ TimeZones } content = Content::Description; + /** The labels of the chips of a Content::Choice page */ + std::vector choices; + /** The index in choices that is picked, -1 when none. Guarded by the LVGL lock. */ + int32_t choice = -1; }; -/** The location choices of the location page, in the order of their chips */ -constexpr const char* LOCATION_CHOICES[] = { "Precise", "Approximate", "Disabled" }; - struct Context { uint32_t appInstanceId; @@ -119,6 +135,9 @@ struct Context { uint32_t pendingStepDialogId = 0; // Valid while the window is shown + // The area above the buttons, which scrolls when the page doesn't fit + lv_obj_t* content = nullptr; + lv_obj_t* contentInner = nullptr; lv_obj_t* titleLabel = nullptr; lv_obj_t* descriptionLabel = nullptr; lv_obj_t* backButton = nullptr; @@ -127,9 +146,7 @@ struct Context { lv_obj_t* stepIndicator = nullptr; lv_obj_t* bottom = nullptr; lv_obj_t* timeZoneContainer = nullptr; - lv_obj_t* locationChips = nullptr; - // The index in LOCATION_CHOICES picked on the location page, -1 when none. Guarded by the LVGL lock. - int32_t locationChoice = -1; + lv_obj_t* choiceChips = nullptr; std::unique_ptr timeZoneView; // The time zone picked on the time zone page, guarded by the LVGL lock @@ -177,70 +194,104 @@ StepConfiguration::Content getContent(const Context* ctx) { return ctx->phase == Phase::StepIntro ? ctx->steps[ctx->stepIndex].content : StepConfiguration::Content::Description; } -/** Shows which location choice is picked */ -void renderLocationChips(Context* ctx) { - const uint32_t count = lv_obj_get_child_count(ctx->locationChips); +/** Shows which choice of the current step is picked */ +void renderChoiceChips(Context* ctx) { + const int32_t choice = ctx->steps[ctx->stepIndex].choice; + const uint32_t count = lv_obj_get_child_count(ctx->choiceChips); for (uint32_t i = 0; i < count; i++) { - lv_obj_set_state(lv_obj_get_child(ctx->locationChips, static_cast(i)), LV_STATE_CHECKED, static_cast(i) == ctx->locationChoice); + lv_obj_set_state(lv_obj_get_child(ctx->choiceChips, static_cast(i)), LV_STATE_CHECKED, static_cast(i) == choice); } } -/** The time zone page shows the title at the top and the list below it, the other pages center their text */ +void onChoiceChipPressed(lv_event_t* event); + +/** Replaces the chips with the choices of the current step */ +void createChoiceChips(Context* ctx) { + lv_obj_clean(ctx->choiceChips); + const auto& choices = ctx->steps[ctx->stepIndex].choices; + for (size_t i = 0; i < choices.size(); i++) { + auto* chip = lvgl_chip_create(ctx->choiceChips); + lv_label_set_text(lv_label_create(chip), choices[i].c_str()); + lv_obj_set_user_data(chip, reinterpret_cast(static_cast(i))); + lv_obj_add_event_cb(chip, onChoiceChipPressed, LV_EVENT_SHORT_CLICKED, ctx); + } +} + +/** Small widgets get smaller titles */ +enum LvglFontSize getTitleFontSize() { + const auto size = lvgl_theme_get_size(); + if (size <= 20) { + return FONT_SIZE_SMALL; + } + return size < 50 ? FONT_SIZE_DEFAULT : FONT_SIZE_LARGE; +} + +/** + * The time zone page shows the title at the top and the list below it, the other pages center their text. + * Pages that don't fit above the buttons scroll. + */ void renderLayout(Context* ctx) { const auto content = getContent(ctx); const bool showsTimeZones = content == StepConfiguration::Content::TimeZones; - const bool showsLocationChoice = content == StepConfiguration::Content::LocationChoice; + const bool showsChoice = content == StepConfiguration::Content::Choice; const bool hasTimeZoneWidgets = lv_obj_get_child_count(ctx->timeZoneContainer) > 0; lv_obj_set_hidden(ctx->descriptionLabel, showsTimeZones); lv_obj_set_hidden(ctx->timeZoneContainer, !showsTimeZones); - lv_obj_set_hidden(ctx->locationChips, !showsLocationChoice); + lv_obj_set_hidden(ctx->choiceChips, !showsChoice); + // The group skips disabled widgets, but not hidden ones + lv_obj_set_state(ctx->choiceChips, LV_STATE_DISABLED, !showsChoice); + lv_obj_scroll_to_y(ctx->content, 0, LV_ANIM_OFF); + + lv_area_t parent; + lv_area_t bottom; if (!showsTimeZones) { if (hasTimeZoneWidgets) { lv_obj_clean(ctx->timeZoneContainer); ctx->timeZoneView->destroyWidgets(); } - const int titleMargin = lvgl_get_text_font_height(FONT_SIZE_LARGE); - if (showsLocationChoice) { - // Centered as a group in the space above the buttons - const int32_t gap = titleMargin / 2; - lv_obj_update_layout(lv_obj_get_parent(ctx->titleLabel)); - const int32_t height = lv_obj_get_height(ctx->titleLabel) + gap + lv_obj_get_height(ctx->descriptionLabel) + gap + lv_obj_get_height(ctx->locationChips); - lv_area_t parent; - lv_area_t bottom; - lv_obj_get_content_coords(lv_obj_get_parent(ctx->titleLabel), &parent); - lv_obj_get_coords(ctx->bottom, &bottom); - const int32_t top = LV_MAX((bottom.y1 - parent.y1 - height) / 2, 0); - lv_obj_align(ctx->titleLabel, LV_ALIGN_TOP_MID, 0, top); - lv_obj_align_to(ctx->descriptionLabel, ctx->titleLabel, LV_ALIGN_OUT_BOTTOM_MID, 0, gap); - lv_obj_align_to(ctx->locationChips, ctx->descriptionLabel, LV_ALIGN_OUT_BOTTOM_MID, 0, gap); + const int titleMargin = lvgl_get_text_font_height(getTitleFontSize()); + if (showsChoice) { + createChoiceChips(ctx); // Continue confirms the picked choice - renderLocationChips(ctx); - lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, ctx->locationChoice < 0); + renderChoiceChips(ctx); + lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, ctx->steps[ctx->stepIndex].choice < 0); } else { - lv_obj_align(ctx->descriptionLabel, LV_ALIGN_CENTER, 0, 0); - lv_obj_align_to(ctx->titleLabel, ctx->descriptionLabel, LV_ALIGN_OUT_TOP_MID, 0, -titleMargin); lv_obj_remove_state(ctx->continueButton, LV_STATE_DISABLED); } - return; - } - - lv_obj_align(ctx->titleLabel, LV_ALIGN_TOP_MID, 0, LV_DPX(8)); - if (!hasTimeZoneWidgets) { - ctx->timeZoneView->createWidgets(ctx->timeZoneContainer); + lv_obj_set_style_pad_row(ctx->contentInner, showsChoice ? titleMargin / 2 : titleMargin, LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(ctx->contentInner, 0, LV_STATE_DEFAULT); + lv_obj_set_style_min_height(ctx->contentInner, LV_PCT(100), LV_STATE_DEFAULT); + // Fills the space above the buttons + lv_obj_update_layout(lv_obj_get_parent(ctx->content)); + lv_obj_get_content_coords(lv_obj_get_parent(ctx->content), &parent); + lv_obj_get_coords(ctx->bottom, &bottom); + lv_obj_set_height(ctx->content, bottom.y1 - parent.y1); + } else { + if (!hasTimeZoneWidgets) { + ctx->timeZoneView->createWidgets(ctx->timeZoneContainer); + } + // Continue confirms the picked time zone + lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, ctx->timeZoneName.empty()); + + lv_obj_set_style_pad_row(ctx->contentInner, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(ctx->contentInner, LV_DPX(8), LV_STATE_DEFAULT); + lv_obj_set_style_min_height(ctx->contentInner, 0, LV_STATE_DEFAULT); + lv_obj_set_height(ctx->content, LV_SIZE_CONTENT); + + // The list fills the space between the title and the buttons + lv_obj_update_layout(lv_obj_get_parent(ctx->timeZoneContainer)); + lv_area_t title; + lv_obj_get_coords(ctx->content, &title); + lv_obj_get_coords(ctx->bottom, &bottom); + lv_obj_get_content_coords(lv_obj_get_parent(ctx->timeZoneContainer), &parent); + lv_obj_set_pos(ctx->timeZoneContainer, 0, title.y2 + LV_DPX(4) - parent.y1); + lv_obj_set_size(ctx->timeZoneContainer, LV_PCT(100), bottom.y1 - title.y2 - LV_DPX(8)); } - // Continue confirms the picked time zone - lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, ctx->timeZoneName.empty()); - // Fills the space between the title and the buttons - lv_obj_update_layout(lv_obj_get_parent(ctx->timeZoneContainer)); - lv_area_t title; - lv_area_t bottom; - lv_obj_get_coords(ctx->titleLabel, &title); - lv_obj_get_coords(ctx->bottom, &bottom); - lv_area_t parent; - lv_obj_get_content_coords(lv_obj_get_parent(ctx->timeZoneContainer), &parent); - lv_obj_set_pos(ctx->timeZoneContainer, 0, title.y2 + LV_DPX(4) - parent.y1); - lv_obj_set_size(ctx->timeZoneContainer, LV_PCT(100), bottom.y1 - title.y2 - LV_DPX(8)); + // Only a page that doesn't fit takes the focus, so the keys can scroll it + lv_obj_update_layout(ctx->content); + const bool scrolls = lv_obj_get_scroll_bottom(ctx->content) > 0 || lv_obj_get_scroll_top(ctx->content) > 0; + lv_obj_set_state(ctx->content, LV_STATE_DISABLED, !scrolls); } void renderCurrent(Context* ctx) { @@ -272,6 +323,12 @@ void renderCurrent(Context* ctx) { break; } renderLayout(ctx); + // Without a touch screen, Finish has the focus + if (ctx->phase == Phase::Done && !lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) { + auto* buttonRow = lv_obj_get_parent(ctx->continueButton); + lv_group_focus_obj(buttonRow); + lv_gridnav_set_focused(buttonRow, ctx->continueButton, LV_ANIM_OFF); + } } void advanceTo(Context* ctx, size_t index) { @@ -330,44 +387,53 @@ void onContinueClicked(lv_event_t* event) { } } -void onLocationChipPressed(lv_event_t* event) { +void onChoiceChipPressed(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); - ctx->locationChoice = static_cast(reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event)))); - renderLocationChips(ctx); + ctx->steps[ctx->stepIndex].choice = static_cast(reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event)))); + renderChoiceChips(ctx); lv_obj_remove_state(ctx->continueButton, LV_STATE_DISABLED); } void createWidgets(lv_obj_t* parent, void* userData) { auto* ctx = static_cast(userData); + lvgl::systemBarsSetStatusbarVisible(false); + + ctx->content = lv_obj_create(parent); + lv_obj_remove_style_all(ctx->content); + lv_obj_set_size(ctx->content, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_scroll_dir(ctx->content, LV_DIR_VER); + if (auto* group = lv_group_get_default()) { + lv_group_add_obj(group, ctx->content); + } - ctx->titleLabel = lv_label_create(parent); + // At least as high as the content, so that short pages are centered + ctx->contentInner = lv_obj_create(ctx->content); + lv_obj_remove_style_all(ctx->contentInner); + lv_obj_set_size(ctx->contentInner, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(ctx->contentInner, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(ctx->contentInner, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + ctx->titleLabel = lv_label_create(ctx->contentInner); lv_obj_set_width(ctx->titleLabel, LV_PCT(80)); lv_obj_set_style_text_align(ctx->titleLabel, LV_TEXT_ALIGN_CENTER, 0); lv_label_set_long_mode(ctx->titleLabel, LV_LABEL_LONG_WRAP); - auto* font = lvgl_get_text_font(FONT_SIZE_LARGE); + auto* font = lvgl_get_text_font(getTitleFontSize()); lv_obj_set_style_text_font(ctx->titleLabel, font, 0); - ctx->descriptionLabel = lv_label_create(parent); + ctx->descriptionLabel = lv_label_create(ctx->contentInner); lv_obj_set_width(ctx->descriptionLabel, LV_PCT(80)); lv_obj_set_style_text_align(ctx->descriptionLabel, LV_TEXT_ALIGN_CENTER, 0); lv_label_set_long_mode(ctx->descriptionLabel, LV_LABEL_LONG_WRAP); - lv_obj_align(ctx->descriptionLabel, LV_ALIGN_CENTER, 0, 0); - // The location choices: one can be picked, and they wrap to more lines on narrow screens - ctx->locationChips = lv_obj_create(parent); - lv_obj_remove_style_all(ctx->locationChips); - lv_obj_set_size(ctx->locationChips, LV_PCT(100), LV_SIZE_CONTENT); + // The choices of a step: one can be picked, and they wrap to more lines on narrow screens + ctx->choiceChips = lv_obj_create(ctx->contentInner); + lv_obj_remove_style_all(ctx->choiceChips); + lv_obj_set_size(ctx->choiceChips, LV_PCT(100), LV_SIZE_CONTENT); // The chip margins space the choices, so they fit on one line on small screens - lv_obj_set_flex_flow(ctx->locationChips, LV_FLEX_FLOW_ROW_WRAP); - lv_obj_set_flex_align(ctx->locationChips, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - for (size_t i = 0; i < std::size(LOCATION_CHOICES); i++) { - auto* chip = lvgl_chip_create(ctx->locationChips); - lv_label_set_text(lv_label_create(chip), LOCATION_CHOICES[i]); - lv_obj_set_user_data(chip, reinterpret_cast(static_cast(i))); - lv_obj_add_event_cb(chip, onLocationChipPressed, LV_EVENT_SHORT_CLICKED, ctx); - } - lvgl_grid_navigation_add(ctx->locationChips); - lv_obj_set_hidden(ctx->locationChips, true); + lv_obj_set_flex_flow(ctx->choiceChips, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(ctx->choiceChips, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lvgl_grid_navigation_add(ctx->choiceChips); + lv_obj_set_hidden(ctx->choiceChips, true); // Filled with the time zone list on the time zone page ctx->timeZoneContainer = lv_obj_create(parent); @@ -401,6 +467,8 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_obj_remove_style_all(spacer); lv_obj_set_height(spacer, 1); lv_obj_set_flex_grow(spacer, 1); + // Grid navigation would otherwise stop on it + lv_obj_set_clickable(spacer, false); ctx->skipButton = lvgl_text_button_create(buttonRow); lv_label_set_text(lv_label_create(ctx->skipButton), "Skip"); @@ -425,6 +493,9 @@ void createWidgets(lv_obj_t* parent, void* userData) { void destroyWidgets(void* userData) { auto* ctx = static_cast(userData); + lvgl::systemBarsSetStatusbarVisible(true); + ctx->content = nullptr; + ctx->contentInner = nullptr; ctx->titleLabel = nullptr; ctx->descriptionLabel = nullptr; ctx->backButton = nullptr; @@ -433,7 +504,7 @@ void destroyWidgets(void* userData) { ctx->stepIndicator = nullptr; ctx->bottom = nullptr; ctx->timeZoneContainer = nullptr; - ctx->locationChips = nullptr; + ctx->choiceChips = nullptr; ctx->timeZoneView->destroyWidgets(); } @@ -471,16 +542,18 @@ int32_t appMain(int argc, char* argv[]) { .title = "Location Services", .description = "Do you want to allow apps to retrieve your location? It uses GPS and/or contacts geoiplookup.io", .run = [&ctx] { - // The order of LOCATION_CHOICES: precise, approximate, disabled - if (ctx.locationChoice == 0 || ctx.locationChoice == 1) { - location_set_precision(ctx.locationChoice == 0 ? LOCATION_PRECISION_PRECISE : LOCATION_PRECISION_APPROXIMATE); + // The order of the choices: precise, approximate, disabled + const int32_t choice = ctx.steps[ctx.stepIndex].choice; + if (choice == 0 || choice == 1) { + location_set_precision(choice == 0 ? LOCATION_PRECISION_PRECISE : LOCATION_PRECISION_APPROXIMATE); location_set_enabled(true); } else { location_set_enabled(false); } advanceTo(&ctx, ctx.stepIndex + 1); }, - .content = StepConfiguration::Content::LocationChoice + .content = StepConfiguration::Content::Choice, + .choices = { "Precise", "Approximate", "Disabled" } }); // Setting the time zone also sets the radio region, as both depend on where the device is @@ -501,6 +574,21 @@ int32_t appMain(int argc, char* argv[]) { } }); + if (device_exists_of_type(&GPS_TYPE)) { + ctx.steps.push_back({ + .title = "Time from GPS", + .description = "Set time from GPS at boot?", + .run = [&ctx] { + // The order of the choices: enabled, disabled + settings::setGpsTimeSyncAtBoot(ctx.steps[ctx.stepIndex].choice == 0); + advanceTo(&ctx, ctx.stepIndex + 1); + }, + .content = StepConfiguration::Content::Choice, + .choices = { "Enabled", "Disabled" }, + .choice = 0 + }); + } + TaskEventGroup event_group {}; task_event_group_construct(&event_group); diff --git a/Tactility/Source/app/systeminfo/SystemInfo.cpp b/Tactility/Source/app/systeminfo/SystemInfo.cpp index 0c3419e42..3ff4c9185 100644 --- a/Tactility/Source/app/systeminfo/SystemInfo.cpp +++ b/Tactility/Source/app/systeminfo/SystemInfo.cpp @@ -272,7 +272,7 @@ lv_obj_t* createTab(lv_obj_t* tabview, const char* name) { auto* tab = lv_tabview_add_tab(tabview, name); auto* tab_bar = lv_tabview_get_tab_bar(tabview); auto* tab_button = lv_obj_get_child(tab_bar, static_cast(lv_obj_get_child_count(tab_bar)) - 1); - auto* shared_icon_font = lvgl_theme_is_compact() ? lvgl_get_shared_icon_default_font() : lvgl_get_shared_icon_large_font(); + auto* shared_icon_font = lvgl_theme_get_size() < 50 ? lvgl_get_shared_icon_default_font() : lvgl_get_shared_icon_large_font(); lv_obj_set_style_text_font(tab_button, shared_icon_font, LV_STATE_DEFAULT); // Square buttons, so the theme's round selection indicator fits the icon const int32_t button_size = lv_font_get_line_height(shared_icon_font) + 2 * lv_obj_get_style_pad_top(tab_button, LV_PART_MAIN); @@ -380,7 +380,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_obj_set_flex_grow(wrapper, 1); lv_obj_set_style_pad_all(wrapper, 0, LV_STATE_DEFAULT); // Space between the screen edges and the rounded tab bar and content - const int32_t edge_space = lvgl_theme_is_compact() ? 2 : LV_DPX(8); + const int32_t edge_space = lvgl_theme_lerp(2, LV_DPX(8), lvgl_theme_get_spacing()); lv_obj_set_style_pad_hor(wrapper, edge_space, LV_STATE_DEFAULT); lv_obj_set_style_pad_bottom(wrapper, edge_space, LV_STATE_DEFAULT); @@ -396,7 +396,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { // As wide as the buttons, which are spread over the bar's height auto* tab_bar = lv_tabview_get_tab_bar(tabview); lv_obj_set_width(tab_bar, LV_SIZE_CONTENT); - lv_obj_set_style_pad_hor(tab_bar, lvgl_theme_is_compact() ? 2 : LV_DPX(12), LV_STATE_DEFAULT); + lv_obj_set_style_pad_hor(tab_bar, lvgl_theme_lerp(2, LV_DPX(12), lvgl_theme_get_spacing()), LV_STATE_DEFAULT); lv_obj_set_flex_align(tab_bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); // Memory tab content diff --git a/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp b/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp index a35885b4e..3dcb427e9 100644 --- a/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp +++ b/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp @@ -13,10 +13,14 @@ #include #include +#include +#include #include +#include #include #include +#include #include #include #include @@ -39,6 +43,11 @@ struct Context { uint32_t appInstanceId; TaskEventGroup* eventGroup = nullptr; uint32_t selectDateFormatBit = 0; + uint32_t gpsSyncChangedBit = 0; + // Read when the app starts, as widgets can't read files + bool hasGps = false; + bool gpsSyncAtBoot = false; + bool locationEnabled = false; uint32_t pendingTimeZoneDialogId = 0; uint32_t dateFormatDialogId = 0; // Valid while the window is shown @@ -70,6 +79,12 @@ void onTimeFormatChanged(lv_event_t* event) { settings::setTimeFormat24Hour(show_24); } +void onGpsSyncChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->gpsSyncAtBoot = lv_obj_has_state(lv_event_get_target_obj(event), LV_STATE_CHECKED); + task_event_group_signal(ctx->eventGroup, ctx->gpsSyncChangedBit); +} + void onTimeZonePressed(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); ctx->pendingTimeZoneDialogId = timezone::start(ctx->appInstanceId, true); @@ -197,6 +212,27 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_label_set_text(date_format_icon, LVGL_ICON_SHARED_LIST_ALT); lv_obj_add_event_cb(date_format_button, onSelectDateFormatPressed, LV_EVENT_SHORT_CLICKED, ctx); + // "GPS sync at boot [switch]" + + if (ctx->hasGps) { + auto* gps_sync_row = lv_obj_create(card); + lv_obj_set_size(gps_sync_row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(gps_sync_row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(gps_sync_row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_border_width(gps_sync_row, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(gps_sync_row, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(gps_sync_row, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_scrollable(gps_sync_row, false); + + auto* gps_sync_value = createTitleAndValue(gps_sync_row, "GPS sync at boot"); + lv_label_set_text(gps_sync_value, ctx->locationEnabled ? "Sets the clock once after boot" : "Requires location services"); + + auto* gps_sync_switch = lv_switch_create(gps_sync_row); + lv_obj_set_state(gps_sync_switch, LV_STATE_CHECKED, ctx->gpsSyncAtBoot); + lv_obj_add_event_cb(gps_sync_switch, onGpsSyncChanged, LV_EVENT_VALUE_CHANGED, ctx); + lvgl_grid_navigation_add(gps_sync_row); + } + // "Timezone Europe/Brussels [Change]" auto* timezone_row = lv_obj_create(card); @@ -237,6 +273,13 @@ int32_t appMain(int argc, char* argv[]) { task_event_group_construct(&event_group); ctx.eventGroup = &event_group; check(task_event_group_claim_bit(&event_group, &ctx.selectDateFormatBit) == ERROR_NONE); + check(task_event_group_claim_bit(&event_group, &ctx.gpsSyncChangedBit) == ERROR_NONE); + + ctx.hasGps = device_exists_of_type(&GPS_TYPE); + if (ctx.hasGps) { + ctx.gpsSyncAtBoot = settings::isGpsTimeSyncAtBoot(); + ctx.locationEnabled = location_is_enabled(); + } AppEventSubscription sub {}; check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); @@ -251,6 +294,9 @@ int32_t appMain(int argc, char* argv[]) { if (flags & ctx.selectDateFormatBit) { startDateFormatSelection(&ctx); } + if (flags & ctx.gpsSyncChangedBit) { + settings::setGpsTimeSyncAtBoot(ctx.gpsSyncAtBoot); + } AppEvent event {}; while (app_event_poll(&sub, &event) == ERROR_NONE) { diff --git a/Tactility/Source/lvgl/SystemBars.cpp b/Tactility/Source/lvgl/SystemBars.cpp index 87835702a..18ef89554 100644 --- a/Tactility/Source/lvgl/SystemBars.cpp +++ b/Tactility/Source/lvgl/SystemBars.cpp @@ -170,6 +170,12 @@ void systemBarsRefresh() { } } +void systemBarsSetStatusbarVisible(bool visible) { + if (state.statusbar != nullptr) { + lv_obj_set_hidden(state.statusbar, !visible); + } +} + SystemBarsLayout systemBarsGetLayout() { return state.layout; } diff --git a/Tactility/Source/lvgl/SystemMenu.cpp b/Tactility/Source/lvgl/SystemMenu.cpp index df07d6555..7061b70a0 100644 --- a/Tactility/Source/lvgl/SystemMenu.cpp +++ b/Tactility/Source/lvgl/SystemMenu.cpp @@ -254,7 +254,7 @@ static void createWidgets(lv_obj_t* parent, void* /*userData*/) { lv_obj_set_style_max_width(grid, max_card_width - card_padding, LV_STATE_DEFAULT); lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP); lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - const int32_t gap = lvgl_theme_is_compact() ? 8 : 16; + const int32_t gap = lvgl_theme_lerp(8, 16, lvgl_theme_get_spacing()); lv_obj_set_style_pad_column(grid, gap, LV_STATE_DEFAULT); lv_obj_set_style_pad_row(grid, gap, LV_STATE_DEFAULT); diff --git a/Tactility/Source/lvgl/Theme.cpp b/Tactility/Source/lvgl/Theme.cpp index 39fb57b08..057cfe319 100644 --- a/Tactility/Source/lvgl/Theme.cpp +++ b/Tactility/Source/lvgl/Theme.cpp @@ -11,8 +11,11 @@ void configureTheme(const settings::appearance::AppearanceSettings& settings) { theme_settings.is_dark = settings.themeMode == settings::appearance::ThemeMode::Dark; } theme_settings.is_mono = settings.monoTheme; - if (settings.densityMode != settings::appearance::DensityMode::DeviceDefault) { - theme_settings.is_compact = settings.densityMode == settings::appearance::DensityMode::Compact; + if (settings.spacing.has_value()) { + theme_settings.spacing = *settings.spacing; + } + if (settings.size.has_value()) { + theme_settings.size = *settings.size; } theme_settings.animations_enabled = settings.animationsEnabled; if (settings.primaryColor.has_value()) { diff --git a/Tactility/Source/network/Ntp.cpp b/Tactility/Source/network/Ntp.cpp index 2e81d94c8..6a5dce942 100644 --- a/Tactility/Source/network/Ntp.cpp +++ b/Tactility/Source/network/Ntp.cpp @@ -57,10 +57,13 @@ void setTimeFromNvs() { } int64_t synced_time = 0; if (preferences_opt_int64(preferences, "syncTime", &synced_time)) { - LOG_I(TAG, "Restoring last known time to %ld", (long)synced_time); - timeval get_nvs_time; - get_nvs_time.tv_sec = synced_time; - settimeofday(&get_nvs_time, nullptr); + // The stored time is the minimum, as time doesn't go backwards + if (synced_time > time(nullptr)) { + LOG_I(TAG, "Restoring last known time to %ld", (long)synced_time); + timeval get_nvs_time; + get_nvs_time.tv_sec = synced_time; + settimeofday(&get_nvs_time, nullptr); + } } preferences_close(preferences); } @@ -83,6 +86,8 @@ void init() { #else +void storeTimeInNvs() {} + void init() { processedSyncEvent = true; } diff --git a/Tactility/Source/service/gpstime/GpsTimeSyncService.cpp b/Tactility/Source/service/gpstime/GpsTimeSyncService.cpp new file mode 100644 index 000000000..8852e4677 --- /dev/null +++ b/Tactility/Source/service/gpstime/GpsTimeSyncService.cpp @@ -0,0 +1,125 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace tt::service::gpstime { + +constexpr auto* TAG = "GpsTimeSync"; +constexpr TickType_t TIMER_INTERVAL_TICKS = 2000 / portTICK_PERIOD_MS; + +bool GpsTimeSyncService::setSystemTime(int64_t timeMs) { +#ifdef ESP_PLATFORM + const timeval gps = { + .tv_sec = static_cast(timeMs / 1000), + .tv_usec = static_cast((timeMs % 1000) * 1000) + }; + if (settimeofday(&gps, nullptr) != 0) { + LOG_E(TAG, "settimeofday failed"); + return false; + } + return true; +#else + // The simulator must not change the clock of the host + LOG_W(TAG, "Not setting the system time to %lld on this platform", static_cast(timeMs)); + return false; +#endif +} + +void GpsTimeSyncService::onLocationTime(int64_t timeMs) { + if (done.exchange(true)) { + return; + } + + timeval now = {}; + gettimeofday(&now, nullptr); + const int64_t systemTimeMs = static_cast(now.tv_sec) * 1000 + now.tv_usec / 1000; + if (!shouldApplyGpsTime(timeMs, systemTimeMs, network::ntp::isSynced())) { + LOG_I(TAG, "System time is within %lld ms of GPS time", static_cast(MAX_DRIFT_MS)); + return; + } + + if (!setSystemTime(timeMs)) { + return; + } + LOG_I(TAG, "System time set from GPS (%lld)", static_cast(timeMs / 1000)); + network::ntp::storeTimeInNvs(); + system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0); +} + +void GpsTimeSyncService::onTimer() { + // GPS only needs to run until the time was announced + if (done && subscribed.exchange(false)) { + location_unsubscribe(&subscription); + } +} + +void GpsTimeSyncService::onLocationTimeTrampoline(struct SystemEvent* event, void* context) { + LocationTimeEvent data; + memcpy(&data, event->data, sizeof(data)); + static_cast(context)->onLocationTime(data.time_ms); +} + +bool GpsTimeSyncService::onStart(ServiceContext& /*serviceContext*/) { + Device* gps = nullptr; + if (!settings::isGpsTimeSyncAtBoot() || device_get_first_by_type(&GPS_TYPE, &gps) != ERROR_NONE) { + return true; + } + device_put(gps); + + if (system_event_callback_add(KERNEL_EVENT_LOCATION_TIME, &GpsTimeSyncService::onLocationTimeTrampoline, this) != ERROR_NONE) { + LOG_E(TAG, "Failed to listen for GPS time"); + return true; + } + eventSubscribed = true; + + // Fixes only come in while something is subscribed to the location, which also needs location services to be enabled + if (!location_is_enabled()) { + LOG_W(TAG, "Location services are disabled, waiting for another app to use them"); + return true; + } + task_event_group_construct(&eventGroup); + eventGroupConstructed = true; + if (location_subscribe(&subscription, &eventGroup, "Time sync") == ERROR_NONE) { + subscribed = true; + timer = std::make_unique(Timer::Type::Periodic, TIMER_INTERVAL_TICKS, [this] { onTimer(); }); + timer->start(); + } + return true; +} + +void GpsTimeSyncService::onStop(ServiceContext& /*serviceContext*/) { + if (timer) { + timer->stop(); + timer = nullptr; + } + if (eventSubscribed) { + system_event_callback_remove(KERNEL_EVENT_LOCATION_TIME, &GpsTimeSyncService::onLocationTimeTrampoline); + eventSubscribed = false; + } + if (subscribed.exchange(false)) { + location_unsubscribe(&subscription); + } + if (eventGroupConstructed) { + task_event_group_destruct(&eventGroup); + eventGroupConstructed = false; + } +} + +extern const ServiceManifest manifest = { + .id = "tactility.gpstime", + .createService = create +}; + +} // namespace tt::service::gpstime diff --git a/Tactility/Source/settings/AppearanceSettings.cpp b/Tactility/Source/settings/AppearanceSettings.cpp index d9fc43e4f..c2db83963 100644 --- a/Tactility/Source/settings/AppearanceSettings.cpp +++ b/Tactility/Source/settings/AppearanceSettings.cpp @@ -19,6 +19,8 @@ constexpr auto* SETTINGS_KEY_MONO_FONT_PATH = "monoFontPath"; constexpr auto* SETTINGS_KEY_THEME_MODE = "themeMode"; constexpr auto* SETTINGS_KEY_MONO_THEME = "monoTheme"; constexpr auto* SETTINGS_KEY_DENSITY = "density"; +constexpr auto* SETTINGS_KEY_SPACING = "spacing"; +constexpr auto* SETTINGS_KEY_SIZE = "size"; constexpr auto* SETTINGS_KEY_ANIMATIONS = "animations"; constexpr auto* SETTINGS_KEY_PRIMARY_COLOR = "primaryColor"; constexpr auto* SETTINGS_KEY_SECONDARY_COLOR = "secondaryColor"; @@ -59,6 +61,20 @@ static std::optional parseColor(const std::map parsePercent(const std::map& map, const char* key) { + const auto entry = map.find(key); + if (entry == map.end() || entry->second.empty()) { + return std::nullopt; + } + const std::string& text = entry->second; + uint32_t percent = 0; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), percent); + if (error != std::errc {} || end != text.data() + text.size() || percent > 100) { + return std::nullopt; + } + return static_cast(percent); +} + // Saving only updates the keys that are written, so an unset colour is written as an empty value static void putColor(std::map& map, const char* key, std::optional color) { if (color.has_value()) { @@ -81,16 +97,6 @@ static const char* toString(ThemeMode mode) { } } -static const char* toString(DensityMode mode) { - switch (mode) { - case DensityMode::Regular: - return "regular"; - case DensityMode::Compact: - return "compact"; - default: - return "default"; - } -} static std::string getSettingsFilePath() { char path[256]; @@ -131,13 +137,22 @@ bool load(AppearanceSettings& settings) { if (const auto entry = map.find(SETTINGS_KEY_MONO_THEME); entry != map.end()) { settings.monoTheme = entry->second == "true"; } + // Before spacing and size were separate, the density was either regular or compact if (const auto entry = map.find(SETTINGS_KEY_DENSITY); entry != map.end()) { if (entry->second == "regular") { - settings.densityMode = DensityMode::Regular; + settings.spacing = 100; + settings.size = 100; } else if (entry->second == "compact") { - settings.densityMode = DensityMode::Compact; + settings.spacing = 0; + settings.size = 0; } } + if (const auto value = parsePercent(map, SETTINGS_KEY_SPACING)) { + settings.spacing = value; + } + if (const auto value = parsePercent(map, SETTINGS_KEY_SIZE)) { + settings.size = value; + } if (const auto entry = map.find(SETTINGS_KEY_ANIMATIONS); entry != map.end()) { settings.animationsEnabled = entry->second != "false"; } @@ -175,7 +190,10 @@ bool save(const AppearanceSettings& settings) { map[SETTINGS_KEY_MONO_FONT_PATH] = settings.monoFontPath; map[SETTINGS_KEY_THEME_MODE] = toString(settings.themeMode); map[SETTINGS_KEY_MONO_THEME] = settings.monoTheme ? "true" : "false"; - map[SETTINGS_KEY_DENSITY] = toString(settings.densityMode); + // Unset values are written as empty, as with the colours. This also replaces the old density key. + map[SETTINGS_KEY_DENSITY] = ""; + map[SETTINGS_KEY_SPACING] = settings.spacing.has_value() ? std::to_string(*settings.spacing) : ""; + map[SETTINGS_KEY_SIZE] = settings.size.has_value() ? std::to_string(*settings.size) : ""; map[SETTINGS_KEY_ANIMATIONS] = settings.animationsEnabled ? "true" : "false"; putColor(map, SETTINGS_KEY_PRIMARY_COLOR, settings.primaryColor); putColor(map, SETTINGS_KEY_SECONDARY_COLOR, settings.secondaryColor); diff --git a/Tactility/Source/settings/time.cpp b/Tactility/Source/settings/time.cpp index 1060444b8..49bfa990c 100644 --- a/Tactility/Source/settings/time.cpp +++ b/Tactility/Source/settings/time.cpp @@ -22,6 +22,7 @@ constexpr auto* TAG = "Time"; constexpr auto* TIMEZONE_PREFERENCES_KEY_NAME = "tz_name"; constexpr auto* TIMEZONE_PREFERENCES_KEY_CODE = "tz_code"; constexpr auto* TIMEZONE_PREFERENCES_KEY_TIME24 = "tz_time24"; +constexpr auto* GPS_SYNC_AT_BOOT_KEY = "gps_sync_boot"; namespace { @@ -142,4 +143,31 @@ void setTimeFormat24Hour(bool show24Hour) { lvgl_statusbar_set_time_format_24h(show24Hour); } +bool isGpsTimeSyncAtBoot() { + std::string path; + if (!getPreferencesPath(path)) { + return false; + } + Preferences* preferences = preferences_open(path.c_str()); + if (preferences == nullptr) { + return false; + } + bool enabled = false; + preferences_opt_bool(preferences, GPS_SYNC_AT_BOOT_KEY, &enabled); + preferences_close(preferences); + return enabled; +} + +void setGpsTimeSyncAtBoot(bool enabled) { + std::string path; + if (!getPreferencesPath(path)) { + return; + } + Preferences* preferences = preferences_open(path.c_str()); + if (preferences != nullptr) { + preferences_put_bool(preferences, GPS_SYNC_AT_BOOT_KEY, enabled); + preferences_close(preferences); + } +} + } diff --git a/Tactility/Tests/Source/AppearanceSettingsTest.cpp b/Tactility/Tests/Source/AppearanceSettingsTest.cpp new file mode 100644 index 000000000..5ee5789cd --- /dev/null +++ b/Tactility/Tests/Source/AppearanceSettingsTest.cpp @@ -0,0 +1,101 @@ +#include "doctest.h" + +#include +#include + +#include +#include + +using namespace tt::settings::appearance; + +namespace { + +std::string getSettingsPath() { + char path[256]; + REQUIRE(app_paths_get_user_data_path("tactility.appearance", "appearance.properties", path, sizeof(path)) == ERROR_NONE); + return path; +} + +void writeSettingsFile(const std::string& content) { + const auto path = getSettingsPath(); + REQUIRE(tt::file::findOrCreateParentDirectory(path, 0755)); + REQUIRE(tt::file::writeString(path, content)); +} + +} + +TEST_CASE("spacing and size are saved and loaded") { + AppearanceSettings settings; + settings.spacing = 40; + settings.size = 80; + REQUIRE(save(settings)); + + AppearanceSettings loaded; + REQUIRE(load(loaded)); + CHECK_EQ(loaded.spacing, std::optional(40)); + CHECK_EQ(loaded.size, std::optional(80)); +} + +TEST_CASE("unset spacing and size stay unset after saving over set values") { + AppearanceSettings settings; + settings.spacing = 20; + settings.size = 20; + REQUIRE(save(settings)); + + settings.spacing = std::nullopt; + settings.size = std::nullopt; + REQUIRE(save(settings)); + + AppearanceSettings loaded; + REQUIRE(load(loaded)); + CHECK_FALSE(loaded.spacing.has_value()); + CHECK_FALSE(loaded.size.has_value()); +} + +TEST_CASE("the old density setting becomes spacing and size") { + writeSettingsFile("density=compact\n"); + AppearanceSettings loaded; + REQUIRE(load(loaded)); + CHECK_EQ(loaded.spacing, std::optional(0)); + CHECK_EQ(loaded.size, std::optional(0)); + + writeSettingsFile("density=regular\n"); + REQUIRE(load(loaded)); + CHECK_EQ(loaded.spacing, std::optional(100)); + CHECK_EQ(loaded.size, std::optional(100)); + + writeSettingsFile("density=default\n"); + REQUIRE(load(loaded)); + CHECK_FALSE(loaded.spacing.has_value()); + CHECK_FALSE(loaded.size.has_value()); +} + +TEST_CASE("spacing and size take precedence over the old density setting") { + writeSettingsFile("density=compact\nspacing=60\nsize=\n"); + AppearanceSettings loaded; + REQUIRE(load(loaded)); + CHECK_EQ(loaded.spacing, std::optional(60)); + CHECK_EQ(loaded.size, std::optional(0)); +} + +TEST_CASE("the old density setting is not applied again after saving the device defaults") { + writeSettingsFile("density=compact\n"); + AppearanceSettings settings; + REQUIRE(load(settings)); + settings.spacing = std::nullopt; + settings.size = std::nullopt; + REQUIRE(save(settings)); + + AppearanceSettings loaded; + REQUIRE(load(loaded)); + CHECK_FALSE(loaded.spacing.has_value()); + CHECK_FALSE(loaded.size.has_value()); +} + +TEST_CASE("invalid spacing and size count as unset") { + writeSettingsFile("spacing=150\nsize=abc\n"); + AppearanceSettings loaded; + REQUIRE(load(loaded)); + CHECK_FALSE(loaded.spacing.has_value()); + CHECK_FALSE(loaded.size.has_value()); +} diff --git a/Tactility/Tests/Source/GpsTimeSyncTest.cpp b/Tactility/Tests/Source/GpsTimeSyncTest.cpp new file mode 100644 index 000000000..70b37e151 --- /dev/null +++ b/Tactility/Tests/Source/GpsTimeSyncTest.cpp @@ -0,0 +1,30 @@ +#include "doctest.h" + +#include + +#include + +using namespace tt::service::gpstime; + +TEST_CASE("gps time is applied when ntp has not synced") { + CHECK(shouldApplyGpsTime(1700000000000, 1700000000000, false)); + CHECK(shouldApplyGpsTime(1700000000000, 0, false)); +} + +TEST_CASE("gps time only corrects a synced clock when it drifted") { + const int64_t gps = 1700000000000; + CHECK_FALSE(shouldApplyGpsTime(gps, gps, true)); + CHECK_FALSE(shouldApplyGpsTime(gps, gps + MAX_DRIFT_MS, true)); + CHECK_FALSE(shouldApplyGpsTime(gps, gps - MAX_DRIFT_MS, true)); + CHECK(shouldApplyGpsTime(gps, gps + MAX_DRIFT_MS + 1, true)); + CHECK(shouldApplyGpsTime(gps, gps - MAX_DRIFT_MS - 1, true)); +} + +#ifndef ESP_PLATFORM +TEST_CASE("gps time doesn't change the clock of the host") { + const time_t before = time(nullptr); + GpsTimeSyncService service; + service.onLocationTime(static_cast(before + 86400) * 1000); + CHECK(std::llabs(static_cast(time(nullptr) - before)) <= 1); +} +#endif diff --git a/TactilityKernel/include/tactility/network.h b/TactilityKernel/include/tactility/network.h new file mode 100644 index 000000000..437b88354 --- /dev/null +++ b/TactilityKernel/include/tactility/network.h @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @return true when a network interface is connected, so connections to the internet can succeed. + * Currently this is the case when a WiFi station is connected. + */ +bool network_is_available(void); + +#ifdef __cplusplus +} +#endif diff --git a/TactilityKernel/include/tactility/system_event.h b/TactilityKernel/include/tactility/system_event.h index 1d6c032a9..8ac9177b5 100644 --- a/TactilityKernel/include/tactility/system_event.h +++ b/TactilityKernel/include/tactility/system_event.h @@ -26,6 +26,7 @@ enum SystemEventType { KERNEL_EVENT_SERVICE_STOPPED, // ServiceStoppedEvent KERNEL_EVENT_TIME_CHANGED, // No data - fired whenever system time is set (NTP sync, RTC restore, manual change) KERNEL_EVENT_DEEP_SLEEP_PREPARE, // No data - fired synchronously right before the system enters deep sleep + KERNEL_EVENT_LOCATION_TIME, // struct LocationTimeEvent }; /** Data for KERNEL_EVENT_NETWORK_CONNECTED. */ @@ -60,6 +61,12 @@ struct ServiceStoppedEvent { const char* id; }; +/** Data for KERNEL_EVENT_LOCATION_TIME. Fired at most once per minute while a satellite provider reports fixes. */ +struct LocationTimeEvent { + /** Current time according to the provider, in milliseconds since the Unix epoch */ + int64_t time_ms; +}; + /** Size of the largest type-specific event struct documented in SystemEventType, i.e. the * embedded buffer size needed by SystemEvent/SystemEventSubscription to hold any event's * payload by value. */ diff --git a/TactilityKernel/source/network.c b/TactilityKernel/source/network.c new file mode 100644 index 000000000..b16039890 --- /dev/null +++ b/TactilityKernel/source/network.c @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include + +static bool check_wifi_connected(struct Device* device, void* context) { + if (!device_is_ready(device)) { + return true; + } + enum WifiStationState state = WIFI_STATION_STATE_DISCONNECTED; + if (wifi_get_station_state(device, &state) == ERROR_NONE && state == WIFI_STATION_STATE_CONNECTED) { + *(bool*)context = true; + return false; + } + return true; +} + +bool network_is_available(void) { + bool available = false; + device_for_each_of_type(&WIFI_TYPE, &available, check_wifi_connected); + return available; +} diff --git a/TactilityKernel/source/symbols.c b/TactilityKernel/source/symbols.c index 9cd2b5cff..8e703051b 100644 --- a/TactilityKernel/source/symbols.c +++ b/TactilityKernel/source/symbols.c @@ -54,6 +54,7 @@ #include #include #include +#include #include #include #include @@ -475,6 +476,8 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = { DEFINE_MODULE_SYMBOL(wifi_event_poll), DEFINE_MODULE_SYMBOL(wifi_get_firmware_ops), DEFINE_MODULE_SYMBOL(WIFI_TYPE), + // network + DEFINE_MODULE_SYMBOL(network_is_available), // wifi_auto_scan DEFINE_MODULE_SYMBOL(wifi_auto_scan_set_paused), // drivers/radio diff --git a/TactilityKernel/tests/source/network_test.cpp b/TactilityKernel/tests/source/network_test.cpp new file mode 100644 index 000000000..97440f649 --- /dev/null +++ b/TactilityKernel/tests/source/network_test.cpp @@ -0,0 +1,56 @@ +#include "doctest.h" +#include +#include +#include +#include + +static WifiStationState fake_station_state = WIFI_STATION_STATE_DISCONNECTED; + +static error_t fake_get_station_state(Device*, WifiStationState* state) { + *state = fake_station_state; + return ERROR_NONE; +} + +static const WifiApi network_test_wifi_api = { + .get_station_state = fake_get_station_state, +}; + +static Driver network_test_wifi_driver = { + .name = "network_test_wifi_driver", + .compatible = (const char*[]) { "network_test,wifi", nullptr }, + .start_device = nullptr, + .stop_device = nullptr, + .api = &network_test_wifi_api, + .device_type = &WIFI_TYPE, + .internal = nullptr, +}; + +TEST_CASE("network is only available while a wifi station is connected") { + static Device device { + .name = "network_test_wifi_device", + .config = nullptr, + .parent = nullptr, + }; + + CHECK_FALSE(network_is_available()); + + CHECK_EQ(driver_construct_add(&network_test_wifi_driver), ERROR_NONE); + CHECK_EQ(device_construct_add(&device, "network_test,wifi"), ERROR_NONE); + + // A device that isn't started has no connection + fake_station_state = WIFI_STATION_STATE_CONNECTED; + CHECK_FALSE(network_is_available()); + + CHECK_EQ(device_start(&device), ERROR_NONE); + CHECK(network_is_available()); + + fake_station_state = WIFI_STATION_STATE_CONNECTION_PENDING; + CHECK_FALSE(network_is_available()); + fake_station_state = WIFI_STATION_STATE_DISCONNECTED; + CHECK_FALSE(network_is_available()); + + CHECK_EQ(device_stop(&device), ERROR_NONE); + CHECK_EQ(device_remove(&device), ERROR_NONE); + CHECK_EQ(device_destruct(&device), ERROR_NONE); + CHECK_EQ(driver_remove_destruct(&network_test_wifi_driver), ERROR_NONE); +} diff --git a/device.py b/device.py index 31a626856..c21262199 100755 --- a/device.py +++ b/device.py @@ -330,6 +330,13 @@ def write_lvgl_variables(output_file, device_properties: dict): if fs_cache_size_text is not None: fs_cache_size = safe_int(fs_cache_size_text, f"File cache size must be an integer, but was: '{fs_cache_size_text}'") output_file.write(f"CONFIG_LV_FS_STDIO_CACHE_SIZE={fs_cache_size}\n") + for key, config in (("lvgl.uiSpacing", "CONFIG_TT_UI_SPACING"), ("lvgl.uiSize", "CONFIG_TT_UI_SIZE")): + percent_text = get_property_or_none(device_properties, key) + if percent_text is not None: + percent = safe_int(percent_text, f"{key} must be an integer, but was: '{percent_text}'") + if not 0 <= percent <= 100: + exit_with_error(f"{key} must be between 0 and 100, but was: {percent}") + output_file.write(f"{config}={percent}\n") font_size_text = get_property_or_none(device_properties, "font.defaultSize") if font_size_text is None: font_size_text = get_property_or_default(device_properties, "lvgl.fontSize", "14") From 085fa0587230e13ddc367be42c5e74644786b7a0 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 31 Oct 2026 00:04:15 +0100 Subject: [PATCH 02/13] Fixes --- .../include/location/location.h | 3 +- .../include/location/provider.h | 6 ++++ .../location-module/source/gps_provider.cpp | 1 + .../location-module/source/ip_provider.cpp | 1 + .../source/location_service.cpp | 34 ++++++++++++++++--- .../tests/source/service_test.cpp | 26 ++++++++++++-- Modules/lvgl-module/include/lvgl/theme.h | 2 +- .../lvgl-module/tests/source/theme_test.cpp | 6 ++++ Modules/mesh-module/include/mesh/backend.h | 1 + Modules/mesh-module/source/mesh_service.cpp | 15 +++++++- .../mesh-module/tests/source/service_test.cpp | 24 +++++++++++++ Tactility/Source/app/boot/BootInit.cpp | 4 ++- Tactility/Source/app/boot/BootScreen.cpp | 12 ++++--- Tactility/Source/app/setup/Setup.cpp | 15 +++++++- .../app/timedatesettings/TimeDateSettings.cpp | 28 +++++++++++++-- .../service/gpstime/GpsTimeSyncService.cpp | 27 ++++++++------- .../Source/settings/AppearanceSettings.cpp | 9 ++--- .../Tests/Source/AppearanceSettingsTest.cpp | 2 +- 18 files changed, 179 insertions(+), 37 deletions(-) diff --git a/Modules/location-module/include/location/location.h b/Modules/location-module/include/location/location.h index 845f32e66..915e98dfd 100644 --- a/Modules/location-module/include/location/location.h +++ b/Modules/location-module/include/location/location.h @@ -89,6 +89,7 @@ enum LocationPrecision location_get_precision(void); */ error_t location_get(struct Location* location); +/** In bytes */ #define LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH 24 /** @@ -111,7 +112,7 @@ struct LocationSubscription { * Providers start and stop in the background: subscribing and unsubscribing don't wait for them. * @param[in,out] sub subscription to register, must stay alive until unsubscribed * @param[in] event_group caller-owned group to wait on, must outlive @a sub - * @param[in] description what the subscriber is, shown to the user. Truncated to LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH characters. + * @param[in] description what the subscriber is, shown to the user. Truncated to LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH bytes, at a UTF-8 character boundary. * @retval ERROR_RESOURCE @a event_group has no free bits left * @retval ERROR_INVALID_STATE @a sub is already registered */ diff --git a/Modules/location-module/include/location/provider.h b/Modules/location-module/include/location/provider.h index a0e244a62..99a73568f 100644 --- a/Modules/location-module/include/location/provider.h +++ b/Modules/location-module/include/location/provider.h @@ -28,6 +28,12 @@ struct LocationProviderApi { */ bool fallback; + /** + * True when the provider reports the time of its fixes from a reliable clock, e.g. satellites. + * The service announces that time with KERNEL_EVENT_LOCATION_TIME. + */ + bool provides_time; + /** * Start the provider. Called on the service's worker task while the service runs and has subscriptions. * It must not block: the work happens on the provider's own task. diff --git a/Modules/location-module/source/gps_provider.cpp b/Modules/location-module/source/gps_provider.cpp index 797f69bf8..3344d13a6 100644 --- a/Modules/location-module/source/gps_provider.cpp +++ b/Modules/location-module/source/gps_provider.cpp @@ -331,6 +331,7 @@ bool gps_location_from_nmea(const minmea_sentence_rmc* rmc, const minmea_sentenc const LocationProviderApi gps_location_provider = { .id = "gps", .fallback = false, + .provides_time = true, .create = create, .request_stop = requestStop, .wait_stopped = waitStopped, diff --git a/Modules/location-module/source/ip_provider.cpp b/Modules/location-module/source/ip_provider.cpp index 59588e59e..2d664ce95 100644 --- a/Modules/location-module/source/ip_provider.cpp +++ b/Modules/location-module/source/ip_provider.cpp @@ -397,6 +397,7 @@ bool ip_lookup_parse_response(const char* response, IpLookupResponse* out) { const LocationProviderApi ip_location_provider = { .id = "ip", .fallback = true, + .provides_time = false, .create = create, .request_stop = requestStop, .wait_stopped = waitStopped, diff --git a/Modules/location-module/source/location_service.cpp b/Modules/location-module/source/location_service.cpp index a9dca1955..d3d37d8d2 100644 --- a/Modules/location-module/source/location_service.cpp +++ b/Modules/location-module/source/location_service.cpp @@ -80,6 +80,14 @@ uint64_t lastTimeEventUptimeMs = 0; Thread* worker = nullptr; bool workerActive = false; +// 2025-01-01 and 2100-01-01 in milliseconds since the Unix epoch. GPS receivers with a wrong date, e.g. from a week rollover, fall outside it. +constexpr int64_t MIN_PLAUSIBLE_TIME_MS = 1735689600000; +constexpr int64_t MAX_PLAUSIBLE_TIME_MS = 4102444800000; + +bool isPlausibleTime(int64_t timeMs) { + return timeMs >= MIN_PLAUSIBLE_TIME_MS && timeMs < MAX_PLAUSIBLE_TIME_MS; +} + GlobalMutex settingsMutex; LocationSettings settings {}; bool settingsLoaded = false; @@ -445,13 +453,14 @@ void location_host_report(LocationHost* host, const Location* location) { strcpy(host->location.provider, host->api->id); host->location.uptime_ms = getUptimeMs(); host->hasLocation = true; - if (findBest() == host) { + const bool isBest = findBest() == host; + if (isBest) { for (auto* sub = subscriptions; sub != nullptr; sub = sub->internal.next) { task_event_group_signal(sub->internal.event_group, sub->bit); } } const uint64_t now = host->location.uptime_ms; - const bool emitTime = !host->api->fallback && host->location.time_ms > 0 && + const bool emitTime = isBest && host->api->provides_time && isPlausibleTime(host->location.time_ms) && (!hasEmittedTime || now - lastTimeEventUptimeMs >= TIME_EVENT_INTERVAL_MS); if (emitTime) { hasEmittedTime = true; @@ -488,6 +497,24 @@ error_t location_get(Location* location) { return best != nullptr ? ERROR_NONE : ERROR_NOT_FOUND; } +namespace { + +/** Copies at most LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH bytes, without cutting a UTF-8 character in half */ +void copyDescription(char* out, const char* description) { + size_t length = strnlen(description, LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH + 1); + if (length > LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH) { + length = LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH; + // The first byte that doesn't fit must start a character, otherwise back up to the start of the cut one + while (length > 0 && (static_cast(description[length]) & 0xC0) == 0x80) { + length--; + } + } + memcpy(out, description, length); + out[length] = '\0'; +} + +} + error_t location_subscribe(LocationSubscription* sub, TaskEventGroup* event_group, const char* description) { uint32_t bit; const error_t claimed = task_event_group_claim_bit(event_group, &bit); @@ -504,8 +531,7 @@ error_t location_subscribe(LocationSubscription* sub, TaskEventGroup* event_grou } } sub->bit = bit; - strncpy(sub->internal.description, description != nullptr ? description : "", LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH); - sub->internal.description[LOCATION_SUBSCRIBER_DESCRIPTION_MAX_LENGTH] = '\0'; + copyDescription(sub->internal.description, description != nullptr ? description : ""); sub->internal.event_group = event_group; sub->internal.next = subscriptions; subscriptions = sub; diff --git a/Modules/location-module/tests/source/service_test.cpp b/Modules/location-module/tests/source/service_test.cpp index 8b3d99018..84903caf6 100644 --- a/Modules/location-module/tests/source/service_test.cpp +++ b/Modules/location-module/tests/source/service_test.cpp @@ -41,6 +41,7 @@ bool fakeWaitStopped(void*, TickType_t) { const LocationProviderApi fakeProvider = { .id = "fake", .fallback = false, + .provides_time = true, .create = fakeCreate, .request_stop = fakeRequestStop, .wait_stopped = fakeWaitStopped, @@ -97,6 +98,17 @@ TEST_CASE("location service lists the descriptions of its subscribers") { CHECK(descriptions[1] == "First"); CHECK(location_unsubscribe(&second) == ERROR_NONE); + + // A character that crosses the limit is dropped entirely: 23 bytes followed by a 2 byte character + LocationSubscription accented {}; + REQUIRE(location_subscribe(&accented, &group, "aaaaaaaaaaaaaaaaaaaaaaa\xC3\xA9") == ERROR_NONE); + descriptions.clear(); + location_for_each_subscriber(&descriptions, [](const char* description, void* context) { + static_cast*>(context)->push_back(description); + }); + REQUIRE(descriptions.size() == 2); + CHECK(descriptions[0] == "aaaaaaaaaaaaaaaaaaaaaaa"); + CHECK(location_unsubscribe(&accented) == ERROR_NONE); descriptions.clear(); location_for_each_subscriber(&descriptions, [](const char* description, void* context) { static_cast*>(context)->push_back(description); @@ -179,7 +191,7 @@ TEST_CASE("location provider registration is limited") { char ids[LOCATION_PROVIDER_MAX_COUNT + 1][8]; for (size_t i = 0; i <= LOCATION_PROVIDER_MAX_COUNT; i++) { snprintf(ids[i], sizeof(ids[i]), "p%zu", i); - providers[i] = { .id = ids[i], .fallback = false, .create = fakeCreate, .request_stop = fakeRequestStop, .wait_stopped = fakeWaitStopped }; + providers[i] = { .id = ids[i], .fallback = false, .provides_time = false, .create = fakeCreate, .request_stop = fakeRequestStop, .wait_stopped = fakeWaitStopped }; } for (size_t i = 0; i < LOCATION_PROVIDER_MAX_COUNT; i++) { REQUIRE(location_provider_register(&providers[i]) == ERROR_NONE); @@ -289,7 +301,7 @@ TEST_CASE("location service announces the time of satellite fixes once per minut REQUIRE(location_subscribe(&sub, &group, "test") == ERROR_NONE); REQUIRE(waitUntil([] { return fakeHost != nullptr && fallbackHost != nullptr; })); - // Fallback providers don't know the time + // Providers that don't provide the time are ignored Location fix = makeLocation(51.05, 3.72); location_host_report(fallbackHost, &fix); CHECK(timeEventCount == 0); @@ -299,10 +311,18 @@ TEST_CASE("location service announces the time of satellite fixes once per minut location_host_report(fakeHost, &fix); CHECK(timeEventCount == 0); + // Dates before the firmware existed or far in the future are wrong fix.time_ms = 1700000000123; location_host_report(fakeHost, &fix); + CHECK(timeEventCount == 0); + fix.time_ms = 4200000000000; + location_host_report(fakeHost, &fix); + CHECK(timeEventCount == 0); + + fix.time_ms = 1780000000123; + location_host_report(fakeHost, &fix); CHECK(timeEventCount == 1); - CHECK(lastTimeEventMs == 1700000000123); + CHECK(lastTimeEventMs == 1780000000123); // The next fix is within the same minute fix.time_ms += 1000; diff --git a/Modules/lvgl-module/include/lvgl/theme.h b/Modules/lvgl-module/include/lvgl/theme.h index 051ea8461..54da99d1d 100644 --- a/Modules/lvgl-module/include/lvgl/theme.h +++ b/Modules/lvgl-module/include/lvgl/theme.h @@ -73,7 +73,7 @@ uint8_t lvgl_theme_get_size(void); * @param[in] percent 0 to 100, e.g. from lvgl_theme_get_spacing() or lvgl_theme_get_size() */ static inline int32_t lvgl_theme_lerp(int32_t compact, int32_t regular, uint8_t percent) { - return compact + (regular - compact) * (int32_t)percent / 100; + return (int32_t)(compact + ((int64_t)regular - compact) * percent / 100); } /** diff --git a/Modules/lvgl-module/tests/source/theme_test.cpp b/Modules/lvgl-module/tests/source/theme_test.cpp index 44e01c8b8..d2db27254 100644 --- a/Modules/lvgl-module/tests/source/theme_test.cpp +++ b/Modules/lvgl-module/tests/source/theme_test.cpp @@ -20,3 +20,9 @@ TEST_CASE("lvgl_theme_lerp interpolates downwards and with negative values") { CHECK_EQ(lvgl_theme_lerp(-3, -8, 0), -3); CHECK_EQ(lvgl_theme_lerp(-3, -8, 100), -8); } + +TEST_CASE("lvgl_theme_lerp doesn't overflow with extreme values") { + CHECK_EQ(lvgl_theme_lerp(INT32_MIN, INT32_MAX, 0), INT32_MIN); + CHECK_EQ(lvgl_theme_lerp(INT32_MIN, INT32_MAX, 100), INT32_MAX); + CHECK_EQ(lvgl_theme_lerp(INT32_MAX, INT32_MIN, 100), INT32_MIN); +} diff --git a/Modules/mesh-module/include/mesh/backend.h b/Modules/mesh-module/include/mesh/backend.h index 96f211fb7..f2cbe7dc1 100644 --- a/Modules/mesh-module/include/mesh/backend.h +++ b/Modules/mesh-module/include/mesh/backend.h @@ -115,6 +115,7 @@ struct MeshBackendApi { /** * Register a backend. Usually called from the backend module's start(). + * @retval ERROR_INVALID_ARGUMENT when the id is empty or has characters other than letters, digits, '_' and '-' * @retval ERROR_ALREADY_EXISTS when a backend with the same id is registered * @retval ERROR_RESOURCE when the registry is full */ diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index c03d93ff7..e75264574 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -729,7 +729,15 @@ void configure(MeshHost* host) { // The messages and contacts of the selected backend are available when its radio is off, and no other backend's are lock(); + char previousStorage[MESH_BACKEND_ID_MAX_LENGTH + 1]; + strncpy(previousStorage, host->storageBackend, sizeof(previousStorage)); + previousStorage[MESH_BACKEND_ID_MAX_LENGTH] = '\0'; const bool storageReady = switchStorage(host, getSelectedBackend()); + // Consumers show the contacts and channels of the selected backend, also when its radio is off + if (strncmp(previousStorage, host->storageBackend, MESH_BACKEND_ID_MAX_LENGTH) != 0) { + emitEvent(MESH_EVENT_CONTACT, host->state, 0, MESH_DELIVERY_RECEIVED); + emitEvent(MESH_EVENT_CHANNEL, host->state, 0, MESH_DELIVERY_RECEIVED); + } unlock(); if (!storageReady) { setStateLocked(host, MESH_STATE_ERROR); @@ -1198,8 +1206,10 @@ error_t onStart(ServiceInstance*, void* data) { }); } + // Opened before the service is published, so queries don't see an empty store. A failure is reported by configure(). lock(); ensureSettingsLoaded(); + switchStorage(host, getSelectedBackend()); service = host; unlock(); @@ -1213,6 +1223,7 @@ error_t onStart(ServiceInstance*, void* data) { } lock(); service = nullptr; + switchStorage(host, nullptr); unlock(); releaseResources(host); return ERROR_RESOURCE; @@ -1269,7 +1280,9 @@ extern "C" { error_t mesh_backend_register(const MeshBackendApi* api) { lock(); error_t result = ERROR_NONE; - if (findBackend(api->id) != nullptr) { + if (!isValidBackendId(api->id)) { + result = ERROR_INVALID_ARGUMENT; + } else if (findBackend(api->id) != nullptr) { result = ERROR_ALREADY_EXISTS; } else if (globals().backendCount >= MAX_BACKENDS) { result = ERROR_RESOURCE; diff --git a/Modules/mesh-module/tests/source/service_test.cpp b/Modules/mesh-module/tests/source/service_test.cpp index 1ff8f8a24..9a9b70a27 100644 --- a/Modules/mesh-module/tests/source/service_test.cpp +++ b/Modules/mesh-module/tests/source/service_test.cpp @@ -271,6 +271,11 @@ TEST_CASE("mesh service sends and receives through the active backend") { REQUIRE_EQ(mesh_backend_register(&loopbackApi), ERROR_NONE); CHECK_EQ(mesh_backend_register(&loopbackApi), ERROR_ALREADY_EXISTS); + for (const char* id : { "", "../escape", "a/b", "a.b", "a b" }) { + MeshBackendApi invalidApi = loopbackApi; + invalidApi.id = id; + CHECK_EQ(mesh_backend_register(&invalidApi), ERROR_INVALID_ARGUMENT); + } REQUIRE_EQ(service_manager_add(&mesh_service_manifest, false), ERROR_NONE); SUBCASE("messages and contacts that all backends shared move to their backends") { @@ -428,12 +433,31 @@ TEST_CASE("mesh service sends and receives through the active backend") { CHECK_EQ(countConversation(), 1); CHECK_EQ(countContacts(), 1); + TaskEventGroup storageGroup {}; + task_event_group_construct(&storageGroup); + MeshEventSubscription storageEvents {}; + REQUIRE_EQ(mesh_event_subscribe(&storageEvents, &storageGroup), ERROR_NONE); + // Events of the setup above + MeshEvent storageEvent; + while (mesh_event_poll(&storageEvents, &storageEvent) == ERROR_NONE) {} + // Another backend starts with nothing, and what it stores stays with it REQUIRE_EQ(mesh_set_backend("spare"), ERROR_NONE); REQUIRE(waitFor([] { char id[MESH_BACKEND_ID_MAX_LENGTH + 1]; return mesh_get_state() == MESH_STATE_ON && mesh_get_backend(id, sizeof(id)) == ERROR_NONE && std::string(id) == "spare"; })); + // Selecting a backend tells consumers that the contacts and channels changed + bool contactsChanged = false; + bool channelsChanged = false; + while (mesh_event_poll(&storageEvents, &storageEvent) == ERROR_NONE) { + contactsChanged |= storageEvent.type == MESH_EVENT_CONTACT; + channelsChanged |= storageEvent.type == MESH_EVENT_CHANNEL; + } + CHECK(contactsChanged); + CHECK(channelsChanged); + CHECK_EQ(mesh_event_unsubscribe(&storageEvents), ERROR_NONE); + task_event_group_destruct(&storageGroup); CHECK_EQ(countHistory(), 0); CHECK_EQ(countConversation(), 0); CHECK_EQ(countContacts(), 0); diff --git a/Tactility/Source/app/boot/BootInit.cpp b/Tactility/Source/app/boot/BootInit.cpp index 4cf2c1cab..17c5dd054 100644 --- a/Tactility/Source/app/boot/BootInit.cpp +++ b/Tactility/Source/app/boot/BootInit.cpp @@ -204,7 +204,9 @@ bool powerOff() { /** Offers reboot and power off, or only a reboot when the device can't power off */ void showMessageAndRebootOrPowerOff(BootScreen& screen, const std::vector& lines) { - if (!isPowerOffSupported()) { + const bool power_off_supported = isPowerOffSupported(); + LOG_I(TAG, "Power off supported: %d", static_cast(power_off_supported)); + if (!power_off_supported) { auto with_prompt = lines; with_prompt.push_back(getInputPrompt("reboot")); screen.show({}, with_prompt); diff --git a/Tactility/Source/app/boot/BootScreen.cpp b/Tactility/Source/app/boot/BootScreen.cpp index ad202b620..816a7fe3b 100644 --- a/Tactility/Source/app/boot/BootScreen.cpp +++ b/Tactility/Source/app/boot/BootScreen.cpp @@ -418,7 +418,7 @@ BootChoice waitForBootChoice(BootScreen& screen, const BootLogo& logo, const std KeyboardKeyData data; while (keyboard_read_key(keyboards[i], &data) == ERROR_NONE && data.key != 0 && data.continue_reading) {} - while (keyboard_poll(keyboards[i], &subscriptions[i], &data) == ERROR_NONE) { + while (!confirmed && keyboard_poll(keyboards[i], &subscriptions[i], &data) == ERROR_NONE) { if (!data.pressed) { continue; } @@ -450,9 +450,13 @@ BootChoice waitForBootChoice(BootScreen& screen, const BootLogo& logo, const std const bool touched = touch_get_touched_points(pointer, &x, &y, &strength, &point_count, 1) && point_count > 0; if (!touched) { if (pointer_down) { - // A tap - selected = selected == BootChoice::Reboot ? BootChoice::PowerOff : BootChoice::Reboot; - redraw = true; + if (get_millis() - pointer_down_at >= LONG_PRESS_MS) { + confirmed = true; + } else { + // A tap + selected = selected == BootChoice::Reboot ? BootChoice::PowerOff : BootChoice::Reboot; + redraw = true; + } pointer_down = false; } pointer_released = true; diff --git a/Tactility/Source/app/setup/Setup.cpp b/Tactility/Source/app/setup/Setup.cpp index cd4b4919b..22925795d 100644 --- a/Tactility/Source/app/setup/Setup.cpp +++ b/Tactility/Source/app/setup/Setup.cpp @@ -124,6 +124,8 @@ struct StepConfiguration { std::vector choices; /** The index in choices that is picked, -1 when none. Guarded by the LVGL lock. */ int32_t choice = -1; + /** Optional: the choice to pick when the page is shown and none was picked yet. Called with the LVGL lock held. */ + std::function defaultChoice = nullptr; }; struct Context { @@ -340,6 +342,12 @@ void advanceTo(Context* ctx, size_t index) { } lvgl_lock(); + if (ctx->phase == Phase::StepIntro) { + auto& step = ctx->steps[ctx->stepIndex]; + if (step.choice < 0 && step.defaultChoice) { + step.choice = step.defaultChoice(); + } + } // Covered by a step's app: createWidgets() renders the current page when the window is shown again if (ctx->titleLabel != nullptr) { renderCurrent(ctx); @@ -538,6 +546,7 @@ int32_t appMain(int argc, char* argv[]) { } }; + const size_t locationStepIndex = ctx.steps.size(); ctx.steps.push_back({ .title = "Location Services", .description = "Do you want to allow apps to retrieve your location? It uses GPS and/or contacts geoiplookup.io", @@ -585,7 +594,11 @@ int32_t appMain(int argc, char* argv[]) { }, .content = StepConfiguration::Content::Choice, .choices = { "Enabled", "Disabled" }, - .choice = 0 + // Enabled unless location services are, as the time needs them + .defaultChoice = [&ctx, locationStepIndex] { + const int32_t locationChoice = ctx.steps[locationStepIndex].choice; + return (locationChoice == 0 || locationChoice == 1) ? 0 : 1; + } }); } diff --git a/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp b/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp index 3dcb427e9..640d7a131 100644 --- a/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp +++ b/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp @@ -47,14 +47,24 @@ struct Context { // Read when the app starts, as widgets can't read files bool hasGps = false; bool gpsSyncAtBoot = false; + // Guarded by the LVGL lock, and refreshed by the app's task bool locationEnabled = false; uint32_t pendingTimeZoneDialogId = 0; uint32_t dateFormatDialogId = 0; // Valid while the window is shown lv_obj_t* timeZoneLabel = nullptr; lv_obj_t* dateFormatLabel = nullptr; + lv_obj_t* gpsSyncLabel = nullptr; }; +constexpr uint32_t LOCATION_REFRESH_MS = 1000; + +void updateGpsSyncText(Context* ctx) { + if (ctx->gpsSyncLabel != nullptr) { + lv_label_set_text(ctx->gpsSyncLabel, ctx->locationEnabled ? "Sets the clock once after boot" : "Requires location services"); + } +} + std::string getDateFormat() { settings::SystemSettings sysSettings; if (settings::loadSystemSettings(sysSettings)) { @@ -224,8 +234,8 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_obj_set_style_bg_opa(gps_sync_row, LV_OPA_TRANSP, LV_STATE_DEFAULT); lv_obj_set_scrollable(gps_sync_row, false); - auto* gps_sync_value = createTitleAndValue(gps_sync_row, "GPS sync at boot"); - lv_label_set_text(gps_sync_value, ctx->locationEnabled ? "Sets the clock once after boot" : "Requires location services"); + ctx->gpsSyncLabel = createTitleAndValue(gps_sync_row, "GPS sync at boot"); + updateGpsSyncText(ctx); auto* gps_sync_switch = lv_switch_create(gps_sync_row); lv_obj_set_state(gps_sync_switch, LV_STATE_CHECKED, ctx->gpsSyncAtBoot); @@ -262,6 +272,7 @@ void destroyWidgets(void* userData) { auto* ctx = static_cast(userData); ctx->timeZoneLabel = nullptr; ctx->dateFormatLabel = nullptr; + ctx->gpsSyncLabel = nullptr; } int32_t appMain(int argc, char* argv[]) { @@ -289,7 +300,18 @@ int32_t appMain(int argc, char* argv[]) { bool shouldClose = false; while (!shouldClose) { uint32_t flags = 0; - task_event_group_wait_any(&event_group, &flags, portMAX_DELAY); + // Location settings can change while this app is buried, so the explanation of the GPS row is refreshed regularly + task_event_group_wait_any(&event_group, &flags, ctx.hasGps ? pdMS_TO_TICKS(LOCATION_REFRESH_MS) : portMAX_DELAY); + + if (ctx.hasGps) { + const bool locationEnabled = location_is_enabled(); + lvgl_lock(); + if (locationEnabled != ctx.locationEnabled) { + ctx.locationEnabled = locationEnabled; + updateGpsSyncText(&ctx); + } + lvgl_unlock(); + } if (flags & ctx.selectDateFormatBit) { startDateFormatSelection(&ctx); diff --git a/Tactility/Source/service/gpstime/GpsTimeSyncService.cpp b/Tactility/Source/service/gpstime/GpsTimeSyncService.cpp index 8852e4677..63c840f56 100644 --- a/Tactility/Source/service/gpstime/GpsTimeSyncService.cpp +++ b/Tactility/Source/service/gpstime/GpsTimeSyncService.cpp @@ -59,9 +59,16 @@ void GpsTimeSyncService::onLocationTime(int64_t timeMs) { } void GpsTimeSyncService::onTimer() { - // GPS only needs to run until the time was announced - if (done && subscribed.exchange(false)) { - location_unsubscribe(&subscription); + if (done) { + // GPS only needs to run until the time was announced + if (subscribed.exchange(false)) { + location_unsubscribe(&subscription); + } + return; + } + // Fixes only come in while something is subscribed to the location, which needs location services to be enabled + if (!subscribed && location_is_enabled() && location_subscribe(&subscription, &eventGroup, "Time sync") == ERROR_NONE) { + subscribed = true; } } @@ -84,18 +91,12 @@ bool GpsTimeSyncService::onStart(ServiceContext& /*serviceContext*/) { } eventSubscribed = true; - // Fixes only come in while something is subscribed to the location, which also needs location services to be enabled - if (!location_is_enabled()) { - LOG_W(TAG, "Location services are disabled, waiting for another app to use them"); - return true; - } task_event_group_construct(&eventGroup); eventGroupConstructed = true; - if (location_subscribe(&subscription, &eventGroup, "Time sync") == ERROR_NONE) { - subscribed = true; - timer = std::make_unique(Timer::Type::Periodic, TIMER_INTERVAL_TICKS, [this] { onTimer(); }); - timer->start(); - } + // Subscribes now, or as soon as location services are enabled + onTimer(); + timer = std::make_unique(Timer::Type::Periodic, TIMER_INTERVAL_TICKS, [this] { onTimer(); }); + timer->start(); return true; } diff --git a/Tactility/Source/settings/AppearanceSettings.cpp b/Tactility/Source/settings/AppearanceSettings.cpp index c2db83963..77cc0bff7 100644 --- a/Tactility/Source/settings/AppearanceSettings.cpp +++ b/Tactility/Source/settings/AppearanceSettings.cpp @@ -147,11 +147,12 @@ bool load(AppearanceSettings& settings) { settings.size = 0; } } - if (const auto value = parsePercent(map, SETTINGS_KEY_SPACING)) { - settings.spacing = value; + // Present keys win over the old density, also when empty or invalid, which means the device default + if (map.contains(SETTINGS_KEY_SPACING)) { + settings.spacing = parsePercent(map, SETTINGS_KEY_SPACING); } - if (const auto value = parsePercent(map, SETTINGS_KEY_SIZE)) { - settings.size = value; + if (map.contains(SETTINGS_KEY_SIZE)) { + settings.size = parsePercent(map, SETTINGS_KEY_SIZE); } if (const auto entry = map.find(SETTINGS_KEY_ANIMATIONS); entry != map.end()) { settings.animationsEnabled = entry->second != "false"; diff --git a/Tactility/Tests/Source/AppearanceSettingsTest.cpp b/Tactility/Tests/Source/AppearanceSettingsTest.cpp index 5ee5789cd..caf5807b3 100644 --- a/Tactility/Tests/Source/AppearanceSettingsTest.cpp +++ b/Tactility/Tests/Source/AppearanceSettingsTest.cpp @@ -75,7 +75,7 @@ TEST_CASE("spacing and size take precedence over the old density setting") { AppearanceSettings loaded; REQUIRE(load(loaded)); CHECK_EQ(loaded.spacing, std::optional(60)); - CHECK_EQ(loaded.size, std::optional(0)); + CHECK_FALSE(loaded.size.has_value()); } TEST_CASE("the old density setting is not applied again after saving the device defaults") { From b7901eb4a4a036deaf4c893b7615b89c07a104ba Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 31 Oct 2026 00:04:52 +0100 Subject: [PATCH 03/13] ui density/size settings for devices --- Devices/cl32-v02-v03/device.properties | 3 ++- Devices/cl32-v04/device.properties | 3 ++- Devices/heltec-wifi-lora-32-v3/device.properties | 3 ++- Devices/lilygo-tdeck-plus/device.properties | 2 ++ Devices/lilygo-tdisplay-s3/device.properties | 3 ++- Devices/lilygo-tdisplay/device.properties | 3 ++- Devices/lilygo-tdongle-s3/device.properties | 3 ++- Devices/lilygo-tembed-cc1101-plus/device.properties | 3 ++- Devices/lilygo-tembed-cc1101/device.properties | 3 ++- Devices/lilygo-tlora-pager/device.properties | 2 ++ Devices/m5stack-cardputer-adv/device.properties | 3 ++- Devices/m5stack-cardputer/device.properties | 3 ++- Devices/m5stack-stickc-plus2/device.properties | 3 ++- Devices/m5stack-sticks3/device.properties | 3 ++- Devices/waveshare-esp32-s3-geek/device.properties | 3 ++- Devices/waveshare-s3-lcd-13/device.properties | 3 ++- Devices/waveshare-s3-touch-amoled-206/device.properties | 1 - Devices/waveshare-s3-touch-lcd-128/device.properties | 3 ++- Devices/waveshare-s3-touch-lcd-147/device.properties | 3 ++- 19 files changed, 36 insertions(+), 17 deletions(-) diff --git a/Devices/cl32-v02-v03/device.properties b/Devices/cl32-v02-v03/device.properties index a50c64a48..5421cae39 100644 --- a/Devices/cl32-v02-v03/device.properties +++ b/Devices/cl32-v02-v03/device.properties @@ -18,6 +18,7 @@ display.dpi=139 lvgl.colorDepth=8 lvgl.theme=Mono -lvgl.uiDensity=compact +lvgl.uiSpacing=40 +lvgl.uiSize=0 cdn.infoMessage=This variant supports hardware revision v0.2 and v0.3. diff --git a/Devices/cl32-v04/device.properties b/Devices/cl32-v04/device.properties index a20930527..662f6b232 100644 --- a/Devices/cl32-v04/device.properties +++ b/Devices/cl32-v04/device.properties @@ -18,6 +18,7 @@ display.dpi=139 lvgl.colorDepth=8 lvgl.theme=Mono -lvgl.uiDensity=compact +lvgl.uiSpacing=40 +lvgl.uiSize=0 cdn.infoMessage=This variant supports hardware revision v0.4 with the reflective LCD or e-paper display. diff --git a/Devices/heltec-wifi-lora-32-v3/device.properties b/Devices/heltec-wifi-lora-32-v3/device.properties index 26ab0b4f7..b7f34c795 100644 --- a/Devices/heltec-wifi-lora-32-v3/device.properties +++ b/Devices/heltec-wifi-lora-32-v3/device.properties @@ -18,4 +18,5 @@ cdn.infoMessage=Due to the small size of the screen, the icons don't render prop lvgl.theme=Mono lvgl.colorDepth=16 -lvgl.uiDensity=compact +lvgl.uiSpacing=40 +lvgl.uiSize=0 diff --git a/Devices/lilygo-tdeck-plus/device.properties b/Devices/lilygo-tdeck-plus/device.properties index af98ca4ef..b4066158d 100644 --- a/Devices/lilygo-tdeck-plus/device.properties +++ b/Devices/lilygo-tdeck-plus/device.properties @@ -19,6 +19,8 @@ display.dpi=143 cdn.infoMessage=To put the device into bootloader mode:
1. Press the trackball and then the reset button at the same time,
2. Let go of the reset button, then the trackball.

When this website reports that flashing is finished, you likely have to press the reset button. lvgl.colorDepth=16 +lvgl.uiSpacing=40 +lvgl.uiSize=60 sdkconfig.CONFIG_CODEC_DUMMY_SUPPORT=y diff --git a/Devices/lilygo-tdisplay-s3/device.properties b/Devices/lilygo-tdisplay-s3/device.properties index d5802ce77..8a84a18e9 100644 --- a/Devices/lilygo-tdisplay-s3/device.properties +++ b/Devices/lilygo-tdisplay-s3/device.properties @@ -16,4 +16,5 @@ display.size=1.9" display.dpi=191 lvgl.colorDepth=16 -lvgl.uiDensity=compact +lvgl.uiSpacing=40 +lvgl.uiSize=0 diff --git a/Devices/lilygo-tdisplay/device.properties b/Devices/lilygo-tdisplay/device.properties index 6fdf08d08..be2515744 100644 --- a/Devices/lilygo-tdisplay/device.properties +++ b/Devices/lilygo-tdisplay/device.properties @@ -12,4 +12,5 @@ display.size=1.14" display.dpi=242 lvgl.colorDepth=16 -lvgl.uiDensity=compact +lvgl.uiSpacing=40 +lvgl.uiSize=0 diff --git a/Devices/lilygo-tdongle-s3/device.properties b/Devices/lilygo-tdongle-s3/device.properties index 1760a28d9..d906daede 100644 --- a/Devices/lilygo-tdongle-s3/device.properties +++ b/Devices/lilygo-tdongle-s3/device.properties @@ -15,5 +15,6 @@ display.size=0.96" display.dpi=186 lvgl.colorDepth=16 -lvgl.uiDensity=compact +lvgl.uiSpacing=40 +lvgl.uiSize=0 lvgl.fontSize=10 diff --git a/Devices/lilygo-tembed-cc1101-plus/device.properties b/Devices/lilygo-tembed-cc1101-plus/device.properties index 9c60d97f2..020f236d5 100644 --- a/Devices/lilygo-tembed-cc1101-plus/device.properties +++ b/Devices/lilygo-tembed-cc1101-plus/device.properties @@ -18,7 +18,8 @@ display.dpi=191 lvgl.colorDepth=16 lvgl.fontSize=16 -lvgl.uiDensity=compact +lvgl.uiSpacing=20 +lvgl.uiSize=0 sdkconfig.CONFIG_CODEC_DUMMY_SUPPORT=y diff --git a/Devices/lilygo-tembed-cc1101/device.properties b/Devices/lilygo-tembed-cc1101/device.properties index 3f92c2703..4c408f741 100644 --- a/Devices/lilygo-tembed-cc1101/device.properties +++ b/Devices/lilygo-tembed-cc1101/device.properties @@ -18,7 +18,8 @@ display.dpi=191 lvgl.colorDepth=16 lvgl.fontSize=16 -lvgl.uiDensity=compact +lvgl.uiSpacing=20 +lvgl.uiSize=0 sdkconfig.CONFIG_CODEC_DUMMY_SUPPORT=y diff --git a/Devices/lilygo-tlora-pager/device.properties b/Devices/lilygo-tlora-pager/device.properties index 0d6bceb6d..469a92174 100644 --- a/Devices/lilygo-tlora-pager/device.properties +++ b/Devices/lilygo-tlora-pager/device.properties @@ -19,3 +19,5 @@ display.dpi=227 lvgl.colorDepth=16 lvgl.dpi=150 lvgl.fontSize=16 +lvgl.uiSpacing=40 +lvgl.uiSize=0 diff --git a/Devices/m5stack-cardputer-adv/device.properties b/Devices/m5stack-cardputer-adv/device.properties index 0e862cde9..836b21c24 100644 --- a/Devices/m5stack-cardputer-adv/device.properties +++ b/Devices/m5stack-cardputer-adv/device.properties @@ -15,7 +15,8 @@ display.size=1.14" display.dpi=139 lvgl.colorDepth=16 -lvgl.uiDensity=compact +lvgl.uiSpacing=20 +lvgl.uiSize=0 ui.systemBarsPortrait=split ui.systemBarsLandscape=split diff --git a/Devices/m5stack-cardputer/device.properties b/Devices/m5stack-cardputer/device.properties index 2abe104af..7bd10cd5a 100644 --- a/Devices/m5stack-cardputer/device.properties +++ b/Devices/m5stack-cardputer/device.properties @@ -15,7 +15,8 @@ display.size=1.14" display.dpi=139 lvgl.colorDepth=16 -lvgl.uiDensity=compact +lvgl.uiSpacing=20 +lvgl.uiSize=0 ui.systemBarsPortrait=split ui.systemBarsLandscape=split diff --git a/Devices/m5stack-stickc-plus2/device.properties b/Devices/m5stack-stickc-plus2/device.properties index a492388d2..50ad14728 100644 --- a/Devices/m5stack-stickc-plus2/device.properties +++ b/Devices/m5stack-stickc-plus2/device.properties @@ -14,7 +14,8 @@ display.size=1.14" display.dpi=242 lvgl.colorDepth=16 -lvgl.uiDensity=compact +lvgl.uiSpacing=20 +lvgl.uiSize=0 ui.systemBarsPortrait=split ui.systemBarsLandscape=split diff --git a/Devices/m5stack-sticks3/device.properties b/Devices/m5stack-sticks3/device.properties index d1009d092..a0abc62f8 100644 --- a/Devices/m5stack-sticks3/device.properties +++ b/Devices/m5stack-sticks3/device.properties @@ -16,7 +16,8 @@ display.size=1.14" display.dpi=242 lvgl.colorDepth=16 -lvgl.uiDensity=compact +lvgl.uiSpacing=20 +lvgl.uiSize=0 ui.systemBarsPortrait=split ui.systemBarsLandscape=split diff --git a/Devices/waveshare-esp32-s3-geek/device.properties b/Devices/waveshare-esp32-s3-geek/device.properties index dc491cb23..9c7d59a31 100644 --- a/Devices/waveshare-esp32-s3-geek/device.properties +++ b/Devices/waveshare-esp32-s3-geek/device.properties @@ -18,7 +18,8 @@ display.size=1.14" display.dpi=143 lvgl.colorDepth=16 -lvgl.uiDensity=compact +lvgl.uiSpacing=20 +lvgl.uiSize=0 ui.systemBarsPortrait=split ui.systemBarsLandscape=split diff --git a/Devices/waveshare-s3-lcd-13/device.properties b/Devices/waveshare-s3-lcd-13/device.properties index d7d7d9a7a..7cc532174 100644 --- a/Devices/waveshare-s3-lcd-13/device.properties +++ b/Devices/waveshare-s3-lcd-13/device.properties @@ -16,7 +16,8 @@ display.size=1.3" display.dpi=261 lvgl.colorDepth=16 -lvgl.uiDensity=compact +lvgl.uiSpacing=20 +lvgl.uiSize=0 ui.systemBarsPortrait=split ui.systemBarsLandscape=split diff --git a/Devices/waveshare-s3-touch-amoled-206/device.properties b/Devices/waveshare-s3-touch-amoled-206/device.properties index 05a9ebab7..d35512a99 100644 --- a/Devices/waveshare-s3-touch-amoled-206/device.properties +++ b/Devices/waveshare-s3-touch-amoled-206/device.properties @@ -18,7 +18,6 @@ display.shape=rectangle display.dpi=326 lvgl.colorDepth=16 -lvgl.uiDensity=default lvgl.fontSize=22 sdkconfig.CONFIG_TASK_WDT_TIMEOUT_S=10 diff --git a/Devices/waveshare-s3-touch-lcd-128/device.properties b/Devices/waveshare-s3-touch-lcd-128/device.properties index d22b3c449..d83895173 100644 --- a/Devices/waveshare-s3-touch-lcd-128/device.properties +++ b/Devices/waveshare-s3-touch-lcd-128/device.properties @@ -17,7 +17,8 @@ display.size=1.28" display.dpi=265 lvgl.colorDepth=16 -lvgl.uiDensity=compact +lvgl.uiSpacing=20 +lvgl.uiSize=0 ui.systemBarsPortrait=split ui.systemBarsLandscape=split diff --git a/Devices/waveshare-s3-touch-lcd-147/device.properties b/Devices/waveshare-s3-touch-lcd-147/device.properties index a57fdff95..a08045906 100644 --- a/Devices/waveshare-s3-touch-lcd-147/device.properties +++ b/Devices/waveshare-s3-touch-lcd-147/device.properties @@ -17,7 +17,8 @@ display.size=1.47" display.dpi=247 lvgl.colorDepth=16 -lvgl.uiDensity=compact +lvgl.uiSpacing=20 +lvgl.uiSize=0 ui.systemBarsPortrait=split ui.systemBarsLandscape=split \ No newline at end of file From 6e6f769612608332241e84ba12920a9cf48a0340 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 31 Oct 2026 00:23:28 +0100 Subject: [PATCH 04/13] Fixes --- Modules/mesh-module/include/mesh/mesh.h | 5 +++-- Modules/mesh-module/source/mesh_service.cpp | 11 ++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Modules/mesh-module/include/mesh/mesh.h b/Modules/mesh-module/include/mesh/mesh.h index 4676be32b..6db079ef5 100644 --- a/Modules/mesh-module/include/mesh/mesh.h +++ b/Modules/mesh-module/include/mesh/mesh.h @@ -338,7 +338,7 @@ error_t mesh_message_get(uint32_t seq, struct MeshMessage* message); * @param[out] messages buffer for the messages * @param[in] max_count capacity of @a messages * @param[out] count the number of messages returned - * @retval ERROR_INVALID_STATE when the service is not running + * @retval ERROR_INVALID_STATE when the service is not running, or the store of the selected backend failed to open */ error_t mesh_conversation_get(const struct MeshAddress* peer, uint8_t channel, uint32_t before_seq, struct MeshMessage* messages, size_t max_count, size_t* count); @@ -348,6 +348,7 @@ error_t mesh_conversation_get(const struct MeshAddress* peer, uint8_t channel, u * @param[out] messages buffer for the messages * @param[in] max_count capacity of @a messages * @param[out] count the number of messages returned + * @retval ERROR_INVALID_STATE when the service is not running, or the store of the selected backend failed to open */ error_t mesh_history_get(uint32_t before_seq, struct MeshMessage* messages, size_t max_count, size_t* count); @@ -357,7 +358,7 @@ error_t mesh_history_get(uint32_t before_seq, struct MeshMessage* messages, size /** * Get the saved contacts of the active backend. A node that sends a direct message is saved automatically. - * @retval ERROR_INVALID_STATE when the service is not running + * @retval ERROR_INVALID_STATE when the service is not running, or the store of the selected backend failed to open */ error_t mesh_get_contacts(struct MeshContact* contacts, size_t max_count, size_t* count); diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index e75264574..80a626e83 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -2047,10 +2047,15 @@ error_t mesh_message_get(uint32_t seq, MeshMessage* message) { return result; } +/** A selected backend whose store failed to open must not look like an empty history. Requires the global lock and a service. */ +static bool isStorageUnavailable() { + return getSelectedBackend() != nullptr && !service->store.isOpen(); +} + error_t mesh_history_get(uint32_t before_seq, MeshMessage* messages, size_t max_count, size_t* count) { lock(); error_t result = ERROR_INVALID_STATE; - if (service != nullptr) { + if (service != nullptr && !isStorageUnavailable()) { *count = service->store.getHistory(before_seq, messages, max_count); result = ERROR_NONE; } @@ -2061,7 +2066,7 @@ error_t mesh_history_get(uint32_t before_seq, MeshMessage* messages, size_t max_ error_t mesh_conversation_get(const MeshAddress* peer, uint8_t channel, uint32_t before_seq, MeshMessage* messages, size_t max_count, size_t* count) { lock(); error_t result = ERROR_INVALID_STATE; - if (service != nullptr) { + if (service != nullptr && !isStorageUnavailable()) { *count = service->store.getConversation(service->storageBackend, peer, channel, before_seq, messages, max_count); result = ERROR_NONE; } @@ -2072,7 +2077,7 @@ error_t mesh_conversation_get(const MeshAddress* peer, uint8_t channel, uint32_t error_t mesh_get_contacts(MeshContact* contacts, size_t max_count, size_t* count) { lock(); error_t result = ERROR_INVALID_STATE; - if (service != nullptr) { + if (service != nullptr && !isStorageUnavailable()) { *count = 0; for (const auto& contact : service->contacts) { if (*count < max_count && strncmp(contact.backend, service->storageBackend, MESH_BACKEND_ID_MAX_LENGTH) == 0) { From 1c741e4eedaa4bc27340bbf59df4d133e74e7e70 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 31 Oct 2026 00:44:09 +0100 Subject: [PATCH 05/13] Fix for contact loading failure --- Modules/mesh-module/source/mesh_service.cpp | 38 +++++++++++++++---- .../mesh-module/tests/source/service_test.cpp | 24 ++++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index 80a626e83..0b782e5da 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -318,6 +319,8 @@ struct MeshHost { tt::std::vector nodes; /** The saved contacts of the backend that owns the open storage */ tt::std::vector contacts; + /** False when the contacts of the storage backend couldn't be read. They must not be saved then, as that would replace the saved ones. */ + bool contactsLoaded = false; char activeBackend[MESH_BACKEND_ID_MAX_LENGTH + 1] = {}; /** The backend whose messages and contacts are open. It's the selected backend, whether or not it is running. */ char storageBackend[MESH_BACKEND_ID_MAX_LENGTH + 1] = {}; @@ -410,14 +413,15 @@ MeshContact* findContact(MeshHost* host, const MeshAddress& address) { return nullptr; } -void loadContacts(MeshHost* host, const char* backend) { +bool loadContacts(MeshHost* host, const char* backend) { char path[FILE_MAX_PATH_STRING_LENGTH]; if (!getBackendPath(backend, CONTACTS_FILE_NAME, path, sizeof(path), false)) { - return; + return false; } FILE* file = fopen(path, "rb"); if (file == nullptr) { - return; + // No contacts were saved yet + return errno == ENOENT; } MeshContact contact; while (host->contacts.size() < MAX_CONTACTS && fread(&contact, sizeof(contact), 1, file) == 1) { @@ -427,13 +431,15 @@ void loadContacts(MeshHost* host, const char* backend) { host->contacts.push_back(contact); } } + const bool failed = ferror(file) != 0; fclose(file); + return !failed; } /** Requires the global lock */ void saveContacts(MeshHost* host) { char path[FILE_MAX_PATH_STRING_LENGTH]; - if (host->storageBackend[0] == '\0' || !getBackendPath(host->storageBackend, CONTACTS_FILE_NAME, path, sizeof(path), true)) { + if (host->storageBackend[0] == '\0' || !host->contactsLoaded || !getBackendPath(host->storageBackend, CONTACTS_FILE_NAME, path, sizeof(path), true)) { return; } FILE* file = fopen(path, "wb"); @@ -455,11 +461,13 @@ void saveContacts(MeshHost* host) { */ bool switchStorage(MeshHost* host, const MeshBackendApi* api) { const char* id = (api != nullptr) ? api->id : ""; - if (strncmp(host->storageBackend, id, MESH_BACKEND_ID_MAX_LENGTH) == 0 && (api == nullptr || host->store.isOpen())) { + // A failed contacts load is retried + if (strncmp(host->storageBackend, id, MESH_BACKEND_ID_MAX_LENGTH) == 0 && (api == nullptr || (host->store.isOpen() && host->contactsLoaded))) { return true; } host->store.close(); host->contacts.clear(); + host->contactsLoaded = false; host->storageBackend[0] = '\0'; if (api == nullptr) { return true; @@ -471,7 +479,11 @@ bool switchStorage(MeshHost* host, const MeshBackendApi* api) { return false; } strncpy(host->storageBackend, id, MESH_BACKEND_ID_MAX_LENGTH); - loadContacts(host, id); + host->contactsLoaded = loadContacts(host, id); + if (!host->contactsLoaded) { + LOG_E(TAG, "Failed to load the contacts of %s", id); + host->contacts.clear(); + } return true; } @@ -483,6 +495,9 @@ error_t addContact(MeshHost* host, const MeshAddress& address, const char* name, if (address.length == 0 || address.length > MESH_ADDRESS_MAX_LENGTH) { return ERROR_INVALID_ARGUMENT; } + if (!host->contactsLoaded) { + return ERROR_INVALID_STATE; + } char resolvedName[MESH_NAME_MAX_LENGTH + 1] = {}; if (name != nullptr) { @@ -2052,6 +2067,11 @@ static bool isStorageUnavailable() { return getSelectedBackend() != nullptr && !service->store.isOpen(); } +/** Contacts are unavailable when the store failed to open or the contacts failed to load. Requires the global lock and a service. */ +static bool isContactsUnavailable() { + return getSelectedBackend() != nullptr && (!service->store.isOpen() || !service->contactsLoaded); +} + error_t mesh_history_get(uint32_t before_seq, MeshMessage* messages, size_t max_count, size_t* count) { lock(); error_t result = ERROR_INVALID_STATE; @@ -2077,7 +2097,7 @@ error_t mesh_conversation_get(const MeshAddress* peer, uint8_t channel, uint32_t error_t mesh_get_contacts(MeshContact* contacts, size_t max_count, size_t* count) { lock(); error_t result = ERROR_INVALID_STATE; - if (service != nullptr && !isStorageUnavailable()) { + if (service != nullptr && !isContactsUnavailable()) { *count = 0; for (const auto& contact : service->contacts) { if (*count < max_count && strncmp(contact.backend, service->storageBackend, MESH_BACKEND_ID_MAX_LENGTH) == 0) { @@ -2121,7 +2141,9 @@ error_t mesh_remove_contact(const MeshAddress* address) { error_t result = ERROR_INVALID_STATE; if (service != nullptr) { auto* contact = findContact(service, *address); - if (contact == nullptr) { + if (!service->contactsLoaded) { + result = ERROR_INVALID_STATE; + } else if (contact == nullptr) { result = ERROR_NOT_FOUND; } else { service->contacts.erase(service->contacts.begin() + (contact - service->contacts.data())); diff --git a/Modules/mesh-module/tests/source/service_test.cpp b/Modules/mesh-module/tests/source/service_test.cpp index 9a9b70a27..4ab07bbc4 100644 --- a/Modules/mesh-module/tests/source/service_test.cpp +++ b/Modules/mesh-module/tests/source/service_test.cpp @@ -353,6 +353,30 @@ TEST_CASE("mesh service sends and receives through the active backend") { REQUIRE_EQ(mesh_backend_unregister(&spareApi), ERROR_NONE); } + SUBCASE("contacts that failed to load are not available and are never replaced") { + // A directory where the contacts file belongs can't be read + char contactsPath[FILE_MAX_PATH_STRING_LENGTH]; + REQUIRE_EQ(service_paths_get_user_data_path(MESH_SERVICE_ID, "backends/loopback/contacts.bin", contactsPath, sizeof(contactsPath)), ERROR_NONE); + REQUIRE_EQ(directory_make(contactsPath, true), ERROR_NONE); + + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + REQUIRE_EQ(mesh_set_backend("loopback"), ERROR_NONE); + REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + + MeshContact contacts[4]; + size_t count = 0; + CHECK_EQ(mesh_get_contacts(contacts, 4, &count), ERROR_INVALID_STATE); + const MeshAddress alice = { .length = 1, .bytes = { 0x42 } }; + CHECK_EQ(mesh_add_contact(&alice, "alice"), ERROR_INVALID_STATE); + CHECK_EQ(mesh_remove_contact(&alice), ERROR_INVALID_STATE); + // Messages are not affected + MeshMessage messages[4]; + CHECK_EQ(mesh_history_get(0, messages, 4, &count), ERROR_NONE); + + REQUIRE_EQ(directory_remove_tree(contactsPath), ERROR_NONE); + } + SUBCASE("without a region the radio stays off") { REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); CHECK(waitFor([] { return mesh_get_state() == MESH_STATE_NO_REGION; })); From 44531496cc1e7b4bd8fb8ec60e55c7c0a0aced78 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 31 Oct 2026 17:44:32 +0100 Subject: [PATCH 06/13] Update ideas.md --- Documentation/ideas.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Documentation/ideas.md b/Documentation/ideas.md index 9871f7505..afc551fb9 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -7,6 +7,7 @@ ## Higher Priority +- LVGL spinner not working on cl32 v04 (possibly anything monochrome) - Export platform-esp32 with TactilitySDK - Find out why the first build of an app with a clean build cache folder always fails - Improve posix support: use user home path and system /tmp folder. From 78bdd6eb6d7fe29609060125ed6c46e7f18fd088 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 31 Oct 2026 17:45:30 +0100 Subject: [PATCH 07/13] Fix for logo selection --- .../Private/Tactility/app/boot/BootScreen.h | 3 ++ Tactility/Source/app/boot/BootInit.cpp | 49 ++++++++++++++----- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/Tactility/Private/Tactility/app/boot/BootScreen.h b/Tactility/Private/Tactility/app/boot/BootScreen.h index 7d20ba9fa..aa2352e2b 100644 --- a/Tactility/Private/Tactility/app/boot/BootScreen.h +++ b/Tactility/Private/Tactility/app/boot/BootScreen.h @@ -60,6 +60,9 @@ class BootScreen { /** @return the display width in the current orientation, or 0 when there is no display */ int getLogicalWidth() const { return logicalWidth(); } + /** @return the display height in the current orientation, or 0 when there is no display */ + int getLogicalHeight() const { return logicalHeight(); } + /** * Shows a logo with text lines below it, centered on a black background. * @param[in] logo a PNG image, or empty for no logo diff --git a/Tactility/Source/app/boot/BootInit.cpp b/Tactility/Source/app/boot/BootInit.cpp index 17c5dd054..205b8d035 100644 --- a/Tactility/Source/app/boot/BootInit.cpp +++ b/Tactility/Source/app/boot/BootInit.cpp @@ -46,31 +46,56 @@ constexpr auto* DEFAULT_LAUNCHER_ID = "tactility.launcher"; namespace { -BootLogo getSizedLogo(const BootScreen& screen, const BootLogo& small, const BootLogo& medium, const BootLogo& large) { - const int width = screen.getLogicalWidth(); - if (width < 150) { // e.g. Cardputer - return small; - } else if (width > 320) { - return large; - } else { - return medium; +/** Reads the size from the IHDR chunk of a PNG image */ +bool getPngSize(const BootLogo& logo, uint32_t& width, uint32_t& height) { + if (logo.size < 24) { + return false; } + const auto read_u32 = [&logo](size_t offset) { + return (static_cast(logo.data[offset]) << 24) | (static_cast(logo.data[offset + 1]) << 16) | + (static_cast(logo.data[offset + 2]) << 8) | static_cast(logo.data[offset + 3]); + }; + width = read_u32(16); + height = read_u32(20); + return true; +} + +/** + * Picks the largest logo that takes at most 60% of the width and of the height of the screen, + * and at most an eighth of its surface. Without such a logo, the smallest one is used. + * @param[in] logos sorted from small to large + */ +BootLogo getSizedLogo(const BootScreen& screen, std::initializer_list logos) { + const uint64_t screen_width = screen.getLogicalWidth(); + const uint64_t screen_height = screen.getLogicalHeight(); + BootLogo selected = *logos.begin(); + for (const auto& logo : logos) { + uint32_t width = 0; + uint32_t height = 0; + if (getPngSize(logo, width, height) && + width * 10 <= screen_width * 6 && + height * 10 <= screen_height * 6 && + static_cast(width) * height * 8 <= screen_width * screen_height) { + selected = logo; + } + } + return selected; } BootLogo getLogo(const BootScreen& screen) { - return getSizedLogo(screen, + return getSizedLogo(screen, { { asset_boot_logo_small_png, asset_boot_logo_small_png_SIZE }, { asset_boot_logo_medium_png, asset_boot_logo_medium_png_SIZE }, { asset_boot_logo_large_png, asset_boot_logo_large_png_SIZE } - ); + }); } BootLogo getUsbLogo(const BootScreen& screen) { - return getSizedLogo(screen, + return getSizedLogo(screen, { { asset_boot_logo_usb_small_png, asset_boot_logo_usb_small_png_SIZE }, { asset_boot_logo_usb_medium_png, asset_boot_logo_usb_medium_png_SIZE }, { asset_boot_logo_usb_large_png, asset_boot_logo_usb_large_png_SIZE } - ); + }); } void setupDisplay() { From f1713fa564d52bcc669674652587c98382eda997 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 31 Oct 2026 20:27:21 +0100 Subject: [PATCH 08/13] Fix for tile grid shifting upwards after 1 rendered frame on launcher start --- Tactility/Private/Tactility/app/TileGrid.h | 15 ++++++++++++--- Tactility/Source/app/TileGrid.cpp | 8 ++++++-- Tactility/Source/app/launcher/Launcher.cpp | 7 ++++--- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/Tactility/Private/Tactility/app/TileGrid.h b/Tactility/Private/Tactility/app/TileGrid.h index c009e4365..803a7ca59 100644 --- a/Tactility/Private/Tactility/app/TileGrid.h +++ b/Tactility/Private/Tactility/app/TileGrid.h @@ -69,12 +69,21 @@ class TileGrid final { void createWidgets(lv_obj_t* parent); /** - * Creates the grid and populates it, with a bar below it: buttons on the left (see addBarButton()), + * Creates the grid, with a bar below it: buttons on the left (see addBarButton()), * followed by the page buttons, and a page indicator on the right. The page indicator and buttons are * hidden when there's only one page, and the page buttons wrap around. * @param[in] parent the container for the grid and the bar + * @param[in] sideContainer the container for the bar's buttons when it's shown beside the grid (see setBarSideContainer()), + * so the grid is populated at its final size, or nullptr to show the bar below the grid + * @warning The grid is populated by finishWidgetsWithBottomBar(), which must be called after adding the bar buttons. */ - void createWidgetsWithBottomBar(lv_obj_t* parent); + void createWidgetsWithBottomBar(lv_obj_t* parent, lv_obj_t* sideContainer = nullptr); + + /** + * Populates the grid. Call after createWidgetsWithBottomBar() and addBarButton(), as the buttons change the size of the bar, + * and so the size of the grid. + */ + void finishWidgetsWithBottomBar(); /** * Makes the buttons go to the previous and next page. They are hidden when there's only one page, and wrap around. @@ -83,7 +92,7 @@ class TileGrid final { void setPageButtons(lv_obj_t* previous, lv_obj_t* next); /** - * Adds an icon button to the bottom bar, before the page buttons. Call right after createWidgetsWithBottomBar(). + * Adds an icon button to the bottom bar, before the page buttons. Call after createWidgetsWithBottomBar() and before finishWidgetsWithBottomBar(). * @param[in] icon a shared icon (see lvgl/icons/shared.h) * @return the button */ diff --git a/Tactility/Source/app/TileGrid.cpp b/Tactility/Source/app/TileGrid.cpp index 135f13a88..90506e49c 100644 --- a/Tactility/Source/app/TileGrid.cpp +++ b/Tactility/Source/app/TileGrid.cpp @@ -452,7 +452,7 @@ void TileGrid::onBottomBarFocused(lv_event_t* e) { } } -void TileGrid::createWidgetsWithBottomBar(lv_obj_t* parent) { +void TileGrid::createWidgetsWithBottomBar(lv_obj_t* parent, lv_obj_t* sideContainer) { barParent = parent; barSideContainer = nullptr; barButtons.clear(); @@ -488,8 +488,12 @@ void TileGrid::createWidgetsWithBottomBar(lv_obj_t* parent) { // The arrow keys move between all buttons of the bar as one row lvgl_grid_navigation_add(bottomBar); lv_obj_add_event_cb(bottomBar, onBottomBarFocused, LV_EVENT_FOCUSED, this); + // Before the grid is populated, as its size depends on where the bar is + setBarSideContainer(sideContainer); +} - finishCreate(parent); +void TileGrid::finishWidgetsWithBottomBar() { + finishCreate(barParent); } void TileGrid::createWidgets(lv_obj_t* parent) { diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index cf4f4bd4d..16be21f4c 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -132,12 +132,13 @@ void createWidgets(lv_obj_t* parent, void* userData) { applyIconColor(ctx); ctx->grid.setSwipeNavigation(true); - ctx->grid.createWidgetsWithBottomBar(parent); + // In the side layout, the bar's buttons are in the system bars strip + ctx->grid.createWidgetsWithBottomBar(parent, lvgl::systemBarsGetActionContainer()); lv_obj_t* modeButton = ctx->grid.addBarButton(getLauncherMode(getOtherMode(ctx->mode)).buttonIcon, onModeButtonPressed, ctx); ctx->modeButtonIcon = lv_obj_get_child(modeButton, 0); + // After all bar buttons, as they change the size of the grid + ctx->grid.finishWidgetsWithBottomBar(); - // In the side layout, the bar's buttons are in the system bars strip - ctx->grid.setBarSideContainer(lvgl::systemBarsGetActionContainer()); ctx->systemBarsListener = lvgl::systemBarsAddListener([ctx] { ctx->grid.setBarSideContainer(lvgl::systemBarsGetActionContainer()); }); From dc2e57d1015358f2d82b85df91bd7eb85d41bd0c Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 31 Oct 2026 20:27:25 +0100 Subject: [PATCH 09/13] Update ideas --- Documentation/ideas.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Documentation/ideas.md b/Documentation/ideas.md index afc551fb9..cc1e4cada 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -7,6 +7,7 @@ ## Higher Priority +- Make tt.py work without an app project. Ping the device for info. - LVGL spinner not working on cl32 v04 (possibly anything monochrome) - Export platform-esp32 with TactilitySDK - Find out why the first build of an app with a clean build cache folder always fails From 8bc6799edac4fea3f4d0beb79bbf200e0cc7c39f Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 31 Oct 2026 20:32:16 +0100 Subject: [PATCH 10/13] ui spacing and size updated for many devices --- Devices/cyd-2432s022c/device.properties | 2 ++ Devices/cyd-2432s024c/device.properties | 2 ++ Devices/cyd-2432s024r/device.properties | 2 ++ Devices/cyd-2432s028r/device.properties | 2 ++ Devices/cyd-2432s028rv3/device.properties | 2 ++ Devices/cyd-2432s032c/device.properties | 2 ++ Devices/cyd-e32r28t/device.properties | 2 ++ Devices/cyd-e32r32p/device.properties | 2 ++ Devices/elecrow-crowpanel-advance-28/device.properties | 2 ++ Devices/elecrow-crowpanel-advance-35/device.properties | 2 ++ Devices/elecrow-crowpanel-basic-28/device.properties | 2 ++ Devices/elecrow-crowpanel-basic-35/device.properties | 2 ++ Devices/es3c28p/device.properties | 2 ++ Devices/lilygo-tdeck/device.properties | 2 ++ Devices/lilygo-thmi/device.properties | 2 ++ 15 files changed, 30 insertions(+) diff --git a/Devices/cyd-2432s022c/device.properties b/Devices/cyd-2432s022c/device.properties index eab2252b7..0cc51fb15 100644 --- a/Devices/cyd-2432s022c/device.properties +++ b/Devices/cyd-2432s022c/device.properties @@ -11,3 +11,5 @@ display.size=2.2" display.dpi=182 lvgl.colorDepth=16 +lvgl.uiSpacing=40 +lvgl.uiSize=60 diff --git a/Devices/cyd-2432s024c/device.properties b/Devices/cyd-2432s024c/device.properties index e46cfeb35..3207937a6 100644 --- a/Devices/cyd-2432s024c/device.properties +++ b/Devices/cyd-2432s024c/device.properties @@ -11,3 +11,5 @@ display.size=2.4" display.dpi=167 lvgl.colorDepth=16 +lvgl.uiSpacing=40 +lvgl.uiSize=60 diff --git a/Devices/cyd-2432s024r/device.properties b/Devices/cyd-2432s024r/device.properties index a4354475c..5b9aff4ad 100644 --- a/Devices/cyd-2432s024r/device.properties +++ b/Devices/cyd-2432s024r/device.properties @@ -11,3 +11,5 @@ display.size=2.4" display.dpi=167 lvgl.colorDepth=16 +lvgl.uiSpacing=40 +lvgl.uiSize=60 diff --git a/Devices/cyd-2432s028r/device.properties b/Devices/cyd-2432s028r/device.properties index b93b50f81..bf0a644be 100644 --- a/Devices/cyd-2432s028r/device.properties +++ b/Devices/cyd-2432s028r/device.properties @@ -17,3 +17,5 @@ cdn.warningMessage=There are 3 hardware variants of this board. This build works lvgl.colorDepth=16 lvgl.fsCacheSize=512 +lvgl.uiSpacing=40 +lvgl.uiSize=60 diff --git a/Devices/cyd-2432s028rv3/device.properties b/Devices/cyd-2432s028rv3/device.properties index ae63a8915..2611d11d4 100644 --- a/Devices/cyd-2432s028rv3/device.properties +++ b/Devices/cyd-2432s028rv3/device.properties @@ -16,3 +16,5 @@ touch.calibrationRequired=false cdn.warningMessage=There are 3 hardware variants of this board. This build only supports board version 3. lvgl.colorDepth=16 +lvgl.uiSpacing=40 +lvgl.uiSize=60 diff --git a/Devices/cyd-2432s032c/device.properties b/Devices/cyd-2432s032c/device.properties index 0f1c43049..5753894c7 100644 --- a/Devices/cyd-2432s032c/device.properties +++ b/Devices/cyd-2432s032c/device.properties @@ -11,3 +11,5 @@ display.size=3.2" display.dpi=125 lvgl.colorDepth=16 +lvgl.uiSpacing=40 +lvgl.uiSize=60 diff --git a/Devices/cyd-e32r28t/device.properties b/Devices/cyd-e32r28t/device.properties index 53db09d96..294d55967 100644 --- a/Devices/cyd-e32r28t/device.properties +++ b/Devices/cyd-e32r28t/device.properties @@ -14,3 +14,5 @@ touch.calibrationSupported=true touch.calibrationRequired=false lvgl.colorDepth=16 +lvgl.uiSpacing=40 +lvgl.uiSize=60 diff --git a/Devices/cyd-e32r32p/device.properties b/Devices/cyd-e32r32p/device.properties index 88b6fc805..a7162bb0f 100644 --- a/Devices/cyd-e32r32p/device.properties +++ b/Devices/cyd-e32r32p/device.properties @@ -14,3 +14,5 @@ touch.calibrationSupported=true touch.calibrationRequired=false lvgl.colorDepth=16 +lvgl.uiSpacing=40 +lvgl.uiSize=60 diff --git a/Devices/elecrow-crowpanel-advance-28/device.properties b/Devices/elecrow-crowpanel-advance-28/device.properties index 8711bc1dd..e1513c35b 100644 --- a/Devices/elecrow-crowpanel-advance-28/device.properties +++ b/Devices/elecrow-crowpanel-advance-28/device.properties @@ -16,3 +16,5 @@ display.size=2.8" display.dpi=143 lvgl.colorDepth=16 +lvgl.uiSpacing=40 +lvgl.uiSize=60 diff --git a/Devices/elecrow-crowpanel-advance-35/device.properties b/Devices/elecrow-crowpanel-advance-35/device.properties index e08b73b37..450457bcd 100644 --- a/Devices/elecrow-crowpanel-advance-35/device.properties +++ b/Devices/elecrow-crowpanel-advance-35/device.properties @@ -18,3 +18,5 @@ display.dpi=165 cdn.warningMessage=This device has display driver and memory issues. App Hub doesn't work. lvgl.colorDepth=16 +lvgl.uiSpacing=40 +lvgl.uiSize=80 diff --git a/Devices/elecrow-crowpanel-basic-28/device.properties b/Devices/elecrow-crowpanel-basic-28/device.properties index 73db8df69..e9c2ca47c 100644 --- a/Devices/elecrow-crowpanel-basic-28/device.properties +++ b/Devices/elecrow-crowpanel-basic-28/device.properties @@ -14,3 +14,5 @@ touch.calibrationSupported=true touch.calibrationRequired=false lvgl.colorDepth=16 +lvgl.uiSpacing=40 +lvgl.uiSize=60 diff --git a/Devices/elecrow-crowpanel-basic-35/device.properties b/Devices/elecrow-crowpanel-basic-35/device.properties index 721ab97d7..76c49ade9 100644 --- a/Devices/elecrow-crowpanel-basic-35/device.properties +++ b/Devices/elecrow-crowpanel-basic-35/device.properties @@ -16,3 +16,5 @@ touch.calibrationRequired=false cdn.warningMessage=This device has display driver and memory issues. App Hub doesn't work. lvgl.colorDepth=16 +lvgl.uiSpacing=40 +lvgl.uiSize=80 diff --git a/Devices/es3c28p/device.properties b/Devices/es3c28p/device.properties index 83462a158..1889d4005 100644 --- a/Devices/es3c28p/device.properties +++ b/Devices/es3c28p/device.properties @@ -16,3 +16,5 @@ display.dpi=143 lvgl.colorDepth=16 storage.userDataLocation=SD +lvgl.uiSpacing=40 +lvgl.uiSize=60 diff --git a/Devices/lilygo-tdeck/device.properties b/Devices/lilygo-tdeck/device.properties index 44053d410..97eba340c 100644 --- a/Devices/lilygo-tdeck/device.properties +++ b/Devices/lilygo-tdeck/device.properties @@ -19,5 +19,7 @@ display.dpi=143 cdn.infoMessage=To put the device into bootloader mode:
1. Press the trackball and then the reset button at the same time,
2. Let go of the reset button, then the trackball.

When this website reports that flashing is finished, you likely have to press the reset button. lvgl.colorDepth=16 +lvgl.uiSpacing=40 +lvgl.uiSize=60 sdkconfig.CONFIG_CODEC_DUMMY_SUPPORT=y \ No newline at end of file diff --git a/Devices/lilygo-thmi/device.properties b/Devices/lilygo-thmi/device.properties index 214835544..101e05713 100644 --- a/Devices/lilygo-thmi/device.properties +++ b/Devices/lilygo-thmi/device.properties @@ -19,3 +19,5 @@ touch.calibrationSupported=true touch.calibrationRequired=false lvgl.colorDepth=16 +lvgl.uiSpacing=40 +lvgl.uiSize=60 From 33dbfa57b0bfeefbbb9bc5b2c624a6fb1b7cfc9f Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 31 Oct 2026 20:47:46 +0100 Subject: [PATCH 11/13] fs.cpp recursion fix - fix du stackoverflow --- TactilityKernel/source/filesystem/fs.cpp | 88 ++++++++----------- .../tests/source/path_tree_size_test.cpp | 46 ++++++++++ 2 files changed, 85 insertions(+), 49 deletions(-) create mode 100644 TactilityKernel/tests/source/path_tree_size_test.cpp diff --git a/TactilityKernel/source/filesystem/fs.cpp b/TactilityKernel/source/filesystem/fs.cpp index 6abbc9201..f1a344331 100644 --- a/TactilityKernel/source/filesystem/fs.cpp +++ b/TactilityKernel/source/filesystem/fs.cpp @@ -12,15 +12,20 @@ #include #include #include +#include +#include constexpr auto* TAG = "fs"; -// Bound on how deep directory_remove_tree()/path_tree_size() will recurse: a fixed-size buffer is +// Bound on how deep directory_remove_tree() will recurse: a fixed-size buffer is // spent per level, so a pathological tree is refused rather than overflowing the stack. constexpr int MAX_RECURSION_DEPTH = 16; +// Bound on how deep path_tree_size() walks, which only stops a loop of links as it doesn't use the stack. +constexpr int MAX_WALK_DEPTH = 64; + // Entries collected per directory pass, and the longest filename handled, for -// directory_remove_tree()/path_tree_size(). dirent allows 255-byte names; storing them at full +// directory_remove_tree(). dirent allows 255-byte names; storing them at full // width would cost 16KB per recursion level, so the cap is lower and over-long names are skipped // rather than silently mishandled. constexpr int MAX_ENTRIES_PER_PASS = 64; @@ -141,60 +146,45 @@ error_t directory_remove_tree_at(const char* path, int depth) { } } -uint64_t path_tree_size_at(const char* path, int depth) { - if (depth > MAX_RECURSION_DEPTH) { - return 0; - } +/** + * Sums the sizes of the files below a directory without recursing, so the stack use doesn't depend on how deep the tree is. + * Only one directory is open at a time, and the directories still to visit are kept on the heap. + */ +uint64_t path_tree_size_at(const char* path) { if (!directory_exists(path)) { struct stat info; return (stat(path, &info) == 0) ? static_cast(info.st_size) : 0; } - struct Accumulator { - uint64_t bytes; + struct Pending { + std::string path; + int depth; }; - Accumulator accumulator { 0 }; - - directory_list(path, &accumulator, [](const DirectoryEntry* entry, void* context) { - // Only files are summed here; directories are recursed into by the caller below. - if (!entry->is_directory) { - static_cast(context)->bytes += entry->size; - } - }); - - // Collect subdirectory names first, then recurse. - // Same name-length cap as directory_remove_tree_at: a truncated name would size a different path. - struct Children { - char names[32][MAX_ENTRY_NAME]; - int count; + struct Walk { + std::vector pending; + const Pending* current; + uint64_t bytes; }; - auto* children = static_cast(calloc(1, sizeof(Children))); - if (children == nullptr) { - return accumulator.bytes; - } - - directory_list(path, children, [](const DirectoryEntry* entry, void* context) { - auto* list = static_cast(context); - if (!entry->is_directory || list->count >= static_cast(sizeof(list->names) / sizeof(list->names[0]))) { - return; - } - const size_t name_length = strlen(entry->name); - if (name_length >= MAX_ENTRY_NAME) { - return; - } - memcpy(list->names[list->count], entry->name, name_length + 1); - list->count++; - }); - - uint64_t total = accumulator.bytes; - for (int i = 0; i < children->count; i++) { - char child_path[FILE_MAX_PATH_STRING_LENGTH]; - snprintf(child_path, sizeof(child_path), "%s/%s", path, children->names[i]); - total += path_tree_size_at(child_path, depth + 1); + Walk walk { {}, nullptr, 0 }; + walk.pending.push_back({ path, 0 }); + + while (!walk.pending.empty()) { + const Pending directory = std::move(walk.pending.back()); + walk.pending.pop_back(); + walk.current = &directory; + directory_list(directory.path.c_str(), &walk, [](const DirectoryEntry* entry, void* context) { + auto* walk = static_cast(context); + if (!entry->is_directory) { + walk->bytes += entry->size; + return; + } + // The depth is bounded, so a loop of links ends. Paths that can't be handled by the file system's limits are skipped. + if (walk->current->depth < MAX_WALK_DEPTH && walk->current->path.size() + 1 + strlen(entry->name) < FILE_MAX_PATH_STRING_LENGTH) { + walk->pending.push_back({ walk->current->path + "/" + entry->name, walk->current->depth + 1 }); + } + }); } - - free(children); - return total; + return walk.bytes; } } // namespace @@ -426,7 +416,7 @@ error_t file_touch(const char* path) { } uint64_t path_tree_size(const char* path) { - return path_tree_size_at(path, 0); + return path_tree_size_at(path); } } // extern "C" diff --git a/TactilityKernel/tests/source/path_tree_size_test.cpp b/TactilityKernel/tests/source/path_tree_size_test.cpp new file mode 100644 index 000000000..82515d169 --- /dev/null +++ b/TactilityKernel/tests/source/path_tree_size_test.cpp @@ -0,0 +1,46 @@ +#include "doctest.h" + +#include + +#include +#include +#include +#include + +namespace { + +void write_file(const std::string& path, size_t size) { + FILE* file = fopen(path.c_str(), "wb"); + REQUIRE(file != nullptr); + const std::string content(size, 'x'); + fwrite(content.data(), 1, content.size(), file); + fclose(file); +} + +} + +TEST_CASE("path_tree_size sums files in a deep tree and in a wide directory") { + char pattern[] = "/tmp/tt_path_tree_size_XXXXXX"; + REQUIRE(mkdtemp(pattern) != nullptr); + const std::string root = pattern; + + // Deeper than a recursive walk with a fixed buffer per level could handle, and more subdirectories than a single pass held + std::string deep = root; + for (int i = 0; i < 40; i++) { + deep += "/d"; + REQUIRE(directory_make(deep.c_str(), false) == ERROR_NONE); + write_file(deep + "/f", 10); + } + for (int i = 0; i < 50; i++) { + const std::string wide = root + "/w" + std::to_string(i); + REQUIRE(directory_make(wide.c_str(), false) == ERROR_NONE); + write_file(wide + "/f", 100); + } + write_file(root + "/top", 1); + + CHECK_EQ(path_tree_size(root.c_str()), 40 * 10 + 50 * 100 + 1); + CHECK_EQ(path_tree_size((root + "/top").c_str()), 1); + CHECK_EQ(path_tree_size((root + "/missing").c_str()), 0); + + std::filesystem::remove_all(root); +} From fcc0022ccb7bc1b72caccf586d1805ce5ce92d67 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 31 Oct 2026 21:09:26 +0100 Subject: [PATCH 12/13] Update on mesh switching --- Modules/mesh-module/source/mesh_service.cpp | 5 +++++ Modules/mesh-module/tests/source/service_test.cpp | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index 0b782e5da..ddf1065e4 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -819,7 +819,12 @@ void configure(MeshHost* host) { lock(); strncpy(host->activeBackend, api->id, MESH_BACKEND_ID_MAX_LENGTH); host->activeBackend[MESH_BACKEND_ID_MAX_LENGTH] = '\0'; + // Switching between backends keeps the state on, but consumers show the active backend + const bool wasOn = host->state == MESH_STATE_ON; setState(host, MESH_STATE_ON); + if (wasOn) { + emitEvent(MESH_EVENT_STATE, MESH_STATE_ON, 0, MESH_DELIVERY_RECEIVED); + } unlock(); LOG_I(TAG, "Backend %s active in region %s", api->id, radio_region_to_string(host->appliedRegion)); diff --git a/Modules/mesh-module/tests/source/service_test.cpp b/Modules/mesh-module/tests/source/service_test.cpp index 4ab07bbc4..3f4f73ff7 100644 --- a/Modules/mesh-module/tests/source/service_test.cpp +++ b/Modules/mesh-module/tests/source/service_test.cpp @@ -474,12 +474,16 @@ TEST_CASE("mesh service sends and receives through the active backend") { // Selecting a backend tells consumers that the contacts and channels changed bool contactsChanged = false; bool channelsChanged = false; + bool stateReported = false; while (mesh_event_poll(&storageEvents, &storageEvent) == ERROR_NONE) { + stateReported |= storageEvent.type == MESH_EVENT_STATE; contactsChanged |= storageEvent.type == MESH_EVENT_CONTACT; channelsChanged |= storageEvent.type == MESH_EVENT_CHANNEL; } CHECK(contactsChanged); CHECK(channelsChanged); + // The active backend changed while the state stayed on + CHECK(stateReported); CHECK_EQ(mesh_event_unsubscribe(&storageEvents), ERROR_NONE); task_event_group_destruct(&storageGroup); CHECK_EQ(countHistory(), 0); From 0ead596eea30f018975730686b3dbe5937dce7a4 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 31 Oct 2026 21:20:27 +0100 Subject: [PATCH 13/13] Mesh Message apps hides features that current mesh backend doesn't support --- .../source/meshcore_backend.cpp | 1 + .../source/meshtastic_backend.cpp | 1 + Modules/mesh-module/include/mesh/backend.h | 3 ++ Modules/mesh-module/include/mesh/mesh.h | 15 +++++++ Modules/mesh-module/source/mesh_service.cpp | 9 +++++ Modules/mesh-module/source/module.cpp | 1 + .../mesh-module/tests/source/service_test.cpp | 22 ++++++++++ .../Source/app/meshchat/MeshChannels.cpp | 40 +++++++++++++++---- 8 files changed, 84 insertions(+), 8 deletions(-) diff --git a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp index 9b1d43f1f..6c0215801 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp @@ -784,4 +784,5 @@ const MeshBackendApi meshcore_backend_api = { .is_valid_contact_address = isValidContactAddress, .on_node_name_changed = onNodeNameChanged, .remove_channel = removeChannel, + .features = MESH_FEATURE_NAMED_CHANNELS | MESH_FEATURE_KEYED_CHANNELS, }; diff --git a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp index 8fc088323..decb90341 100644 --- a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp +++ b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp @@ -615,4 +615,5 @@ const MeshBackendApi meshtastic_backend_api = { .is_valid_contact_address = isValidContactAddress, .on_node_name_changed = onNodeNameChanged, .remove_channel = removeChannel, + .features = MESH_FEATURE_KEYED_CHANNELS, }; diff --git a/Modules/mesh-module/include/mesh/backend.h b/Modules/mesh-module/include/mesh/backend.h index f2cbe7dc1..7e83fc931 100644 --- a/Modules/mesh-module/include/mesh/backend.h +++ b/Modules/mesh-module/include/mesh/backend.h @@ -111,6 +111,9 @@ struct MeshBackendApi { * @retval ERROR_INVALID_ARGUMENT when the backend can't do without the channel */ error_t (*remove_channel)(void* backend, uint8_t index); + + /** The MeshFeature values that the backend supports, combined, see mesh_supports() */ + uint32_t features; }; /** diff --git a/Modules/mesh-module/include/mesh/mesh.h b/Modules/mesh-module/include/mesh/mesh.h index 6db079ef5..a39ee060e 100644 --- a/Modules/mesh-module/include/mesh/mesh.h +++ b/Modules/mesh-module/include/mesh/mesh.h @@ -390,6 +390,21 @@ error_t mesh_get_contact_uri(const struct MeshAddress* contact, char* uri, size_ // region Nodes and channels +/** Optional things a backend can do, that a mesh network can support. Combine them in MeshBackendApi::features and mesh_supports(). */ +enum MeshFeature { + /** Join a channel by its name, as the key is derived from the name (e.g. a "#test" channel) */ + MESH_FEATURE_NAMED_CHANNELS = 1 << 0, + /** Join or create a channel with a key */ + MESH_FEATURE_KEYED_CHANNELS = 1 << 1, +}; + +/** + * Whether the selected backend supports features, also when it isn't running. + * @param[in] features MeshFeature values, combined + * @return true when the backend supports all of them + */ +bool mesh_supports(uint32_t features); + /** Get the nodes heard by the active backend since it started */ error_t mesh_get_nodes(struct MeshNode* nodes, size_t max_count, size_t* count); diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index ddf1065e4..e542103ea 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -1726,6 +1726,15 @@ error_t mesh_get_backend(char* id, size_t id_size) { return result; } +bool mesh_supports(uint32_t features) { + lock(); + ensureSettingsLoaded(); + const MeshBackendApi* api = getSelectedBackend(); + const bool supported = api != nullptr && (api->features & features) == features; + unlock(); + return supported; +} + size_t mesh_get_preset_count(const char* backend) { const bool applies = presetsApply(); lock(); diff --git a/Modules/mesh-module/source/module.cpp b/Modules/mesh-module/source/module.cpp index 56ec3dd75..682cb45ad 100644 --- a/Modules/mesh-module/source/module.cpp +++ b/Modules/mesh-module/source/module.cpp @@ -123,6 +123,7 @@ static const ModuleSymbol mesh_module_symbols[] = { DEFINE_MODULE_SYMBOL(mesh_set_backend), DEFINE_MODULE_SYMBOL(mesh_get_backend), DEFINE_MODULE_SYMBOL(mesh_get_preset_count), + DEFINE_MODULE_SYMBOL(mesh_supports), DEFINE_MODULE_SYMBOL(mesh_get_preset_name), DEFINE_MODULE_SYMBOL(mesh_set_preset), DEFINE_MODULE_SYMBOL(mesh_get_preset), diff --git a/Modules/mesh-module/tests/source/service_test.cpp b/Modules/mesh-module/tests/source/service_test.cpp index 3f4f73ff7..9ea26a295 100644 --- a/Modules/mesh-module/tests/source/service_test.cpp +++ b/Modules/mesh-module/tests/source/service_test.cpp @@ -162,6 +162,7 @@ const MeshBackendApi loopbackApi = { .get_contact_uri = loopbackGetContactUri, .is_valid_contact_address = loopbackIsValidContactAddress, .on_node_name_changed = loopbackOnNodeNameChanged, + .features = MESH_FEATURE_KEYED_CHANNELS, }; // endregion @@ -377,6 +378,27 @@ TEST_CASE("mesh service sends and receives through the active backend") { REQUIRE_EQ(directory_remove_tree(contactsPath), ERROR_NONE); } + SUBCASE("features are those of the selected backend") { + MeshBackendApi spareApi = loopbackApi; + spareApi.id = "spare"; + spareApi.features = MESH_FEATURE_NAMED_CHANNELS; + REQUIRE_EQ(mesh_backend_register(&spareApi), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + + REQUIRE_EQ(mesh_set_backend("loopback"), ERROR_NONE); + CHECK(mesh_supports(MESH_FEATURE_KEYED_CHANNELS)); + CHECK_FALSE(mesh_supports(MESH_FEATURE_NAMED_CHANNELS)); + CHECK_FALSE(mesh_supports(MESH_FEATURE_KEYED_CHANNELS | MESH_FEATURE_NAMED_CHANNELS)); + + REQUIRE_EQ(mesh_set_backend("spare"), ERROR_NONE); + CHECK(mesh_supports(MESH_FEATURE_NAMED_CHANNELS)); + CHECK_FALSE(mesh_supports(MESH_FEATURE_KEYED_CHANNELS)); + + // The selected backend is saved + REQUIRE_EQ(mesh_set_backend("loopback"), ERROR_NONE); + REQUIRE_EQ(mesh_backend_unregister(&spareApi), ERROR_NONE); + } + SUBCASE("without a region the radio stays off") { REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); CHECK(waitFor([] { return mesh_get_state() == MESH_STATE_NO_REGION; })); diff --git a/Tactility/Source/app/meshchat/MeshChannels.cpp b/Tactility/Source/app/meshchat/MeshChannels.cpp index e965155f4..5ad7f5dc4 100644 --- a/Tactility/Source/app/meshchat/MeshChannels.cpp +++ b/Tactility/Source/app/meshchat/MeshChannels.cpp @@ -27,6 +27,7 @@ #include #include #include +#include namespace tt::app::meshchat { @@ -46,6 +47,12 @@ struct Context { /** Written by the LVGL thread before it signals openBit */ ::std::atomic openIndex = 0; uint32_t addTypeDialogId = 0; + /** The kind of channel for each option of the open selection dialog */ + ::std::vector addTypes; + /** Whether the mesh network supports any way to add a channel. Read by the app's task. */ + bool canAddChannel = false; + // Valid while the window is shown + lv_obj_t* addButton = nullptr; uint32_t joinChannelDialogId = 0; uint32_t createChannelDialogId = 0; uint32_t privateNameDialogId = 0; @@ -71,6 +78,7 @@ struct Context { /** Queries the service thread, so it's called without the LVGL lock */ void loadData(Context* ctx) { ctx->network = loadNetworkInfo(); + ctx->canAddChannel = mesh_supports(MESH_FEATURE_NAMED_CHANNELS) || mesh_supports(MESH_FEATURE_KEYED_CHANNELS); ctx->channels.assign(CHANNEL_LIMIT, MeshChannel {}); size_t count = 0; if (mesh_get_channels(ctx->channels.data(), CHANNEL_LIMIT, &count) != ERROR_NONE) { @@ -113,6 +121,7 @@ void updateWidgets(Context* ctx) { return; } updateNetworkHeader(ctx->networkHeader, ctx->network); + lv_obj_set_hidden(ctx->addButton, !ctx->canAddChannel); lv_obj_clean(ctx->content); auto* channelList = createCard(ctx->content); @@ -130,7 +139,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { auto* toolbar = lvgl_toolbar_create(parent, "Mesh Channels"); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddChannelPressed, ctx); + ctx->addButton = lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddChannelPressed, ctx); lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsPressed, ctx); ctx->networkHeader = createNetworkHeader(parent); @@ -144,6 +153,7 @@ void destroyWidgets(void* userData) { auto* ctx = static_cast(userData); ctx->networkHeader = nullptr; ctx->content = nullptr; + ctx->addButton = nullptr; } void startConversation(Context* ctx) { @@ -161,18 +171,29 @@ void showMessage(Context* ctx, const char* title, const char* message) { } } -/** The order of these matches the options of the selection dialog */ enum class AddType { JoinPublic, JoinPrivate, CreatePrivate }; +/** Only offers what the mesh network supports */ void startAddChannel(Context* ctx) { if (ctx->addTypeDialogId != 0) { return; } - ctx->addTypeDialogId = selectiondialog::start(ctx->appInstanceId, "Add channel", { - "Join public channel", - "Join private channel", - "Create private channel" - }); + ctx->addTypes.clear(); + ::std::vector<::std::string> items; + if (mesh_supports(MESH_FEATURE_NAMED_CHANNELS)) { + ctx->addTypes.push_back(static_cast(AddType::JoinPublic)); + items.emplace_back("Join public channel"); + } + if (mesh_supports(MESH_FEATURE_KEYED_CHANNELS)) { + ctx->addTypes.push_back(static_cast(AddType::JoinPrivate)); + items.emplace_back("Join private channel"); + ctx->addTypes.push_back(static_cast(AddType::CreatePrivate)); + items.emplace_back("Create private channel"); + } + if (items.empty()) { + return; + } + ctx->addTypeDialogId = selectiondialog::start(ctx->appInstanceId, "Add channel", items); } void startJoinChannel(Context* ctx); @@ -180,7 +201,10 @@ void startJoinPrivateChannel(Context* ctx); void startCreateChannel(Context* ctx); void onAddTypeSelected(Context* ctx, int32_t selected) { - switch (static_cast(selected)) { + if (selected < 0 || selected >= static_cast(ctx->addTypes.size())) { + return; + } + switch (static_cast(ctx->addTypes[selected])) { case AddType::JoinPublic: startJoinChannel(ctx); break;