From c05d46a28ce14d8a515fb79c776a4102e424ede9 Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Wed, 28 Jan 2026 02:21:16 +1000 Subject: [PATCH] Screensavers (#462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds screensavers in addition to the backlight idle off. More info in screensavers.md ## Summary by CodeRabbit * **New Features** * Added a screensaver system with multiple styles (Bouncing Balls, Mystify, Matrix Rain) and a common screensaver interface; auto-starts after inactivity, dismisses on interaction, and supports auto-off/backlight behavior. * New Display setting and UI dropdown to choose and persist the screensaver. * **Documentation** * Added comprehensive screensaver docs covering usage, extension, and configuration. * **Chores** * Registered the display-idle service. * **Bug Fixes** * Updated LVGL API calls to match renamed functions. ✏️ Tip: You can customize this high-level summary in your review settings. --- .../Source/Trackball/Trackball.cpp | 4 +- .../Source/devices/TdeckKeyboard.cpp | 2 +- Documentation/screensavers.md | 110 ++++++ .../Tactility/settings/DisplaySettings.h | 9 + .../service/displayidle/DisplayIdleService.h | 90 +++++ Tactility/Source/Tactility.cpp | 4 + Tactility/Source/app/display/Display.cpp | 54 +++ .../displayidle/BouncingBallsScreensaver.cpp | 125 +++++++ .../displayidle/BouncingBallsScreensaver.h | 59 ++++ .../service/displayidle/DisplayIdle.cpp | 302 +++++++++++++---- .../displayidle/MatrixRainScreensaver.cpp | 320 ++++++++++++++++++ .../displayidle/MatrixRainScreensaver.h | 86 +++++ .../displayidle/MystifyScreensaver.cpp | 209 ++++++++++++ .../service/displayidle/MystifyScreensaver.h | 72 ++++ .../Source/service/displayidle/Screensaver.h | 48 +++ .../service/keyboardidle/KeyboardIdle.cpp | 2 +- Tactility/Source/settings/DisplaySettings.cpp | 46 ++- 17 files changed, 1474 insertions(+), 68 deletions(-) create mode 100644 Documentation/screensavers.md create mode 100644 Tactility/Private/Tactility/service/displayidle/DisplayIdleService.h create mode 100644 Tactility/Source/service/displayidle/BouncingBallsScreensaver.cpp create mode 100644 Tactility/Source/service/displayidle/BouncingBallsScreensaver.h create mode 100644 Tactility/Source/service/displayidle/MatrixRainScreensaver.cpp create mode 100644 Tactility/Source/service/displayidle/MatrixRainScreensaver.h create mode 100644 Tactility/Source/service/displayidle/MystifyScreensaver.cpp create mode 100644 Tactility/Source/service/displayidle/MystifyScreensaver.h create mode 100644 Tactility/Source/service/displayidle/Screensaver.h diff --git a/Devices/lilygo-tdeck/Source/Trackball/Trackball.cpp b/Devices/lilygo-tdeck/Source/Trackball/Trackball.cpp index b2bd6a06..af7650c2 100644 --- a/Devices/lilygo-tdeck/Source/Trackball/Trackball.cpp +++ b/Devices/lilygo-tdeck/Source/Trackball/Trackball.cpp @@ -107,7 +107,7 @@ static void read_cb(lv_indev_t* indev, lv_indev_data_t* data) { data->enc_diff = static_cast(clamp(diff, INT16_MIN, INT16_MAX)); if (diff != 0) { - lv_disp_trig_activity(nullptr); + lv_display_trigger_activity(nullptr); } } else { // Pointer mode: read and clamp cursor position @@ -127,7 +127,7 @@ static void read_cb(lv_indev_t* indev, lv_indev_data_t* data) { data->state = pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; if (pressed) { - lv_disp_trig_activity(nullptr); + lv_display_trigger_activity(nullptr); } } diff --git a/Devices/lilygo-tdeck/Source/devices/TdeckKeyboard.cpp b/Devices/lilygo-tdeck/Source/devices/TdeckKeyboard.cpp index f0dc6e0f..65357b14 100644 --- a/Devices/lilygo-tdeck/Source/devices/TdeckKeyboard.cpp +++ b/Devices/lilygo-tdeck/Source/devices/TdeckKeyboard.cpp @@ -52,7 +52,7 @@ static void keyboard_read_callback(lv_indev_t* indev, lv_indev_data_t* data) { data->state = LV_INDEV_STATE_PRESSED; // TODO: Avoid performance hit by calling loadOrGetDefault() on each key press // Ensure LVGL activity is triggered so idle services can wake the display - lv_disp_trig_activity(nullptr); + lv_display_trigger_activity(nullptr); // Actively wake display/backlights immediately on key press (independent of idle tick) // Restore display backlight if off (we assume duty 0 means dimmed) diff --git a/Documentation/screensavers.md b/Documentation/screensavers.md new file mode 100644 index 00000000..93a89020 --- /dev/null +++ b/Documentation/screensavers.md @@ -0,0 +1,110 @@ +# DisplayIdle Service + +The DisplayIdle service manages screen timeout, screensavers, and backlight control for Tactility devices. + +## Features + +### Screen Timeout +When enabled, the display will automatically dim after a configurable period of inactivity. Timeout options: +- 15 seconds +- 30 seconds +- 1 minute +- 2 minutes +- 5 minutes +- Never + +### Screensavers +Four screensaver options are available: + +| Type | Description | +|------|-------------| +| **None** | Black screen only, backlight turns off immediately | +| **Bouncing Balls** | Colored balls bouncing around the screen | +| **Mystify** | Classic Windows-style polygon trails with color-changing effects | +| **Matrix Rain** | Digital rain effect with terminal-style grid movement, 6-color gradient trails, glow effects, and random character flicker | + +### Auto-Off Feature +After 5 minutes of screensaver activity, the screensaver animation stops and the backlight turns off completely to save power. Touching the screen restores normal operation. + +## Public API + +The service exposes a public header for external control: + +```cpp +#include + +// Get service instance +auto displayIdle = tt::service::displayidle::findService(); + +// Force start screensaver immediately +displayIdle->startScreensaver(); + +// Force stop screensaver and restore backlight +displayIdle->stopScreensaver(); + +// Check if screensaver is currently active +bool active = displayIdle->isScreensaverActive(); + +// Reload settings (call after external settings changes) +displayIdle->reloadSettings(); +``` + +## Architecture + +### Files + +| File | Purpose | +|------|---------| +| `DisplayIdleService.h` | Public header with service interface | +| `DisplayIdle.cpp` | Service implementation | +| `Screensaver.h` | Base class for screensaver implementations | +| `BouncingBallsScreensaver.h/cpp` | Bouncing balls screensaver | +| `MystifyScreensaver.h/cpp` | Mystify polygon screensaver | +| `MatrixRainScreensaver.h/cpp` | Matrix digital rain screensaver | + +### Screensaver Base Class + +All screensavers inherit from the `Screensaver` base class: + +```cpp +class Screensaver { +public: + virtual void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) = 0; + virtual void stop() = 0; + virtual void update(lv_coord_t screenW, lv_coord_t screenH) = 0; +}; +``` + +### Adding a New Screensaver + +1. Create header and implementation files inheriting from `Screensaver` +2. Add enum value to `ScreensaverType` in `DisplaySettings.h` (before `Count` sentinel) +3. Add string conversion in `DisplaySettings.cpp` (`toString` and `fromString`) +4. Add dropdown option in `Display.cpp` (order must match enum order) +5. Add case in `DisplayIdle.cpp` `activateScreensaver()` switch +6. Include the new header in `DisplayIdle.cpp` + +**Note:** The `ScreensaverType::Count` sentinel must always be the last enum value - it's used for bounds checking in the UI. + +## Settings Integration + +Settings are stored in `/data/settings/display.properties` and managed through `DisplaySettings.h`: + +```cpp +struct DisplaySettings { + Orientation orientation; + uint8_t gammaCurve; + uint8_t backlightDuty; + bool backlightTimeoutEnabled; + uint32_t backlightTimeoutMs; + ScreensaverType screensaverType; +}; +``` + +The Display app (`Display.cpp`) provides the UI for configuring these settings and notifies the DisplayIdle service when settings change via `reloadSettings()`. + +## Timing + +- Service tick interval: 50ms +- Wake activity threshold: 100ms +- Screensaver auto-off: 5 minutes (6000 ticks) diff --git a/Tactility/Include/Tactility/settings/DisplaySettings.h b/Tactility/Include/Tactility/settings/DisplaySettings.h index 926dd6cc..5d9e4586 100644 --- a/Tactility/Include/Tactility/settings/DisplaySettings.h +++ b/Tactility/Include/Tactility/settings/DisplaySettings.h @@ -12,12 +12,21 @@ enum class Orientation { PortraitFlipped, }; +enum class ScreensaverType { + None, // Just black screen + BouncingBalls, + Mystify, + MatrixRain, + Count // Sentinel for bounds checking - must be last +}; + struct DisplaySettings { Orientation orientation; uint8_t gammaCurve; uint8_t backlightDuty; bool backlightTimeoutEnabled; uint32_t backlightTimeoutMs; // 0 = Never + ScreensaverType screensaverType = ScreensaverType::BouncingBalls; }; /** Compares default settings with the function parameter to return the difference */ diff --git a/Tactility/Private/Tactility/service/displayidle/DisplayIdleService.h b/Tactility/Private/Tactility/service/displayidle/DisplayIdleService.h new file mode 100644 index 00000000..8f71322c --- /dev/null +++ b/Tactility/Private/Tactility/service/displayidle/DisplayIdleService.h @@ -0,0 +1,90 @@ +#pragma once +#ifdef ESP_PLATFORM + +#include +#include +#include + +#include +#include + +// Forward declarations +typedef struct _lv_obj_t lv_obj_t; +typedef struct _lv_event_t lv_event_t; + +namespace tt::service::displayidle { + +class Screensaver; + +class DisplayIdleService final : public Service { + + std::unique_ptr timer; + bool displayDimmed = false; + settings::display::DisplaySettings cachedDisplaySettings; + + lv_obj_t* screensaverOverlay = nullptr; + std::atomic stopScreensaverRequested{false}; + std::atomic settingsReloadRequested{false}; + + // Active screensaver instance + std::unique_ptr screensaver; + + // Screensaver auto-off: turn off backlight after 5 minutes of screensaver + static constexpr uint32_t TICK_INTERVAL_MS = 50; + static constexpr uint32_t SCREENSAVER_AUTO_OFF_MS = 5 * 60 * 1000; // 5 minutes + static constexpr int SCREENSAVER_AUTO_OFF_TICKS = SCREENSAVER_AUTO_OFF_MS / TICK_INTERVAL_MS; + int screensaverActiveCounter = 0; + bool backlightOff = false; + + static void stopScreensaverCb(lv_event_t* e); + + /** @pre Caller must hold LVGL lock */ + void activateScreensaver(); + + /** @pre Caller must hold LVGL lock */ + void updateScreensaver(); + + void tick(); + +public: + bool onStart(ServiceContext& service) override; + void onStop(ServiceContext& service) override; + + /** + * Force the screensaver to start immediately, regardless of idle timeout. + * @note Not thread-safe. Call from LVGL/main context only, not from + * arbitrary threads while the timer is running. + */ + void startScreensaver(); + + /** + * Force the screensaver to stop immediately and restore backlight. + * @note Not thread-safe. Call from LVGL/main context only, not from + * arbitrary threads while the timer is running. + */ + void stopScreensaver(); + + /** + * Check if the screensaver is currently active. + * @return true if the screensaver overlay is visible + * @note Not thread-safe. Call from timer thread or LVGL context only. + */ + bool isScreensaverActive() const; + + /** + * Request reload of display settings from storage. + * Thread-safe: can be called from any thread. Actual reload + * happens on the next timer tick. + */ + void reloadSettings(); +}; + +/** + * Find the DisplayIdle service instance. + * @return shared pointer to the service, or nullptr if not found + */ +std::shared_ptr findService(); + +} // namespace tt::service::displayidle + +#endif // ESP_PLATFORM diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index 97b28cb0..3293dc84 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -53,7 +53,9 @@ namespace service { namespace loader { extern const ServiceManifest manifest; } namespace memorychecker { extern const ServiceManifest manifest; } namespace statusbar { extern const ServiceManifest manifest; } +#ifdef ESP_PLATFORM namespace displayidle { extern const ServiceManifest manifest; } +#endif #if defined(ESP_PLATFORM) && defined(CONFIG_TT_DEVICE_LILYGO_TDECK) namespace keyboardidle { extern const ServiceManifest manifest; } #endif @@ -251,7 +253,9 @@ static void registerAndStartSecondaryServices() { addService(service::loader::manifest); addService(service::gui::manifest); addService(service::statusbar::manifest); +#ifdef ESP_PLATFORM addService(service::displayidle::manifest); +#endif #if defined(ESP_PLATFORM) && defined(CONFIG_TT_DEVICE_LILYGO_TDECK) addService(service::keyboardidle::manifest); #endif diff --git a/Tactility/Source/app/display/Display.cpp b/Tactility/Source/app/display/Display.cpp index b169b24a..efc97e29 100644 --- a/Tactility/Source/app/display/Display.cpp +++ b/Tactility/Source/app/display/Display.cpp @@ -1,5 +1,8 @@ #include +#ifdef ESP_PLATFORM +#include +#endif #include #include #include @@ -22,6 +25,7 @@ class DisplayApp final : public App { bool displaySettingsUpdated = false; lv_obj_t* timeoutSwitch = nullptr; lv_obj_t* timeoutDropdown = nullptr; + lv_obj_t* screensaverDropdown = nullptr; static void onBacklightSliderEvent(lv_event_t* event) { auto* slider = static_cast(lv_event_get_target(event)); @@ -73,8 +77,14 @@ class DisplayApp final : public App { if (app->timeoutDropdown) { if (enabled) { lv_obj_clear_state(app->timeoutDropdown, LV_STATE_DISABLED); + if (app->screensaverDropdown) { + lv_obj_clear_state(app->screensaverDropdown, LV_STATE_DISABLED); + } } else { lv_obj_add_state(app->timeoutDropdown, LV_STATE_DISABLED); + if (app->screensaverDropdown) { + lv_obj_add_state(app->screensaverDropdown, LV_STATE_DISABLED); + } } } } @@ -91,6 +101,21 @@ class DisplayApp final : public App { } } + static void onScreensaverChanged(lv_event_t* event) { + auto* app = static_cast(lv_event_get_user_data(event)); + auto* dropdown = static_cast(lv_event_get_target(event)); + uint32_t idx = lv_dropdown_get_selected(dropdown); + // Validate index bounds before casting to enum + if (idx >= static_cast(settings::display::ScreensaverType::Count)) { + return; + } + auto selected_type = static_cast(idx); + if (selected_type != app->displaySettings.screensaverType) { + app->displaySettings.screensaverType = selected_type; + app->displaySettingsUpdated = true; + } + } + public: void onShow(AppContext& app, lv_obj_t* parent) override { @@ -233,6 +258,28 @@ public: if (!displaySettings.backlightTimeoutEnabled) { lv_obj_add_state(timeoutDropdown, LV_STATE_DISABLED); } + + // Screensaver type + auto* screensaver_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(screensaver_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(screensaver_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(screensaver_wrapper, 0, LV_STATE_DEFAULT); + + auto* screensaver_label = lv_label_create(screensaver_wrapper); + lv_label_set_text(screensaver_label, "Screensaver"); + lv_obj_align(screensaver_label, LV_ALIGN_LEFT_MID, 0, 0); + + screensaverDropdown = lv_dropdown_create(screensaver_wrapper); + // Note: order correlates with settings::display::ScreensaverType enum order + lv_dropdown_set_options(screensaverDropdown, "None\nBouncing Balls\nMystify\nMatrix Rain"); + lv_obj_align(screensaverDropdown, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_border_color(screensaverDropdown, lv_color_hex(0xFAFAFA), LV_PART_MAIN); + lv_obj_set_style_border_width(screensaverDropdown, 1, LV_PART_MAIN); + lv_obj_add_event_cb(screensaverDropdown, onScreensaverChanged, LV_EVENT_VALUE_CHANGED, this); + lv_dropdown_set_selected(screensaverDropdown, static_cast(displaySettings.screensaverType)); + if (!displaySettings.backlightTimeoutEnabled) { + lv_obj_add_state(screensaverDropdown, LV_STATE_DISABLED); + } } } @@ -242,6 +289,13 @@ public: const settings::display::DisplaySettings settings_to_save = displaySettings; getMainDispatcher().dispatch([settings_to_save] { settings::display::save(settings_to_save); +#ifdef ESP_PLATFORM + // Notify DisplayIdle service to reload settings + auto displayIdle = service::displayidle::findService(); + if (displayIdle) { + displayIdle->reloadSettings(); + } +#endif }); } } diff --git a/Tactility/Source/service/displayidle/BouncingBallsScreensaver.cpp b/Tactility/Source/service/displayidle/BouncingBallsScreensaver.cpp new file mode 100644 index 00000000..af01c84d --- /dev/null +++ b/Tactility/Source/service/displayidle/BouncingBallsScreensaver.cpp @@ -0,0 +1,125 @@ +#ifdef ESP_PLATFORM + +#include "BouncingBallsScreensaver.h" +#include + +namespace tt::service::displayidle { + +void BouncingBallsScreensaver::start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) { + for (int i = 0; i < NUM_BALLS; i++) { + initBall(balls_[i], overlay, screenW, screenH, i); + } +} + +void BouncingBallsScreensaver::stop() { + for (auto& ball : balls_) { + ball.obj = nullptr; // Objects deleted by parent overlay + } +} + +void BouncingBallsScreensaver::update(lv_coord_t screenW, lv_coord_t screenH) { + for (auto& ball : balls_) { + if (!ball.obj) continue; + + ball.x += ball.dx; + ball.y += ball.dy; + + bool bounced = false; + + if (ball.x <= 0) { + ball.x = 0; + ball.dx = -ball.dx; + bounced = true; + } else if (ball.x >= screenW - BALL_SIZE) { + ball.x = screenW - BALL_SIZE; + ball.dx = -ball.dx; + bounced = true; + } + + if (ball.y <= 0) { + ball.y = 0; + ball.dy = -ball.dy; + bounced = true; + } else if (ball.y >= screenH - BALL_SIZE) { + ball.y = screenH - BALL_SIZE; + ball.dy = -ball.dy; + bounced = true; + } + + if (bounced) { + setRandomTargetColor(ball); + } + + updateBallColor(ball); + lv_obj_set_pos(ball.obj, ball.x, ball.y); + } +} + +void BouncingBallsScreensaver::initBall(Ball& ball, lv_obj_t* parent, lv_coord_t screenW, lv_coord_t screenH, int index) { + ball.obj = lv_obj_create(parent); + if (ball.obj == nullptr) { + return; + } + lv_obj_remove_style_all(ball.obj); + lv_obj_set_size(ball.obj, BALL_SIZE, BALL_SIZE); + lv_obj_set_style_bg_opa(ball.obj, LV_OPA_COVER, 0); + lv_obj_set_style_radius(ball.obj, BALL_SIZE / 2, 0); + + ball.x = (screenW > BALL_SIZE) ? rand() % (screenW - BALL_SIZE) : 0; + ball.y = (screenH > BALL_SIZE) ? rand() % (screenH - BALL_SIZE) : 0; + + // Random speeds between 1-4 with random direction + // Note: adjustment below may extend range to 1-5 to ensure varied paths + ball.dx = (rand() % 4) + 1; + ball.dy = (rand() % 4) + 1; + if (rand() % 2) ball.dx = -ball.dx; + if (rand() % 2) ball.dy = -ball.dy; + // Ensure dx != dy for more varied paths (may adjust dy to 5 or -5) + if (abs(ball.dx) == abs(ball.dy)) { + ball.dy = ball.dy > 0 ? ball.dy + 1 : ball.dy - 1; + } + + uint32_t color = COLORS[index % COLORS.size()]; + ball.currentR = ball.targetR = (color >> 16) & 0xFF; + ball.currentG = ball.targetG = (color >> 8) & 0xFF; + ball.currentB = ball.targetB = color & 0xFF; + ball.colorStep = 0; + + lv_obj_set_style_bg_color(ball.obj, lv_color_make(ball.currentR, ball.currentG, ball.currentB), 0); + lv_obj_set_pos(ball.obj, ball.x, ball.y); +} + +void BouncingBallsScreensaver::setRandomTargetColor(Ball& ball) { + uint32_t color = COLORS[rand() % COLORS.size()]; + ball.targetR = (color >> 16) & 0xFF; + ball.targetG = (color >> 8) & 0xFF; + ball.targetB = color & 0xFF; + ball.colorStep = COLOR_FADE_STEPS; +} + +void BouncingBallsScreensaver::updateBallColor(Ball& ball) { + if (ball.colorStep > 0) { + int stepR = (static_cast(ball.targetR) - ball.currentR) / ball.colorStep; + int stepG = (static_cast(ball.targetG) - ball.currentG) / ball.colorStep; + int stepB = (static_cast(ball.targetB) - ball.currentB) / ball.colorStep; + + ball.currentR = static_cast(ball.currentR + stepR); + ball.currentG = static_cast(ball.currentG + stepG); + ball.currentB = static_cast(ball.currentB + stepB); + ball.colorStep--; + + if (ball.colorStep == 0) { + ball.currentR = ball.targetR; + ball.currentG = ball.targetG; + ball.currentB = ball.targetB; + } + + if (ball.obj) { + lv_obj_set_style_bg_color(ball.obj, lv_color_make(ball.currentR, ball.currentG, ball.currentB), 0); + } + } +} + +} // namespace tt::service::displayidle + +#endif // ESP_PLATFORM diff --git a/Tactility/Source/service/displayidle/BouncingBallsScreensaver.h b/Tactility/Source/service/displayidle/BouncingBallsScreensaver.h new file mode 100644 index 00000000..2bfbf412 --- /dev/null +++ b/Tactility/Source/service/displayidle/BouncingBallsScreensaver.h @@ -0,0 +1,59 @@ +#pragma once +#ifdef ESP_PLATFORM + +#include "Screensaver.h" +#include +#include + +namespace tt::service::displayidle { + +class BouncingBallsScreensaver final : public Screensaver { +public: + BouncingBallsScreensaver() = default; + ~BouncingBallsScreensaver() override = default; + BouncingBallsScreensaver(const BouncingBallsScreensaver&) = delete; + BouncingBallsScreensaver& operator=(const BouncingBallsScreensaver&) = delete; + BouncingBallsScreensaver(BouncingBallsScreensaver&&) = delete; + BouncingBallsScreensaver& operator=(BouncingBallsScreensaver&&) = delete; + + void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) override; + void stop() override; + void update(lv_coord_t screenW, lv_coord_t screenH) override; + +private: + static constexpr int BALL_SIZE = 20; + static constexpr int NUM_BALLS = 5; + static constexpr int COLOR_FADE_STEPS = 15; + + struct Ball { + lv_obj_t* obj = nullptr; + lv_coord_t x = 0; + lv_coord_t y = 0; + lv_coord_t dx = 0; + lv_coord_t dy = 0; + uint8_t currentR = 0, currentG = 0, currentB = 0; + uint8_t targetR = 0, targetG = 0, targetB = 0; + int colorStep = 0; + }; + + static constexpr std::array COLORS = { + 0xFF0000, // Red + 0x00FF00, // Green + 0x0000FF, // Blue + 0xFFFF00, // Yellow + 0xFF00FF, // Magenta + 0x00FFFF, // Cyan + 0xFF8000, // Orange + 0x80FF00, // Lime + }; + + std::array balls_; + + void initBall(Ball& ball, lv_obj_t* parent, lv_coord_t screenW, lv_coord_t screenH, int index); + static void setRandomTargetColor(Ball& ball); + static void updateBallColor(Ball& ball); +}; + +} // namespace tt::service::displayidle + +#endif // ESP_PLATFORM diff --git a/Tactility/Source/service/displayidle/DisplayIdle.cpp b/Tactility/Source/service/displayidle/DisplayIdle.cpp index e577b257..bcb42460 100644 --- a/Tactility/Source/service/displayidle/DisplayIdle.cpp +++ b/Tactility/Source/service/displayidle/DisplayIdle.cpp @@ -1,91 +1,265 @@ +#ifdef ESP_PLATFORM + +#include + +#include "Screensaver.h" +#include "BouncingBallsScreensaver.h" +#include "MatrixRainScreensaver.h" +#include "MystifyScreensaver.h" + +#include #include #include #include #include #include #include -#include -#include +#include +#include namespace tt::service::displayidle { -class DisplayIdleService final : public Service { +static const auto LOGGER = Logger("DisplayIdle"); - std::unique_ptr timer; - bool displayDimmed = false; - settings::display::DisplaySettings cachedDisplaySettings; +constexpr uint32_t kWakeActivityThresholdMs = 100; - static std::shared_ptr getDisplay() { - return hal::findFirstDevice(hal::Device::Type::Display); +static std::shared_ptr getDisplay() { + return hal::findFirstDevice(hal::Device::Type::Display); +} + +void DisplayIdleService::stopScreensaverCb(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + lv_event_stop_bubbling(e); + self->stopScreensaverRequested.store(true, std::memory_order_release); + lv_display_trigger_activity(nullptr); +} + +void DisplayIdleService::stopScreensaver() { + if (!lvgl::lock(100)) { + // Lock failed - keep flag set to retry on next tick + return; } - void tick() { - // Settings are now cached and event-driven (no file I/O in timer callback!) - // This prevents watchdog timeout from blocking the Timer Service task + const auto restoreDuty = cachedDisplaySettings.backlightDuty; + const bool wasDimmed = displayDimmed; - if (lv_disp_get_default() == nullptr) { - return; + if (screensaverOverlay) { + if (screensaver) { + screensaver->stop(); + screensaver.reset(); } + lv_obj_delete(screensaverOverlay); + screensaverOverlay = nullptr; + } + lvgl::unlock(); + stopScreensaverRequested.store(false, std::memory_order_relaxed); - // Query LVGL inactivity once for both checks - uint32_t inactive_ms = 0; - if (lvgl::lock(100)) { - inactive_ms = lv_disp_get_inactive_time(nullptr); - lvgl::unlock(); - } else { - return; - } + // Reset auto-off state + screensaverActiveCounter = 0; + backlightOff = false; - // TODO: The following logic only works with the first display. There might be multiple displays. - // Handle display backlight - auto display = getDisplay(); - if (display != nullptr && display->supportsBacklightDuty()) { - // If timeout disabled, ensure backlight restored if we had dimmed it - if (!cachedDisplaySettings.backlightTimeoutEnabled || cachedDisplaySettings.backlightTimeoutMs == 0) { - if (displayDimmed) { - display->setBacklightDuty(cachedDisplaySettings.backlightDuty); - displayDimmed = false; - } - } else { - if (!displayDimmed && inactive_ms >= cachedDisplaySettings.backlightTimeoutMs) { - display->setBacklightDuty(0); - displayDimmed = true; - } else if (displayDimmed && inactive_ms < 100) { - display->setBacklightDuty(cachedDisplaySettings.backlightDuty); - displayDimmed = false; + // Restore backlight if display was dimmed + auto display = getDisplay(); + if (display && wasDimmed) { + display->setBacklightDuty(restoreDuty); + } + displayDimmed = wasDimmed ? false : displayDimmed; +} + +void DisplayIdleService::activateScreensaver() { + lv_obj_t* top = lv_layer_top(); + + if (screensaverOverlay != nullptr) return; + + // Reset auto-off counter when starting screensaver + screensaverActiveCounter = 0; + backlightOff = false; + + lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr); + lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr); + + // Black background overlay + screensaverOverlay = lv_obj_create(top); + lv_obj_remove_style_all(screensaverOverlay); + lv_obj_set_size(screensaverOverlay, LV_PCT(100), LV_PCT(100)); + lv_obj_set_pos(screensaverOverlay, 0, 0); + lv_obj_set_style_bg_color(screensaverOverlay, lv_color_black(), 0); + lv_obj_set_style_bg_opa(screensaverOverlay, LV_OPA_COVER, 0); + lv_obj_add_flag(screensaverOverlay, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(screensaverOverlay, stopScreensaverCb, LV_EVENT_CLICKED, this); + + // Create and start the screensaver based on settings + switch (cachedDisplaySettings.screensaverType) { + case settings::display::ScreensaverType::Mystify: + screensaver = std::make_unique(); + break; + case settings::display::ScreensaverType::BouncingBalls: + screensaver = std::make_unique(); + break; + case settings::display::ScreensaverType::MatrixRain: + screensaver = std::make_unique(); + break; + case settings::display::ScreensaverType::None: + default: + // Just black screen, no animated screensaver + screensaver = nullptr; + break; + } + + if (screensaver) { + screensaver->start(screensaverOverlay, screenW, screenH); + } +} + +void DisplayIdleService::updateScreensaver() { + if (screensaver) { + lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr); + lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr); + screensaver->update(screenW, screenH); + } +} + +void DisplayIdleService::tick() { + if (!lvgl::lock(100)) { + return; + } + if (lv_display_get_default() == nullptr) { + lvgl::unlock(); + return; + } + + // Check for settings reload request (thread-safe) + if (settingsReloadRequested.exchange(false, std::memory_order_acquire)) { + cachedDisplaySettings = settings::display::loadOrGetDefault(); + } + + uint32_t inactive_ms = 0; + + inactive_ms = lv_display_get_inactive_time(nullptr); + + // Only update if not stopping (prevents lag on touch) + if (displayDimmed && screensaverOverlay && !stopScreensaverRequested.load(std::memory_order_acquire)) { + // Check if screensaver should auto-off after 5 minutes + if (!backlightOff) { + screensaverActiveCounter++; + if (screensaverActiveCounter >= SCREENSAVER_AUTO_OFF_TICKS) { + // Stop screensaver animation and turn off backlight + if (screensaver) { + screensaver->stop(); + screensaver.reset(); + } + auto display = getDisplay(); + if (display) { + display->setBacklightDuty(0); + } + backlightOff = true; + } else { + updateScreensaver(); } } } + + lvgl::unlock(); + + // Check stop request early for faster response + if (stopScreensaverRequested.load(std::memory_order_acquire)) { + stopScreensaver(); + return; } -public: - bool onStart(ServiceContext& service) override { - // Load settings once at startup and cache them - // This eliminates file I/O from timer callback (prevents watchdog timeout) - cachedDisplaySettings = settings::display::loadOrGetDefault(); - - // Note: Settings changes require service restart to take effect - // TODO: Add DisplaySettingsChanged events for dynamic updates - - timer = std::make_unique(Timer::Type::Periodic, kernel::millisToTicks(250), [this]{ this->tick(); }); - timer->setCallbackPriority(Thread::Priority::Lower); - timer->start(); - return true; + auto display = getDisplay(); + if (display != nullptr && display->supportsBacklightDuty()) { + if (!cachedDisplaySettings.backlightTimeoutEnabled || cachedDisplaySettings.backlightTimeoutMs == 0) { + if (displayDimmed) { + display->setBacklightDuty(cachedDisplaySettings.backlightDuty); + displayDimmed = false; + } + } else { + if (!displayDimmed && inactive_ms >= cachedDisplaySettings.backlightTimeoutMs) { + if (!lvgl::lock(100)) { + return; // Retry on next tick + } + activateScreensaver(); + lvgl::unlock(); + // Turn off backlight for "None" screensaver (just black screen) + if (cachedDisplaySettings.screensaverType == settings::display::ScreensaverType::None) { + display->setBacklightDuty(0); + } + displayDimmed = true; + } else if (displayDimmed && (inactive_ms < kWakeActivityThresholdMs)) { + stopScreensaver(); + } + } + } +} + +bool DisplayIdleService::onStart(ServiceContext& service) { + // Seed random number generator for varied screensaver patterns + srand(static_cast(time(nullptr))); + + cachedDisplaySettings = settings::display::loadOrGetDefault(); + + timer = std::make_unique(Timer::Type::Periodic, kernel::millisToTicks(TICK_INTERVAL_MS), [this]{ this->tick(); }); + timer->setCallbackPriority(Thread::Priority::Lower); + timer->start(); + return true; +} + +void DisplayIdleService::onStop(ServiceContext& service) { + if (timer) { + timer->stop(); + timer = nullptr; + } + if (screensaverOverlay) { + // Retry screensaver cleanup during shutdown + constexpr int maxRetries = 5; + for (int i = 0; i < maxRetries && screensaverOverlay; ++i) { + stopScreensaver(); + if (screensaverOverlay && i < maxRetries - 1) { + kernel::delayMillis(50); // Brief delay before retry + } + } + if (screensaverOverlay) { + LOGGER.warn("Failed to stop screensaver during shutdown - potential resource leak"); + } + } + screensaver.reset(); +} + +void DisplayIdleService::startScreensaver() { + if (!lvgl::lock(100)) { + return; } - void onStop(ServiceContext& service) override { - if (timer) { - timer->stop(); - timer = nullptr; - } - // Ensure display restored on stop - auto display = getDisplay(); - if (display && displayDimmed) { - display->setBacklightDuty(cachedDisplaySettings.backlightDuty); - displayDimmed = false; - } + // Reload settings to get current screensaver type + // Note: This is safe because we hold the LVGL lock which serializes with tick() + cachedDisplaySettings = settings::display::loadOrGetDefault(); + + activateScreensaver(); + lvgl::unlock(); + + // Turn off backlight for "None" screensaver + auto display = getDisplay(); + if (display && cachedDisplaySettings.screensaverType == settings::display::ScreensaverType::None) { + display->setBacklightDuty(0); } -}; + displayDimmed = true; +} + +bool DisplayIdleService::isScreensaverActive() const { + return screensaverOverlay != nullptr; +} + +void DisplayIdleService::reloadSettings() { + // Set flag for thread-safe reload - actual reload happens in tick() + settingsReloadRequested.store(true, std::memory_order_release); +} + +std::shared_ptr findService() { + return std::static_pointer_cast( + findServiceById("DisplayIdle") + ); +} extern const ServiceManifest manifest = { .id = "DisplayIdle", @@ -93,3 +267,5 @@ extern const ServiceManifest manifest = { }; } + +#endif // ESP_PLATFORM diff --git a/Tactility/Source/service/displayidle/MatrixRainScreensaver.cpp b/Tactility/Source/service/displayidle/MatrixRainScreensaver.cpp new file mode 100644 index 00000000..d8eadd9f --- /dev/null +++ b/Tactility/Source/service/displayidle/MatrixRainScreensaver.cpp @@ -0,0 +1,320 @@ +#ifdef ESP_PLATFORM + +#include "MatrixRainScreensaver.h" +#include +#include +#include + +namespace tt::service::displayidle { + +static_assert(MatrixRainScreensaver::MAX_TRAIL_LENGTH > MatrixRainScreensaver::MIN_TRAIL_LENGTH, + "MAX_TRAIL_LENGTH must be greater than MIN_TRAIL_LENGTH"); +static_assert(MatrixRainScreensaver::MAX_TICK_DELAY >= MatrixRainScreensaver::MIN_TICK_DELAY, + "MAX_TICK_DELAY must be >= MIN_TICK_DELAY"); + +static constexpr int TRAIL_LENGTH_RANGE = MatrixRainScreensaver::MAX_TRAIL_LENGTH - MatrixRainScreensaver::MIN_TRAIL_LENGTH; +static constexpr int TICK_DELAY_RANGE = MatrixRainScreensaver::MAX_TICK_DELAY - MatrixRainScreensaver::MIN_TICK_DELAY + 1; + +char MatrixRainScreensaver::randomChar() { + static const char chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*<>?"; + return chars[rand() % (sizeof(chars) - 1)]; +} + +lv_color_t MatrixRainScreensaver::getTrailColor(int index, int trailLength) const { + if (trailLength <= 1) { + return lv_color_hex(COLOR_GRADIENT[0]); + } + + float intensity = static_cast(index) / static_cast(trailLength - 1); + int colorIndex = static_cast(intensity * (COLOR_GRADIENT.size() - 1) + 0.5f); + + if (colorIndex < 0) colorIndex = 0; + if (colorIndex >= static_cast(COLOR_GRADIENT.size())) { + colorIndex = COLOR_GRADIENT.size() - 1; + } + + return lv_color_hex(COLOR_GRADIENT[colorIndex]); +} + +int MatrixRainScreensaver::getRandomAvailableColumn() { + if (availableColumns_.empty()) { + return -1; + } + int idx = rand() % availableColumns_.size(); + int column = availableColumns_[idx]; + // O(1) removal: swap with last element and pop + availableColumns_[idx] = availableColumns_.back(); + availableColumns_.pop_back(); + return column; +} + +void MatrixRainScreensaver::releaseColumn(int column) { + // Add column back to available pool if not already present (duplicate protection) + bool alreadyPresent = std::find(availableColumns_.begin(), availableColumns_.end(), column) != availableColumns_.end(); + assert(!alreadyPresent && "Column released twice - indicates bug in column management"); + if (!alreadyPresent) { + availableColumns_.push_back(column); + } +} + +void MatrixRainScreensaver::createRainCharLabels(RainChar& rc, int trailIndex, int trailLength) { + if (overlay_ == nullptr) { + rc.label = nullptr; + rc.glowLabel = nullptr; + return; + } + rc.ch = randomChar(); + + // Create glow layer first (behind main text) + rc.glowLabel = lv_label_create(overlay_); + if (rc.glowLabel == nullptr) { + rc.label = nullptr; + return; + } + lv_obj_set_style_text_font(rc.glowLabel, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(rc.glowLabel, lv_color_hex(COLOR_GLOW), 0); + lv_obj_set_style_opa(rc.glowLabel, LV_OPA_70, 0); + + // Create main character label + rc.label = lv_label_create(overlay_); + if (rc.label == nullptr) { + lv_obj_delete(rc.glowLabel); + rc.glowLabel = nullptr; + return; + } + lv_obj_set_style_text_font(rc.label, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(rc.label, getTrailColor(trailIndex, trailLength), 0); + + char text[2] = {rc.ch, '\0'}; + lv_label_set_text(rc.label, text); + lv_label_set_text(rc.glowLabel, text); + + // Hide initially + lv_obj_set_pos(rc.label, -100, -100); + lv_obj_set_pos(rc.glowLabel, -100, -100); +} + +void MatrixRainScreensaver::initRaindrop(Raindrop& drop, int column) { + drop.column = column; + drop.trailLength = MIN_TRAIL_LENGTH + rand() % TRAIL_LENGTH_RANGE; + drop.headRow = -(drop.trailLength); // Start above screen + drop.tickDelay = MIN_TICK_DELAY + rand() % TICK_DELAY_RANGE; + drop.tickCounter = 0; + drop.active = true; + + // Create character labels for trail + drop.chars.resize(drop.trailLength); + for (int i = 0; i < drop.trailLength; i++) { + createRainCharLabels(drop.chars[i], i, drop.trailLength); + } +} + +void MatrixRainScreensaver::resetRaindrop(Raindrop& drop) { + releaseColumn(drop.column); + + int newColumn = getRandomAvailableColumn(); + if (newColumn < 0) { + drop.active = false; + return; + } + + drop.column = newColumn; + drop.tickDelay = MIN_TICK_DELAY + rand() % TICK_DELAY_RANGE; + drop.tickCounter = 0; + + int newTrailLength = MIN_TRAIL_LENGTH + rand() % TRAIL_LENGTH_RANGE; + + // Resize if needed + if (newTrailLength != drop.trailLength) { + // Delete excess labels + for (size_t i = newTrailLength; i < drop.chars.size(); i++) { + if (drop.chars[i].label) { + lv_obj_delete(drop.chars[i].label); + } + if (drop.chars[i].glowLabel) { + lv_obj_delete(drop.chars[i].glowLabel); + } + } + + size_t oldSize = drop.chars.size(); + drop.chars.resize(newTrailLength); + + // Create new labels if needed (will be refreshed below) + for (size_t i = oldSize; i < drop.chars.size(); i++) { + createRainCharLabels(drop.chars[i], static_cast(i), newTrailLength); + } + + drop.trailLength = newTrailLength; + } + + // Reset position above screen + drop.headRow = -(drop.trailLength); + + // Refresh characters and colors for all labels + for (int i = 0; i < drop.trailLength; i++) { + auto& rc = drop.chars[i]; + if (rc.label == nullptr || rc.glowLabel == nullptr) { + continue; + } + rc.ch = randomChar(); + + char text[2] = {rc.ch, '\0'}; + lv_label_set_text(rc.label, text); + lv_label_set_text(rc.glowLabel, text); + lv_obj_set_style_text_color(rc.label, getTrailColor(i, drop.trailLength), 0); + + lv_obj_set_pos(rc.label, -100, -100); + lv_obj_set_pos(rc.glowLabel, -100, -100); + } + + drop.active = true; +} + +void MatrixRainScreensaver::updateRaindropDisplay(Raindrop& drop) { + int xPos = drop.column * CHAR_WIDTH; + + for (int i = 0; i < drop.trailLength; i++) { + auto& rc = drop.chars[i]; + if (rc.label == nullptr || rc.glowLabel == nullptr) { + continue; + } + int row = drop.headRow - i; + int yPos = row * CHAR_HEIGHT; + + // Only show characters that are on screen (grid-snapped positions) + if (row >= 0 && row < numRows_) { + lv_obj_set_pos(rc.label, xPos, yPos); + lv_obj_set_pos(rc.glowLabel, xPos + GLOW_OFFSET, yPos + GLOW_OFFSET); + } else { + lv_obj_set_pos(rc.label, -100, -100); + lv_obj_set_pos(rc.glowLabel, -100, -100); + } + } +} + +void MatrixRainScreensaver::flickerRandomChars() { + constexpr int ERROR_PERCENT = 3; + + for (auto& drop : drops_) { + if (!drop.active) continue; + + for (int i = 0; i < drop.trailLength; i++) { + int row = drop.headRow - i; + + // Only process visible characters + if (row >= 0 && row < numRows_) { + if (rand() % 100 < ERROR_PERCENT) { + auto& rc = drop.chars[i]; + if (rc.label == nullptr || rc.glowLabel == nullptr) { + continue; + } + rc.ch = randomChar(); + char text[2] = {rc.ch, '\0'}; + lv_label_set_text(rc.label, text); + lv_label_set_text(rc.glowLabel, text); + } + } + } + } +} + +void MatrixRainScreensaver::start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) { + if (!drops_.empty()) { + stop(); + } + + overlay_ = overlay; + screenW_ = screenW; + screenH_ = screenH; + globalFlickerCounter_ = 0; + + numColumns_ = screenW / CHAR_WIDTH; + numRows_ = screenH / CHAR_HEIGHT; + + availableColumns_.clear(); + for (int i = 0; i < numColumns_; i++) { + availableColumns_.push_back(i); + } + + int numDrops = std::min(MAX_ACTIVE_DROPS, numColumns_); + drops_.resize(numDrops); + + for (int i = 0; i < numDrops; i++) { + int col = getRandomAvailableColumn(); + if (col >= 0) { + initRaindrop(drops_[i], col); + // Stagger start positions (guard against tiny screens) + int staggerRange = numRows_ / 2; + if (staggerRange > 0) { + drops_[i].headRow = -(rand() % staggerRange); + } + } + } +} + +void MatrixRainScreensaver::stop() { + // Explicitly delete labels to prevent resource leaks + for (auto& drop : drops_) { + for (auto& rc : drop.chars) { + if (rc.label) { + lv_obj_delete(rc.label); + rc.label = nullptr; + } + if (rc.glowLabel) { + lv_obj_delete(rc.glowLabel); + rc.glowLabel = nullptr; + } + } + drop.chars.clear(); + } + drops_.clear(); + availableColumns_.clear(); + overlay_ = nullptr; +} + +void MatrixRainScreensaver::update(lv_coord_t screenW, lv_coord_t screenH) { + // Screen dimensions captured at start() - no dynamic resize support during screensaver + LV_UNUSED(screenW); + LV_UNUSED(screenH); + + // Global glitch effect + globalFlickerCounter_++; + if (globalFlickerCounter_ >= 5) { + globalFlickerCounter_ = 0; + flickerRandomChars(); + } + + for (auto& drop : drops_) { + if (!drop.active) continue; + + // Tick-based movement (terminal style - advance by row) + drop.tickCounter++; + if (drop.tickCounter >= drop.tickDelay) { + drop.tickCounter = 0; + + // Advance head down by one row + drop.headRow++; + + // Randomly change the head character when it advances + if (!drop.chars.empty() && drop.chars[0].label != nullptr && drop.chars[0].glowLabel != nullptr) { + drop.chars[0].ch = randomChar(); + char text[2] = {drop.chars[0].ch, '\0'}; + lv_label_set_text(drop.chars[0].label, text); + lv_label_set_text(drop.chars[0].glowLabel, text); + } + + // Check if tail has completely left the screen + int tailRow = drop.headRow - (drop.trailLength - 1); + if (tailRow >= numRows_) { + resetRaindrop(drop); + continue; + } + } + + updateRaindropDisplay(drop); + } +} + +} // namespace tt::service::displayidle + +#endif // ESP_PLATFORM diff --git a/Tactility/Source/service/displayidle/MatrixRainScreensaver.h b/Tactility/Source/service/displayidle/MatrixRainScreensaver.h new file mode 100644 index 00000000..76632647 --- /dev/null +++ b/Tactility/Source/service/displayidle/MatrixRainScreensaver.h @@ -0,0 +1,86 @@ +#pragma once +#ifdef ESP_PLATFORM + +#include "Screensaver.h" +#include +#include +#include + +namespace tt::service::displayidle { + +class MatrixRainScreensaver final : public Screensaver { +public: + MatrixRainScreensaver() = default; + MatrixRainScreensaver(const MatrixRainScreensaver&) = delete; + MatrixRainScreensaver& operator=(const MatrixRainScreensaver&) = delete; + MatrixRainScreensaver(MatrixRainScreensaver&&) = delete; + MatrixRainScreensaver& operator=(MatrixRainScreensaver&&) = delete; + + static constexpr int CHAR_WIDTH = 12; + static constexpr int CHAR_HEIGHT = 16; + static constexpr int MIN_TRAIL_LENGTH = 8; + static constexpr int MAX_TRAIL_LENGTH = 20; + static constexpr int MAX_ACTIVE_DROPS = 16; + static constexpr int MIN_TICK_DELAY = 2; // Fastest: advance every 2 frames + static constexpr int MAX_TICK_DELAY = 5; // Slowest: advance every 5 frames + static constexpr int GLOW_OFFSET = 2; + + ~MatrixRainScreensaver() override = default; + + void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) override; + void stop() override; + void update(lv_coord_t screenW, lv_coord_t screenH) override; + +private: + // A single character cell in the rain + struct RainChar { + lv_obj_t* label = nullptr; + lv_obj_t* glowLabel = nullptr; + char ch = ' '; + }; + + // A raindrop - now uses integer row positions (terminal-style grid) + struct Raindrop { + int headRow = 0; // Current head row position (grid-based) + int column = 0; // Column index + int trailLength = 10; + int tickDelay = 3; // Frames between row advances + int tickCounter = 0; // Current tick count + std::vector chars; + bool active = false; + }; + + std::vector drops_; + std::vector availableColumns_; + lv_obj_t* overlay_ = nullptr; + lv_coord_t screenW_ = 0; + lv_coord_t screenH_ = 0; + int numColumns_ = 0; + int numRows_ = 0; + int globalFlickerCounter_ = 0; + + // Six-color gradient palette + static constexpr std::array COLOR_GRADIENT = { + 0xAAFFDD, // 0: Bright cyan-white (head) + 0x00FF66, // 1: Bright green + 0x00DD44, // 2: Medium-bright green + 0x00AA33, // 3: Medium green + 0x006622, // 4: Dim green + 0x003311, // 5: Dark green (tail end) + }; + static constexpr uint32_t COLOR_GLOW = 0x002208; + + void initRaindrop(Raindrop& drop, int column); + void resetRaindrop(Raindrop& drop); + void updateRaindropDisplay(Raindrop& drop); + void flickerRandomChars(); + void createRainCharLabels(RainChar& rc, int trailIndex, int trailLength); + lv_color_t getTrailColor(int index, int trailLength) const; + static char randomChar(); + int getRandomAvailableColumn(); + void releaseColumn(int column); +}; + +} // namespace tt::service::displayidle + +#endif // ESP_PLATFORM diff --git a/Tactility/Source/service/displayidle/MystifyScreensaver.cpp b/Tactility/Source/service/displayidle/MystifyScreensaver.cpp new file mode 100644 index 00000000..24f617e6 --- /dev/null +++ b/Tactility/Source/service/displayidle/MystifyScreensaver.cpp @@ -0,0 +1,209 @@ +#ifdef ESP_PLATFORM + +#include "MystifyScreensaver.h" +#include +#include + +namespace tt::service::displayidle { + +// Helper to generate random float in range [min, max] +static float randomFloat(float min, float max) { + if (max <= min) return min; + return min + (max - min) * static_cast(rand()) / static_cast(RAND_MAX); +} + +void MystifyScreensaver::start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) { + for (auto& polygon : polygons_) { + initPolygon(polygon, overlay, screenW, screenH); + } +} + +void MystifyScreensaver::stop() { + for (auto& polygon : polygons_) { + for (auto& trailLines : polygon.lines) { + for (auto& line : trailLines) { + line = nullptr; // Objects deleted by parent overlay + } + } + polygon.historyHead = 0; + polygon.historyFull = false; + } +} + +void MystifyScreensaver::update(lv_coord_t screenW, lv_coord_t screenH) { + constexpr float minSpeed = 0.5f; + constexpr float maxSpeed = 2.5f; + + for (auto& polygon : polygons_) { + // Periodic color change + polygon.colorChangeCounter++; + if (polygon.colorChangeCounter >= COLOR_CHANGE_INTERVAL) { + polygon.colorChangeCounter = 0; + uint32_t colorHex = COLOR_POOL[rand() % COLOR_POOL.size()]; + polygon.color = lv_color_hex(colorHex); + updateLineColors(polygon); + } + + // Move vertices + for (auto& vertex : polygon.vertices) { + vertex.x += vertex.dx; + vertex.y += vertex.dy; + + // Bounce off edges with slight angle variation for more organic movement + if (vertex.x <= 0) { + vertex.x = 0; + vertex.dx = std::fabs(vertex.dx) + randomFloat(-0.2f, 0.2f); + } else if (vertex.x >= screenW - 1) { + vertex.x = static_cast(screenW - 1); + vertex.dx = -std::fabs(vertex.dx) + randomFloat(-0.2f, 0.2f); + } + + if (vertex.y <= 0) { + vertex.y = 0; + vertex.dy = std::fabs(vertex.dy) + randomFloat(-0.2f, 0.2f); + } else if (vertex.y >= screenH - 1) { + vertex.y = static_cast(screenH - 1); + vertex.dy = -std::fabs(vertex.dy) + randomFloat(-0.2f, 0.2f); + } + + // Clamp speeds to prevent runaway acceleration or stalling + if (std::fabs(vertex.dx) < minSpeed) vertex.dx = (vertex.dx >= 0 ? minSpeed : -minSpeed); + if (std::fabs(vertex.dy) < minSpeed) vertex.dy = (vertex.dy >= 0 ? minSpeed : -minSpeed); + if (std::fabs(vertex.dx) > maxSpeed) vertex.dx = (vertex.dx >= 0 ? maxSpeed : -maxSpeed); + if (std::fabs(vertex.dy) > maxSpeed) vertex.dy = (vertex.dy >= 0 ? maxSpeed : -maxSpeed); + } + + // Shift history + int newHead = polygon.historyHead + 1; + if (newHead >= TRAIL_LENGTH) { + newHead = 0; + polygon.historyFull = true; + } + polygon.historyHead = newHead; + + // Store current positions at head + for (int v = 0; v < NUM_VERTICES; v++) { + polygon.history[newHead][v].x = polygon.vertices[v].x; + polygon.history[newHead][v].y = polygon.vertices[v].y; + } + + // Pre-calculate history indices + int histIndices[TRAIL_LENGTH]; + for (int t = 0; t < TRAIL_LENGTH; t++) { + int idx = newHead - t; + histIndices[t] = (idx >= 0) ? idx : idx + TRAIL_LENGTH; + } + + // Calculate valid trail count + int validTrailCount = polygon.historyFull ? TRAIL_LENGTH : (newHead + 1); + + // Update line positions + for (int t = 0; t < TRAIL_LENGTH; t++) { + auto& trailLines = polygon.lines[t]; + + // Hide lines beyond valid history + if (t >= validTrailCount) { + for (int e = 0; e < NUM_VERTICES; e++) { + if (trailLines[e]) { + lv_obj_add_flag(trailLines[e], LV_OBJ_FLAG_HIDDEN); + } + } + continue; + } + + int histIndex = histIndices[t]; + auto& hist = polygon.history[histIndex]; + auto& linePointsTrail = polygon.linePoints[t]; + + for (int e = 0; e < NUM_VERTICES; e++) { + if (!trailLines[e]) continue; + + lv_obj_remove_flag(trailLines[e], LV_OBJ_FLAG_HIDDEN); + + int v2 = (e + 1) % NUM_VERTICES; + + linePointsTrail[e][0].x = hist[e].x; + linePointsTrail[e][0].y = hist[e].y; + linePointsTrail[e][1].x = hist[v2].x; + linePointsTrail[e][1].y = hist[v2].y; + + lv_line_set_points(trailLines[e], linePointsTrail[e].data(), 2); + } + } + } +} + +void MystifyScreensaver::initPolygon(Polygon& polygon, lv_obj_t* parent, lv_coord_t screenW, lv_coord_t screenH) { + // Sanity check - avoid undefined behavior from modulo by zero + if (screenW <= 0 || screenH <= 0) return; + + // Pick a random color from the pool + uint32_t colorHex = COLOR_POOL[rand() % COLOR_POOL.size()]; + polygon.color = lv_color_hex(colorHex); + polygon.historyHead = 0; + polygon.historyFull = false; + // Stagger color changes so polygons don't all change at once + polygon.colorChangeCounter = rand() % COLOR_CHANGE_INTERVAL; + + // Initialize vertices with random positions and velocities + for (int v = 0; v < NUM_VERTICES; v++) { + auto& vertex = polygon.vertices[v]; + vertex.x = static_cast(rand() % screenW); + vertex.y = static_cast(rand() % screenH); + + // Slower speeds (0.8-2.0) for smooth movement + vertex.dx = randomFloat(0.8f, 2.0f); + vertex.dy = randomFloat(0.8f, 2.0f); + if (rand() % 2) vertex.dx = -vertex.dx; + if (rand() % 2) vertex.dy = -vertex.dy; + + // Ensure dx != dy for more interesting movement patterns + if (std::fabs(std::fabs(vertex.dx) - std::fabs(vertex.dy)) < 0.3f) { + vertex.dy += (vertex.dy > 0 ? 0.5f : -0.5f); + } + } + + // Initialize history with current positions + for (int t = 0; t < TRAIL_LENGTH; t++) { + for (int v = 0; v < NUM_VERTICES; v++) { + polygon.history[t][v].x = polygon.vertices[v].x; + polygon.history[t][v].y = polygon.vertices[v].y; + } + } + + // Create line objects with pre-computed opacity values + for (int t = 0; t < TRAIL_LENGTH; t++) { + int opacity = LV_OPA_COVER - (t * LV_OPA_COVER / TRAIL_LENGTH); + if (opacity < LV_OPA_10) opacity = LV_OPA_10; + + for (int e = 0; e < NUM_VERTICES; e++) { + lv_obj_t* line = lv_line_create(parent); + if (line == nullptr) { + polygon.lines[t][e] = nullptr; + continue; + } + lv_obj_remove_style_all(line); + + lv_obj_set_style_line_color(line, polygon.color, 0); + lv_obj_set_style_line_opa(line, opacity, 0); + lv_obj_set_style_line_width(line, 2, 0); + + polygon.lines[t][e] = line; + } + } +} + +void MystifyScreensaver::updateLineColors(Polygon& polygon) { + // Update all line colors when color changes + for (int t = 0; t < TRAIL_LENGTH; t++) { + for (int e = 0; e < NUM_VERTICES; e++) { + if (polygon.lines[t][e]) { + lv_obj_set_style_line_color(polygon.lines[t][e], polygon.color, 0); + } + } + } +} + +} // namespace tt::service::displayidle + +#endif // ESP_PLATFORM diff --git a/Tactility/Source/service/displayidle/MystifyScreensaver.h b/Tactility/Source/service/displayidle/MystifyScreensaver.h new file mode 100644 index 00000000..7cdf6cd4 --- /dev/null +++ b/Tactility/Source/service/displayidle/MystifyScreensaver.h @@ -0,0 +1,72 @@ +#pragma once + +#ifdef ESP_PLATFORM + +#include "Screensaver.h" +#include +#include + +namespace tt::service::displayidle { + +class MystifyScreensaver final : public Screensaver { +public: + static constexpr int NUM_POLYGONS = 2; + static constexpr int NUM_VERTICES = 4; + static constexpr int TRAIL_LENGTH = 8; + static constexpr int COLOR_CHANGE_INTERVAL = 200; // Frames between color changes (~10s at 20fps) + + ~MystifyScreensaver() override = default; + MystifyScreensaver() = default; + MystifyScreensaver(const MystifyScreensaver&) = delete; + MystifyScreensaver& operator=(const MystifyScreensaver&) = delete; + MystifyScreensaver(MystifyScreensaver&&) = delete; + MystifyScreensaver& operator=(MystifyScreensaver&&) = delete; + + void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) override; + void stop() override; + void update(lv_coord_t screenW, lv_coord_t screenH) override; + +private: + // Use floats for smooth sub-pixel movement + struct Vertex { + float x = 0; + float y = 0; + float dx = 0; + float dy = 0; + }; + + struct Polygon { + std::array vertices; + // History: [trail_index][vertex_index] = {x, y} + std::array, TRAIL_LENGTH> history; + // Line objects: [trail_index][edge_index] + std::array, TRAIL_LENGTH> lines{}; + // Persistent point arrays for each line (required by lv_line_set_points) + std::array, NUM_VERTICES>, TRAIL_LENGTH> linePoints; + lv_color_t color = {}; + int historyHead = 0; + bool historyFull = false; + int colorChangeCounter = 0; + }; + + // Vibrant color pool for random selection + static constexpr std::array COLOR_POOL = { + 0xFF00FF, // Magenta + 0x00FFFF, // Cyan + 0xFFFF00, // Yellow + 0xFF6600, // Orange + 0x00FF66, // Spring green + 0x6600FF, // Purple + 0xFF0066, // Hot pink + 0x66FF00, // Lime + }; + + std::array polygons_; + + void initPolygon(Polygon& polygon, lv_obj_t* parent, lv_coord_t screenW, lv_coord_t screenH); + void updateLineColors(Polygon& polygon); +}; + +} // namespace tt::service::displayidle + +#endif // ESP_PLATFORM diff --git a/Tactility/Source/service/displayidle/Screensaver.h b/Tactility/Source/service/displayidle/Screensaver.h new file mode 100644 index 00000000..aefddcd2 --- /dev/null +++ b/Tactility/Source/service/displayidle/Screensaver.h @@ -0,0 +1,48 @@ +#pragma once +#ifdef ESP_PLATFORM + +#include + +namespace tt::service::displayidle { + +/** + * Base class for screensaver implementations. + * Screensavers are responsible for creating their visual elements + * on the provided overlay and updating them each tick. + */ +class Screensaver { +public: + Screensaver() = default; + virtual ~Screensaver() = default; + Screensaver(const Screensaver&) = delete; + Screensaver& operator=(const Screensaver&) = delete; + Screensaver(Screensaver&&) = delete; + Screensaver& operator=(Screensaver&&) = delete; + + /** + * Start the screensaver, creating visual elements on the overlay. + * @param overlay The parent LVGL object to create elements on + * @param screenW Screen width in pixels + * @param screenH Screen height in pixels + */ + virtual void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) = 0; + + /** + * Stop the screensaver, cleaning up any internal state. + * Note: LVGL objects are deleted by the parent overlay, but any + * internal references should be cleared here. + */ + virtual void stop() = 0; + + /** + * Update the screensaver animation for one frame. + * Called periodically while the screensaver is active. + * @param screenW Screen width in pixels + * @param screenH Screen height in pixels + */ + virtual void update(lv_coord_t screenW, lv_coord_t screenH) = 0; +}; + +} // namespace tt::service::displayidle + +#endif // ESP_PLATFORM diff --git a/Tactility/Source/service/keyboardidle/KeyboardIdle.cpp b/Tactility/Source/service/keyboardidle/KeyboardIdle.cpp index 62c5ea1d..ff13d281 100644 --- a/Tactility/Source/service/keyboardidle/KeyboardIdle.cpp +++ b/Tactility/Source/service/keyboardidle/KeyboardIdle.cpp @@ -32,7 +32,7 @@ class KeyboardIdleService final : public Service { // Query LVGL inactivity once for both checks uint32_t inactive_ms = 0; if (lvgl::lock(100)) { - inactive_ms = lv_disp_get_inactive_time(nullptr); + inactive_ms = lv_display_get_inactive_time(nullptr); lvgl::unlock(); } diff --git a/Tactility/Source/settings/DisplaySettings.cpp b/Tactility/Source/settings/DisplaySettings.cpp index 84e04705..e3fc89ea 100644 --- a/Tactility/Source/settings/DisplaySettings.cpp +++ b/Tactility/Source/settings/DisplaySettings.cpp @@ -16,6 +16,7 @@ constexpr auto* SETTINGS_KEY_GAMMA_CURVE = "gammaCurve"; constexpr auto* SETTINGS_KEY_BACKLIGHT_DUTY = "backlightDuty"; constexpr auto* SETTINGS_KEY_TIMEOUT_ENABLED = "backlightTimeoutEnabled"; constexpr auto* SETTINGS_KEY_TIMEOUT_MS = "backlightTimeoutMs"; +constexpr auto* SETTINGS_KEY_SCREENSAVER_TYPE = "screensaverType"; static Orientation getDefaultOrientation() { auto* display = lv_display_get_default(); @@ -64,6 +65,40 @@ static bool fromString(const std::string& str, Orientation& orientation) { } } +static std::string toString(ScreensaverType type) { + switch (type) { + using enum ScreensaverType; + case None: + return "None"; + case BouncingBalls: + return "BouncingBalls"; + case Mystify: + return "Mystify"; + case MatrixRain: + return "MatrixRain"; + default: + std::unreachable(); + } +} + +static bool fromString(const std::string& str, ScreensaverType& type) { + if (str == "None") { + type = ScreensaverType::None; + return true; + } else if (str == "BouncingBalls") { + type = ScreensaverType::BouncingBalls; + return true; + } else if (str == "Mystify") { + type = ScreensaverType::Mystify; + return true; + } else if (str == "MatrixRain") { + type = ScreensaverType::MatrixRain; + return true; + } else { + return false; + } +} + bool load(DisplaySettings& settings) { std::map map; if (!file::loadPropertiesFile(SETTINGS_FILE, map)) { @@ -103,11 +138,18 @@ bool load(DisplaySettings& settings) { timeout_ms = static_cast(std::strtoul(timeout_ms_entry->second.c_str(), nullptr, 10)); } + auto screensaver_entry = map.find(SETTINGS_KEY_SCREENSAVER_TYPE); + ScreensaverType screensaver_type = ScreensaverType::BouncingBalls; + if (screensaver_entry != map.end()) { + fromString(screensaver_entry->second, screensaver_type); + } + settings.orientation = orientation; settings.gammaCurve = gamma_curve; settings.backlightDuty = backlight_duty; settings.backlightTimeoutEnabled = timeout_enabled; settings.backlightTimeoutMs = timeout_ms; + settings.screensaverType = screensaver_type; return true; } @@ -118,7 +160,8 @@ DisplaySettings getDefault() { .gammaCurve = 1, .backlightDuty = 200, .backlightTimeoutEnabled = false, - .backlightTimeoutMs = 60000 + .backlightTimeoutMs = 60000, + .screensaverType = ScreensaverType::BouncingBalls }; } @@ -137,6 +180,7 @@ bool save(const DisplaySettings& settings) { map[SETTINGS_KEY_ORIENTATION] = toString(settings.orientation); map[SETTINGS_KEY_TIMEOUT_ENABLED] = settings.backlightTimeoutEnabled ? "1" : "0"; map[SETTINGS_KEY_TIMEOUT_MS] = std::to_string(settings.backlightTimeoutMs); + map[SETTINGS_KEY_SCREENSAVER_TYPE] = toString(settings.screensaverType); return file::savePropertiesFile(SETTINGS_FILE, map); }