From 204513432ea515781e8acb17fda8ca608d399e92 Mon Sep 17 00:00:00 2001 From: Noah Axon Date: Thu, 12 Nov 2026 21:12:37 -0600 Subject: [PATCH] Long-press esc on home screen for power menu (lock, reboot, shutdown) --- .../main/ui/esc_hold_hint_controller.cpp | 40 +- .../main/ui/esc_hold_hint_controller.h | 8 + projects/APPLaunch/main/ui/launch.cpp | 16 + projects/APPLaunch/main/ui/launch.h | 1 + .../main/ui/model/power_menu_model.cpp | 116 +++++ .../main/ui/model/power_menu_model.hpp | 69 +++ projects/APPLaunch/main/ui/ui.cpp | 5 + projects/APPLaunch/main/ui/ui_power_menu.cpp | 429 ++++++++++++++++++ projects/APPLaunch/main/ui/ui_power_menu.h | 21 + projects/APPLaunch/main/ui/ui_screensaver.cpp | 11 + projects/APPLaunch/main/ui/ui_screensaver.h | 2 + projects/APPLaunch/tests/run_tests.sh | 5 + .../APPLaunch/tests/test_power_menu_model.cpp | 72 +++ 13 files changed, 790 insertions(+), 5 deletions(-) create mode 100644 projects/APPLaunch/main/ui/model/power_menu_model.cpp create mode 100644 projects/APPLaunch/main/ui/model/power_menu_model.hpp create mode 100644 projects/APPLaunch/main/ui/ui_power_menu.cpp create mode 100644 projects/APPLaunch/main/ui/ui_power_menu.h create mode 100644 projects/APPLaunch/tests/test_power_menu_model.cpp diff --git a/projects/APPLaunch/main/ui/esc_hold_hint_controller.cpp b/projects/APPLaunch/main/ui/esc_hold_hint_controller.cpp index b84b30b1..7bb6b8b3 100644 --- a/projects/APPLaunch/main/ui/esc_hold_hint_controller.cpp +++ b/projects/APPLaunch/main/ui/esc_hold_hint_controller.cpp @@ -24,8 +24,10 @@ bool EscHoldHintController::handle(const key_item *item) return false; if (item->key_state == KBD_KEY_PRESSED) { - if (!return_home_enabled_) return true; + const bool home_hold = home_hold_available(); + if (!return_home_enabled_ && !home_hold) return true; if (!model_.press(lv_tick_get())) return true; + hold_is_home_ = home_hold; if (!poll_timer_) poll_timer_ = lv_timer_create(poll_timer_cb, kPollPeriodMs, this); if (poll_timer_) { @@ -66,6 +68,21 @@ void EscHoldHintController::set_force_home_callback(ForceHomeCallback callback, } } +void EscHoldHintController::set_home_hold_callback(HomeHoldCallback callback, void *user_data) +{ + home_hold_callback_ = callback; + home_hold_user_data_ = user_data; + if (!callback && hold_is_home_) { + if (poll_timer_) lv_timer_pause(poll_timer_); + if (model_.cancel()) launcher_toast().hide(); + } +} + +bool EscHoldHintController::home_hold_available() const +{ + return !return_home_enabled_ && home_hold_callback_ != nullptr; +} + void EscHoldHintController::shutdown() { if (poll_timer_) { @@ -75,18 +92,31 @@ void EscHoldHintController::shutdown() if (model_.cancel()) launcher_toast().hide(); force_home_callback_ = nullptr; force_home_user_data_ = nullptr; + home_hold_callback_ = nullptr; + home_hold_user_data_ = nullptr; } void EscHoldHintController::poll(lv_timer_t *timer) { if (!esc_hold_timer_is_current(timer, poll_timer_)) return; + /* A hold started on the home screen ends if an app takes over, and vice versa. */ + const bool hold_still_valid = hold_is_home_ ? home_hold_available() : return_home_enabled_; + const bool callback_available = + hold_is_home_ ? home_hold_callback_ != nullptr : force_home_callback_ != nullptr; const EscHoldPollDecision decision = model_.poll( - lv_tick_get(), cp0_esc_state_read() != 0, force_home_callback_ != nullptr, - return_home_enabled_); + lv_tick_get(), cp0_esc_state_read() != 0, callback_available, hold_still_valid); if (decision.hide_hint) launcher_toast().hide(); if (decision.show_hint) - launcher_toast().show_persistent("Hold ESC 3s to return home"); - if (decision.force_home) { + launcher_toast().show_persistent(hold_is_home_ ? "Hold ESC 3s for power menu" + : "Hold ESC 3s to return home"); + if (decision.force_home && hold_is_home_) { + HomeHoldCallback callback = home_hold_callback_; + void *user_data = home_hold_user_data_; + /* The menu takes over input and swallows this ESC's release, so end the + * hold here or the next press would be ignored. */ + if (model_.cancel()) launcher_toast().hide(); + if (callback) callback(user_data); + } else if (decision.force_home) { ForceHomeCallback callback = force_home_callback_; void *user_data = force_home_user_data_; if (callback) callback(user_data); diff --git a/projects/APPLaunch/main/ui/esc_hold_hint_controller.h b/projects/APPLaunch/main/ui/esc_hold_hint_controller.h index f4cd9562..8a73bde9 100644 --- a/projects/APPLaunch/main/ui/esc_hold_hint_controller.h +++ b/projects/APPLaunch/main/ui/esc_hold_hint_controller.h @@ -15,22 +15,30 @@ class EscHoldHintController { public: using ForceHomeCallback = void (*)(void *user_data); + using HomeHoldCallback = void (*)(void *user_data); bool handle(const key_item *item); void clear_hint_ownership(); void set_return_home_enabled(bool enabled); void set_force_home_callback(ForceHomeCallback callback, void *user_data); + /* Fired when ESC is held for the same 3s on the launcher home screen + * (i.e. while return-home is disabled). */ + void set_home_hold_callback(HomeHoldCallback callback, void *user_data); void shutdown(); private: void poll(lv_timer_t *timer); + bool home_hold_available() const; static void poll_timer_cb(lv_timer_t *timer) noexcept; lv_timer_t *poll_timer_ = nullptr; EscHoldLifecycleModel model_; ForceHomeCallback force_home_callback_ = nullptr; void *force_home_user_data_ = nullptr; + HomeHoldCallback home_hold_callback_ = nullptr; + void *home_hold_user_data_ = nullptr; bool return_home_enabled_ = false; + bool hold_is_home_ = false; }; EscHoldHintController &esc_hold_hint_controller(); diff --git a/projects/APPLaunch/main/ui/launch.cpp b/projects/APPLaunch/main/ui/launch.cpp index c12ca06d..1393f254 100644 --- a/projects/APPLaunch/main/ui/launch.cpp +++ b/projects/APPLaunch/main/ui/launch.cpp @@ -14,6 +14,7 @@ #include "generated/page_app.h" #include "ui_launch_page.h" #include "ui_screensaver.h" +#include "ui_power_menu.h" #include "ui_loading.h" #include "cp0_lvgl_app.h" #include "sample_log.h" @@ -49,6 +50,7 @@ void Launch::bind_ui() app_directory_watcher_.start([this] { applications_reload(); }); esc_ui_watchdog_.start(); esc_hold_hint_controller().set_force_home_callback(esc_force_home_cb, this); + esc_hold_hint_controller().set_home_hold_callback(esc_home_hold_cb, this); } void Launch::launch_app() @@ -274,11 +276,25 @@ void Launch::esc_force_home_cb(void *user_data) noexcept } } +void Launch::esc_home_hold_cb(void *user_data) noexcept +{ + auto *self = static_cast(user_data); + if (!self || self->app_Page || LVGL_RUN_FLAGE == 0) return; + if (self->page_lifecycle_.state() != LauncherPageState::HOME) return; + if (ui_screensaver_is_active()) return; + try { + SLOGI("[HOME] ESC held for 3000 ms on home, opening power menu"); + ui_power_menu::open(); + } catch (...) { + } +} + Launch::~Launch() { launcher_app_registry_clear_changed_callback(app_registry_changed_cb, this); esc_hold_hint_controller().set_force_home_callback(nullptr, nullptr); + esc_hold_hint_controller().set_home_hold_callback(nullptr, nullptr); esc_hold_hint_controller().set_return_home_enabled(false); esc_ui_watchdog_.shutdown(); app_directory_watcher_.stop(); diff --git a/projects/APPLaunch/main/ui/launch.h b/projects/APPLaunch/main/ui/launch.h index 1b7509b3..a90c0389 100644 --- a/projects/APPLaunch/main/ui/launch.h +++ b/projects/APPLaunch/main/ui/launch.h @@ -89,6 +89,7 @@ class Launch static void lv_go_back_home(void *arg) noexcept; static void esc_force_home_cb(void *user_data) noexcept; + static void esc_home_hold_cb(void *user_data) noexcept; static void app_registry_changed_cb(void *user_data) noexcept; std::weak_ptr launch_page_; diff --git a/projects/APPLaunch/main/ui/model/power_menu_model.cpp b/projects/APPLaunch/main/ui/model/power_menu_model.cpp new file mode 100644 index 00000000..3968edb3 --- /dev/null +++ b/projects/APPLaunch/main/ui/model/power_menu_model.cpp @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "power_menu_model.hpp" + +namespace { + +constexpr PowerMenuItem kItems[PowerMenuModel::kItemCount] = { + PowerMenuItem::Cancel, + PowerMenuItem::Lock, + PowerMenuItem::Reboot, + PowerMenuItem::Shutdown, +}; + +} // namespace + +const char *PowerMenuModel::label(PowerMenuItem item) +{ + switch (item) { + case PowerMenuItem::Lock: + return "Lock"; + case PowerMenuItem::Reboot: + return "Reboot"; + case PowerMenuItem::Shutdown: + return "Shutdown"; + case PowerMenuItem::Cancel: + default: + return "Cancel"; + } +} + +const char *PowerMenuModel::confirm_title(PowerMenuItem item) +{ + switch (item) { + case PowerMenuItem::Lock: + return "Lock the screen?"; + case PowerMenuItem::Reboot: + return "Reboot the system?"; + case PowerMenuItem::Shutdown: + return "Shut down the system?"; + case PowerMenuItem::Cancel: + default: + return ""; + } +} + +PowerMenuItem PowerMenuModel::item_at(size_t index) +{ + return index < kItemCount ? kItems[index] : PowerMenuItem::Cancel; +} + +void PowerMenuModel::open() +{ + stage_ = PowerMenuStage::Choosing; + selected_ = 0; // Cancel is the default. +} + +void PowerMenuModel::close() +{ + stage_ = PowerMenuStage::Closed; + selected_ = 0; +} + +PowerMenuDecision PowerMenuModel::handle(PowerMenuKey key) +{ + PowerMenuDecision decision; + switch (stage_) { + case PowerMenuStage::Choosing: + switch (key) { + case PowerMenuKey::Previous: + selected_ = (selected_ + kItemCount - 1) % kItemCount; + decision.redraw = decision.navigated = true; + break; + case PowerMenuKey::Next: + selected_ = (selected_ + 1) % kItemCount; + decision.redraw = decision.navigated = true; + break; + case PowerMenuKey::Enter: + if (selected() == PowerMenuItem::Cancel) { + close(); + decision.close = true; + } else { + stage_ = PowerMenuStage::Confirming; + decision.redraw = true; + } + break; + case PowerMenuKey::Back: + close(); + decision.close = true; + break; + case PowerMenuKey::Other: + default: + break; + } + break; + case PowerMenuStage::Confirming: + if (key == PowerMenuKey::Enter) { + decision.execute = true; + decision.action = selected(); + close(); + decision.close = true; + } else if (key == PowerMenuKey::Back) { + // Back to the list with the same entry still highlighted. + stage_ = PowerMenuStage::Choosing; + decision.redraw = true; + } + break; + case PowerMenuStage::Closed: + default: + break; + } + return decision; +} diff --git a/projects/APPLaunch/main/ui/model/power_menu_model.hpp b/projects/APPLaunch/main/ui/model/power_menu_model.hpp new file mode 100644 index 00000000..0266b15f --- /dev/null +++ b/projects/APPLaunch/main/ui/model/power_menu_model.hpp @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include +#include + +/* Home-screen power menu opened by holding ESC. Pure logic: no LVGL, so the + * navigation and confirmation rules are unit-tested in test_power_menu_model.cpp. */ + +enum class PowerMenuItem : uint8_t +{ + Cancel, + Lock, + Reboot, + Shutdown, +}; + +enum class PowerMenuStage : uint8_t +{ + Closed, + Choosing, + Confirming, +}; + +enum class PowerMenuKey : uint8_t +{ + Previous, + Next, + Enter, + Back, + Other, +}; + +struct PowerMenuDecision +{ + bool redraw = false; + bool close = false; + bool navigated = false; + bool execute = false; + PowerMenuItem action = PowerMenuItem::Cancel; +}; + +class PowerMenuModel +{ +public: + static constexpr size_t kItemCount = 4; + + static const char *label(PowerMenuItem item); + static const char *confirm_title(PowerMenuItem item); + static PowerMenuItem item_at(size_t index); + + void open(); + void close(); + PowerMenuDecision handle(PowerMenuKey key); + + PowerMenuStage stage() const { return stage_; } + bool is_open() const { return stage_ != PowerMenuStage::Closed; } + size_t selected_index() const { return selected_; } + PowerMenuItem selected() const { return item_at(selected_); } + +private: + PowerMenuStage stage_ = PowerMenuStage::Closed; + size_t selected_ = 0; +}; diff --git a/projects/APPLaunch/main/ui/ui.cpp b/projects/APPLaunch/main/ui/ui.cpp index e9020adc..65524a3b 100644 --- a/projects/APPLaunch/main/ui/ui.cpp +++ b/projects/APPLaunch/main/ui/ui.cpp @@ -15,6 +15,7 @@ #include "ui_global_hint.h" #include "ui_loading.h" #include "ui_screensaver.h" +#include "ui_power_menu.h" #include #include #include "lvgl/src/widgets/gif/lv_gif.h" @@ -40,6 +41,9 @@ void init() cp0_keyboard_set_global_key_handler(ui_global_hint::on_key); home = std::make_unique(); home->start(); + /* Before the low-battery warning, so its filter chains to this one and + * keeps priority over the menu. */ + ui_power_menu::init(); launcher_battery_ui::init_warning(); } @@ -48,6 +52,7 @@ void deinit() cp0_keyboard_set_global_key_handler(nullptr); ui_global_hint::shutdown(); launcher_battery_ui::shutdown_warning(); + ui_power_menu::shutdown(); ui_loading::shutdown(); ui_screensaver_deinit(); home.reset(); diff --git a/projects/APPLaunch/main/ui/ui_power_menu.cpp b/projects/APPLaunch/main/ui/ui_power_menu.cpp new file mode 100644 index 00000000..0902f238 --- /dev/null +++ b/projects/APPLaunch/main/ui/ui_power_menu.cpp @@ -0,0 +1,429 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "ui_power_menu.h" + +#include "ui.h" +#include "ui_screensaver.h" +#include "launcher_toast.h" +#include "input_keys.h" +#include "keyboard_input.h" +#include "model/power_menu_model.hpp" +#include "sample_log.h" + +#include + +namespace ui_power_menu { +namespace { + +constexpr lv_coord_t kPanelWidth = 200; +constexpr lv_coord_t kPanelHeight = 158; +constexpr lv_coord_t kPanelPad = 8; +constexpr lv_coord_t kRowWidth = kPanelWidth - 2 * kPanelPad; +constexpr lv_coord_t kRowHeight = 28; +constexpr lv_coord_t kRowTop = 24; +constexpr lv_coord_t kRowPitch = 30; +constexpr lv_coord_t kOkTop = 58; + +constexpr uint32_t kPanelColor = 0x171717; +constexpr uint32_t kPanelBorderColor = 0x3A3A3A; +constexpr uint32_t kSelectedColor = 0x2E6FD8; +constexpr uint32_t kTextColor = 0xFFFFFF; +constexpr uint32_t kDimTextColor = 0xB0B0B0; + +/* If the system is still running this long after a reboot/shutdown request + * (the simulator backends only log it), give the screen back. */ +constexpr uint32_t kPowerActionFallbackMs = 10000; + +constexpr const char *kFontFile = "AlibabaPuHuiTi-3-55-Regular.ttf"; + +PowerMenuModel model; +lv_obj_t *overlay = nullptr; +lv_obj_t *panel = nullptr; +lv_obj_t *title_label = nullptr; +lv_obj_t *rows[PowerMenuModel::kItemCount] = {}; +lv_obj_t *ok_row = nullptr; +lv_obj_t *hint_label = nullptr; +lv_timer_t *fallback_timer = nullptr; +PowerMenuItem pending_power_action = PowerMenuItem::Cancel; +bool halting = false; + +bool filter_installed = false; +cp0_keyboard_key_filter_t previous_key_filter = nullptr; +/* Keys pressed while the menu owned input; their repeats and releases are + * swallowed even after the menu closes, so e.g. the release of the Enter that + * chose Cancel cannot launch the selected home app. */ +std::set swallowed_keys; + +void play_switch_sound() noexcept +{ + try { + cp0_signal_audio_api({"SystemSoundPlay", "1"}, nullptr); + } catch (...) { + } +} + +void play_enter_sound() noexcept +{ + try { + cp0_signal_audio_api({"SystemSoundPlay", "2"}, nullptr); + } catch (...) { + } +} + +void forget_objects() +{ + overlay = nullptr; + panel = nullptr; + title_label = nullptr; + for (lv_obj_t *&row : rows) row = nullptr; + ok_row = nullptr; + hint_label = nullptr; +} + +void overlay_delete_cb(lv_event_t *event) noexcept +{ + try { + if (lv_event_get_target(event) != overlay) return; + } catch (...) { + } + forget_objects(); + model.close(); +} + +lv_obj_t *create_label(lv_obj_t *parent, uint16_t size, lv_freetype_font_style_t style) +{ + lv_obj_t *label = lv_label_create(parent); + if (!label) return nullptr; + lv_obj_set_style_text_color(label, lv_color_hex(kTextColor), 0); + lv_obj_set_style_text_font(label, launcher_fonts().get(kFontFile, size, style), 0); + return label; +} + +lv_obj_t *create_row(lv_obj_t *parent, lv_coord_t y, const char *text) +{ + lv_obj_t *row = lv_obj_create(parent); + if (!row) return nullptr; + lv_obj_remove_style_all(row); + lv_obj_set_size(row, kRowWidth, kRowHeight); + lv_obj_set_pos(row, 0, y); + lv_obj_set_style_radius(row, 6, 0); + lv_obj_set_style_bg_color(row, lv_color_hex(kSelectedColor), 0); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t *label = create_label(row, 14, LV_FREETYPE_FONT_STYLE_NORMAL); + if (label) { + lv_label_set_text(label, text); + lv_obj_center(label); + } + return row; +} + +void set_row_selected(lv_obj_t *row, bool selected) +{ + if (!row) return; + lv_obj_set_style_bg_opa(row, selected ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_t *label = lv_obj_get_child(row, 0); + if (label) + lv_obj_set_style_text_color(label, lv_color_hex(selected ? kTextColor : kDimTextColor), 0); +} + +bool ensure_created() +{ + if (overlay) return true; + lv_obj_t *parent = lv_layer_top(); + if (!parent) return false; + + overlay = lv_obj_create(parent); + if (!overlay) return false; + lv_obj_add_event_cb(overlay, overlay_delete_cb, LV_EVENT_DELETE, nullptr); + lv_obj_remove_style_all(overlay); + lv_obj_set_size(overlay, lv_pct(100), lv_pct(100)); + lv_obj_set_style_bg_color(overlay, lv_color_black(), 0); + lv_obj_set_style_bg_opa(overlay, LV_OPA_50, 0); + lv_obj_add_flag(overlay, LV_OBJ_FLAG_CLICKABLE); // block touches to the carousel + lv_obj_clear_flag(overlay, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(overlay, LV_OBJ_FLAG_IGNORE_LAYOUT); + + panel = lv_obj_create(overlay); + if (!panel) { + lv_obj_delete(overlay); + return false; + } + lv_obj_remove_style_all(panel); + lv_obj_set_size(panel, kPanelWidth, kPanelHeight); + lv_obj_center(panel); + lv_obj_set_style_bg_color(panel, lv_color_hex(kPanelColor), 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(panel, lv_color_hex(kPanelBorderColor), 0); + lv_obj_set_style_border_width(panel, 1, 0); + lv_obj_set_style_radius(panel, 8, 0); + lv_obj_set_style_pad_all(panel, kPanelPad, 0); + lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + + title_label = create_label(panel, 16, LV_FREETYPE_FONT_STYLE_BOLD); + if (title_label) { + lv_obj_set_width(title_label, kRowWidth); + lv_obj_set_style_text_align(title_label, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_long_mode(title_label, LV_LABEL_LONG_WRAP); + lv_obj_align(title_label, LV_ALIGN_TOP_MID, 0, 0); + } + + for (size_t i = 0; i < PowerMenuModel::kItemCount; ++i) + rows[i] = create_row(panel, kRowTop + static_cast(i) * kRowPitch, + PowerMenuModel::label(PowerMenuModel::item_at(i))); + + ok_row = create_row(panel, kOkTop, "OK"); + + hint_label = create_label(panel, 12, LV_FREETYPE_FONT_STYLE_NORMAL); + if (hint_label) { + lv_obj_set_style_text_color(hint_label, lv_color_hex(kDimTextColor), 0); + lv_label_set_text(hint_label, "ESC to go back"); + lv_obj_align(hint_label, LV_ALIGN_BOTTOM_MID, 0, 0); + } + + lv_obj_add_flag(overlay, LV_OBJ_FLAG_HIDDEN); + return true; +} + +void hide_overlay() +{ + if (overlay) lv_obj_add_flag(overlay, LV_OBJ_FLAG_HIDDEN); +} + +void render() +{ + if (!overlay) return; + const bool confirming = model.stage() == PowerMenuStage::Confirming; + if (title_label) + lv_label_set_text(title_label, confirming ? PowerMenuModel::confirm_title(model.selected()) : "Power"); + for (size_t i = 0; i < PowerMenuModel::kItemCount; ++i) { + if (!rows[i]) continue; + if (confirming) lv_obj_add_flag(rows[i], LV_OBJ_FLAG_HIDDEN); + else lv_obj_clear_flag(rows[i], LV_OBJ_FLAG_HIDDEN); + set_row_selected(rows[i], i == model.selected_index()); + } + if (ok_row) { + if (confirming) lv_obj_clear_flag(ok_row, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(ok_row, LV_OBJ_FLAG_HIDDEN); + set_row_selected(ok_row, true); + } + if (hint_label) { + if (confirming) lv_obj_clear_flag(hint_label, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(hint_label, LV_OBJ_FLAG_HIDDEN); + } +} + +/* Restores the normal dialog look after a blank/"Rebooting" screen. */ +void reset_overlay_look() +{ + if (!overlay) return; + lv_obj_set_style_bg_opa(overlay, LV_OPA_50, 0); + if (panel) lv_obj_clear_flag(panel, LV_OBJ_FLAG_HIDDEN); +} + +void fallback_timer_cb(lv_timer_t *timer) noexcept +{ + try { + if (timer != fallback_timer) return; + lv_timer_delete(fallback_timer); + fallback_timer = nullptr; + if (!halting) return; + halting = false; + hide_overlay(); + reset_overlay_look(); + SLOGW("[POWER] %s did not take effect", PowerMenuModel::label(pending_power_action)); + launcher_toast().show(pending_power_action == PowerMenuItem::Reboot ? "Reboot failed" + : "Shutdown failed"); + } catch (...) { + halting = false; + hide_overlay(); + } +} + +void start_fallback_timer() +{ + if (fallback_timer) lv_timer_delete(fallback_timer); + fallback_timer = lv_timer_create(fallback_timer_cb, kPowerActionFallbackMs, nullptr); +} + +void run_power_action(PowerMenuItem action) +{ + pending_power_action = action; + switch (action) { + case PowerMenuItem::Lock: + hide_overlay(); + SLOGI("[POWER] lock requested"); + ui_screensaver_lock_now(); + return; + case PowerMenuItem::Reboot: + if (!overlay) return; + halting = true; + lv_obj_set_style_bg_opa(overlay, LV_OPA_COVER, 0); + if (title_label) lv_label_set_text(title_label, "Rebooting..."); + for (lv_obj_t *row : rows) + if (row) lv_obj_add_flag(row, LV_OBJ_FLAG_HIDDEN); + if (ok_row) lv_obj_add_flag(ok_row, LV_OBJ_FLAG_HIDDEN); + if (hint_label) lv_obj_add_flag(hint_label, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(overlay, LV_OBJ_FLAG_HIDDEN); + lv_refr_now(nullptr); + SLOGI("[POWER] reboot requested"); + start_fallback_timer(); + cp0_system_reboot(); + return; + case PowerMenuItem::Shutdown: + if (!overlay) return; + halting = true; + /* Blank the whole display first so the panel goes dark cleanly. */ + lv_obj_set_style_bg_opa(overlay, LV_OPA_COVER, 0); + if (panel) lv_obj_add_flag(panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(overlay, LV_OBJ_FLAG_HIDDEN); + lv_refr_now(nullptr); + SLOGI("[POWER] shutdown requested"); + start_fallback_timer(); + cp0_system_shutdown(); + return; + case PowerMenuItem::Cancel: + default: + hide_overlay(); + return; + } +} + +PowerMenuKey menu_key_for(const key_item *item) +{ + const uint32_t code = item->semantic_key ? item->semantic_key : item->key_code; + switch (code) { + case KEY_UP: + case KEY_LEFT: + case KEY_F: + case KEY_Z: + return PowerMenuKey::Previous; + case KEY_DOWN: + case KEY_RIGHT: + case KEY_X: + case KEY_C: + return PowerMenuKey::Next; + case KEY_ENTER: + case KEY_KPENTER: + return PowerMenuKey::Enter; + case KEY_ESC: + return PowerMenuKey::Back; + default: + return PowerMenuKey::Other; + } +} + +void handle_menu_key(const key_item *item) +{ + const PowerMenuKey key = menu_key_for(item); + const bool repeated = item->key_state == KBD_KEY_REPEATED; + /* Only navigation auto-repeats; Enter/ESC act once per press. */ + if (repeated && key != PowerMenuKey::Previous && key != PowerMenuKey::Next) return; + + const PowerMenuDecision decision = model.handle(key); + if (decision.navigated) play_switch_sound(); + else if (key == PowerMenuKey::Enter && (decision.redraw || decision.close)) play_enter_sound(); + + if (decision.execute) { + run_power_action(decision.action); + return; + } + if (decision.close) { + hide_overlay(); + return; + } + if (decision.redraw) render(); +} + +int key_filter(const key_item *item) noexcept +{ + try { + if (!item) return 0; + const auto held = swallowed_keys.find(item->key_code); + if (held != swallowed_keys.end()) { + if (item->key_state == KBD_KEY_RELEASED) { + swallowed_keys.erase(held); + return 1; + } + if (item->key_state != KBD_KEY_PRESSED) { + if (!model.is_open()) return 1; + handle_menu_key(item); // navigation auto-repeat + return 1; + } + swallowed_keys.erase(held); // stale entry: its release went elsewhere + } + if (halting) return 1; // the blank/"Rebooting" screen owns every key + if (!model.is_open()) + return previous_key_filter ? previous_key_filter(item) : 0; + + if (item->key_state != KBD_KEY_RELEASED) swallowed_keys.insert(item->key_code); + if (item->key_state == KBD_KEY_PRESSED) handle_menu_key(item); + return 1; + } catch (...) { + model.close(); + hide_overlay(); + return 1; + } +} + +} // namespace + +void init() +{ + if (filter_installed) return; + previous_key_filter = cp0_keyboard_get_key_filter(); + cp0_keyboard_set_key_filter(key_filter); + filter_installed = true; +} + +void shutdown() +{ + if (filter_installed && cp0_keyboard_get_key_filter() == key_filter) + cp0_keyboard_set_key_filter(previous_key_filter); + filter_installed = false; + previous_key_filter = nullptr; + swallowed_keys.clear(); + if (fallback_timer) { + lv_timer_delete(fallback_timer); + fallback_timer = nullptr; + } + halting = false; + model.close(); + if (overlay) lv_obj_delete(overlay); + forget_objects(); +} + +bool open() +{ + try { + if (!filter_installed || halting || model.is_open()) return false; + if (!ensure_created()) return false; + model.open(); + reset_overlay_look(); + render(); + /* The ESC that opened the menu is still down: swallow its repeats and + * release so they neither close the menu nor reach the home screen. */ + swallowed_keys.insert(KEY_ESC); + lv_obj_move_foreground(overlay); + lv_obj_clear_flag(overlay, LV_OBJ_FLAG_HIDDEN); + play_enter_sound(); + SLOGI("[POWER] menu opened"); + return true; + } catch (...) { + model.close(); + hide_overlay(); + return false; + } +} + +bool is_open() +{ + return model.is_open() || halting; +} + +} // namespace ui_power_menu diff --git a/projects/APPLaunch/main/ui/ui_power_menu.h b/projects/APPLaunch/main/ui/ui_power_menu.h new file mode 100644 index 00000000..8275ff08 --- /dev/null +++ b/projects/APPLaunch/main/ui/ui_power_menu.h @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +/* Power menu shown over the launcher home screen after ESC is held for 3s: + * Cancel / Lock / Reboot / Shutdown, each action confirmed with OK. + * LVGL thread only. */ +namespace ui_power_menu { + +/* Installs the key filter that owns input while the menu is open. */ +void init(); +void shutdown(); + +bool open(); +bool is_open(); + +} // namespace ui_power_menu diff --git a/projects/APPLaunch/main/ui/ui_screensaver.cpp b/projects/APPLaunch/main/ui/ui_screensaver.cpp index 4676f496..ae414cfe 100644 --- a/projects/APPLaunch/main/ui/ui_screensaver.cpp +++ b/projects/APPLaunch/main/ui/ui_screensaver.cpp @@ -636,6 +636,17 @@ extern "C" int ui_screensaver_is_active(void) return s_model.active() || s_exiting; } +extern "C" void ui_screensaver_lock_now(void) +{ + try { + if (!s_timer || !s_model.foreground() || s_model.active() || s_exiting) + return; + enter_lockscreen(); + } catch (...) { + stop_screensaver(); + } +} + extern "C" void ui_screensaver_set_foreground(int foreground) { try { diff --git a/projects/APPLaunch/main/ui/ui_screensaver.h b/projects/APPLaunch/main/ui/ui_screensaver.h index 016c1040..d7c1f8ef 100644 --- a/projects/APPLaunch/main/ui/ui_screensaver.h +++ b/projects/APPLaunch/main/ui/ui_screensaver.h @@ -20,6 +20,8 @@ int ui_screensaver_filter_key(const struct key_item *item); /* Includes the exit animation, until the working screen is fully visible. */ int ui_screensaver_is_active(void); void ui_screensaver_set_foreground(int foreground); +/* Lock immediately (black screen, backlight down), as the idle timeout would. */ +void ui_screensaver_lock_now(void); #ifdef __cplusplus } diff --git a/projects/APPLaunch/tests/run_tests.sh b/projects/APPLaunch/tests/run_tests.sh index b98b75fc..eeab2aeb 100755 --- a/projects/APPLaunch/tests/run_tests.sh +++ b/projects/APPLaunch/tests/run_tests.sh @@ -206,6 +206,11 @@ ${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ "$test_root/../main/ui/model/esc_hold_lifecycle_model.cpp" \ -o "$build_dir/test_esc_hold_lifecycle_model" "$build_dir/test_esc_hold_lifecycle_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$test_root/test_power_menu_model.cpp" \ + "$test_root/../main/ui/model/power_menu_model.cpp" \ + -o "$build_dir/test_power_menu_model" +"$build_dir/test_power_menu_model" ${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ "$(dirname "$0")/test_esc_ui_watchdog.cpp" \ -o "$build_dir/test_esc_ui_watchdog" diff --git a/projects/APPLaunch/tests/test_power_menu_model.cpp b/projects/APPLaunch/tests/test_power_menu_model.cpp new file mode 100644 index 00000000..edfb5bd9 --- /dev/null +++ b/projects/APPLaunch/tests/test_power_menu_model.cpp @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "../main/ui/model/power_menu_model.hpp" + +#include +#include +#include + +int main() +{ + PowerMenuModel model; + assert(!model.is_open()); + assert(!model.handle(PowerMenuKey::Enter).execute); + + // Opens on Cancel; Enter on Cancel just closes. + model.open(); + assert(model.stage() == PowerMenuStage::Choosing); + assert(model.selected() == PowerMenuItem::Cancel); + auto decision = model.handle(PowerMenuKey::Enter); + assert(decision.close && !decision.execute && !model.is_open()); + + // ESC closes from the list. + model.open(); + decision = model.handle(PowerMenuKey::Back); + assert(decision.close && !decision.execute && !model.is_open()); + + // Navigation wraps in both directions. + model.open(); + decision = model.handle(PowerMenuKey::Previous); + assert(decision.navigated && model.selected() == PowerMenuItem::Shutdown); + decision = model.handle(PowerMenuKey::Next); + assert(model.selected() == PowerMenuItem::Cancel); + model.handle(PowerMenuKey::Next); + assert(model.selected() == PowerMenuItem::Lock); + model.handle(PowerMenuKey::Next); + assert(model.selected() == PowerMenuItem::Reboot); + model.handle(PowerMenuKey::Next); + assert(model.selected() == PowerMenuItem::Shutdown); + model.handle(PowerMenuKey::Next); + assert(model.selected() == PowerMenuItem::Cancel); + assert(!model.handle(PowerMenuKey::Other).redraw); + + // Every action needs an explicit OK; ESC from the confirmation returns to the list. + for (PowerMenuItem item : {PowerMenuItem::Lock, PowerMenuItem::Reboot, PowerMenuItem::Shutdown}) { + model.open(); + while (model.selected() != item) model.handle(PowerMenuKey::Next); + decision = model.handle(PowerMenuKey::Enter); + assert(!decision.execute && decision.redraw); + assert(model.stage() == PowerMenuStage::Confirming); + assert(!model.handle(PowerMenuKey::Next).redraw); + assert(model.selected() == item); + + decision = model.handle(PowerMenuKey::Back); + assert(!decision.close && !decision.execute); + assert(model.stage() == PowerMenuStage::Choosing && model.selected() == item); + + model.handle(PowerMenuKey::Enter); + decision = model.handle(PowerMenuKey::Enter); + assert(decision.execute && decision.close && decision.action == item); + assert(!model.is_open()); + assert(std::strlen(PowerMenuModel::confirm_title(item)) > 0); + } + + assert(std::strcmp(PowerMenuModel::label(PowerMenuItem::Cancel), "Cancel") == 0); + assert(std::strcmp(PowerMenuModel::label(PowerMenuItem::Shutdown), "Shutdown") == 0); + assert(PowerMenuModel::item_at(99) == PowerMenuItem::Cancel); + return 0; +}