From ababcf70706cee25363f51a2eb4213e3c1acea00 Mon Sep 17 00:00:00 2001 From: Crazypedia Date: Sun, 5 Jul 2026 16:46:20 -0400 Subject: [PATCH 1/7] feat(display): confirm shutdown visually with a wait-for-flush hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DisplayDevice::waitForFlushComplete() (default no-op) so callers can block until a frame handed to the display has physically finished drawing, and use it in the Launcher's power-off handler to show a plain "Tactility OS / Powered off" screen before cutting power. Displays that draw synchronously within their flush callback are unaffected. Displays with an asynchronous refresh pipeline (e.g. e-paper, where a full refresh can take seconds) should override this so a caller doing something irreversible — like cutting power — can be sure the screen physically shows what was just drawn, rather than freezing mid-refresh. On e-paper this also doubles as a real signal that the device shut down cleanly rather than crashed or hung, since the panel keeps showing it after power cuts. Co-Authored-By: Claude Fable 5 --- .../Tactility/hal/display/DisplayDevice.h | 8 ++++ Tactility/Source/app/launcher/Launcher.cpp | 39 ++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/Tactility/Include/Tactility/hal/display/DisplayDevice.h b/Tactility/Include/Tactility/hal/display/DisplayDevice.h index 64c243c79..7918bfb7e 100644 --- a/Tactility/Include/Tactility/hal/display/DisplayDevice.h +++ b/Tactility/Include/Tactility/hal/display/DisplayDevice.h @@ -29,6 +29,14 @@ class DisplayDevice : public Device { /** For e-paper screens */ virtual void requestFullRefresh() {} + /** Blocks until any frame already handed to this display has physically + * finished drawing. Displays that draw synchronously within their flush + * callback can rely on the default no-op; displays with an asynchronous + * refresh pipeline (e.g. e-paper, where a full refresh can take seconds) + * should override this so callers can safely do something irreversible + * (like cutting power) right after a screen update. */ + virtual void waitForFlushComplete() {} + /** Could return nullptr if not started */ virtual std::shared_ptr getTouchDevice() = 0; diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index a5ee66906..bf758f056 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -84,11 +85,45 @@ class LauncherApp final : public App { start(appId); } + /** Replaces the screen with a plain "powered off" message, forces it to draw + * synchronously, and waits for the display to confirm the draw physically + * finished. On e-paper this is what's left showing once power cuts, so it + * doubles as visual confirmation that the device shut down cleanly rather + * than crashed or hung. */ + static void showPoweredOffScreenAndWait(hal::display::DisplayDevice* display) { + auto* screen = lv_obj_create(nullptr); + lv_obj_set_style_bg_color(screen, lv_color_white(), 0); + lv_obj_set_flex_flow(screen, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(screen, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + auto* title = lv_label_create(screen); + lv_label_set_text(title, "Tactility OS"); + lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_LARGE), 0); + + auto* subtitle = lv_label_create(screen); + lv_label_set_text(subtitle, "Powered off"); + + lv_screen_load(screen); + + if (display != nullptr) { + auto* lvgl_display = display->getLvglDisplay(); + if (lvgl_display != nullptr) { + lv_refr_now(lvgl_display); + } + display->waitForFlushComplete(); + } + } + static void onPowerOffPressed(lv_event_t* e) { auto power = hal::findFirstDevice(hal::Device::Type::Power); - if (power != nullptr && power->supportsPowerOff()) { - power->powerOff(); + if (power == nullptr || !power->supportsPowerOff()) { + return; } + + auto display = hal::findFirstDevice(hal::Device::Type::Display); + showPoweredOffScreenAndWait(display.get()); + + power->powerOff(); } // The screen object outlives the launcher's views (it's recreated by GuiService::redraw() From 291019ebbaa32eea572a17fc92bbc501762a0f28 Mon Sep 17 00:00:00 2001 From: Crazypedia Date: Sun, 5 Jul 2026 17:12:39 -0400 Subject: [PATCH 2/7] feat(launcher): confirm before powering off The power-off button was one accidental tap away from cutting power with no way back. Show an alert dialog ("Power off?" / Cancel) and only proceed to the shutdown screen + PowerDevice::powerOff() once the user picks "Power off". Co-Authored-By: Claude Fable 5 --- Tactility/Source/app/launcher/Launcher.cpp | 28 ++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index bf758f056..cd3aaa0e0 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +37,8 @@ static int32_t computeButtonMargin(int32_t available_span, int32_t total_button_ class LauncherApp final : public App { + LaunchId powerOffConfirmLaunchId = 0; + static lv_obj_t* createAppButton(lv_obj_t* parent, UiDensity uiDensity, const char* imageFile, const char* appId, int32_t itemMargin, bool isLandscape) { const auto button_size = lvgl_get_launcher_icon_font_height(); const auto button_padding = getButtonPadding(uiDensity, button_size); @@ -114,7 +117,7 @@ class LauncherApp final : public App { } } - static void onPowerOffPressed(lv_event_t* e) { + static void performPowerOff() { auto power = hal::findFirstDevice(hal::Device::Type::Power); if (power == nullptr || !power->supportsPowerOff()) { return; @@ -126,6 +129,17 @@ class LauncherApp final : public App { power->powerOff(); } + static void onPowerOffPressed(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + auto power = hal::findFirstDevice(hal::Device::Type::Power); + if (power == nullptr || !power->supportsPowerOff()) { + return; + } + + auto choices = std::vector { "Power off", "Cancel" }; + self->powerOffConfirmLaunchId = alertdialog::start("Power off?", "Are you sure you want to power off?", choices); + } + // The screen object outlives the launcher's views (it's recreated by GuiService::redraw() // via lv_obj_clean() on every app switch), so the LV_EVENT_SIZE_CHANGED callback registered // on it must be removed once buttons_wrapper is destroyed, to avoid a dangling user-data @@ -197,6 +211,16 @@ class LauncherApp final : public App { } } + void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr resultData) override { + if (launchId == powerOffConfirmLaunchId && + result == Result::Ok && + resultData != nullptr && + alertdialog::getResultIndex(*resultData) == 0 + ) { + performPowerOff(); + } + } + void onShow(AppContext& app, lv_obj_t* parent) override { auto* buttons_wrapper = lv_obj_create(parent); @@ -241,7 +265,7 @@ class LauncherApp final : public App { auto* power_button = lv_button_create(parent); lv_obj_set_style_pad_all(power_button, 8, 0); lv_obj_align(power_button, LV_ALIGN_BOTTOM_MID, 0, -10); - lv_obj_add_event_cb(power_button, onPowerOffPressed, LV_EVENT_SHORT_CLICKED, nullptr); + lv_obj_add_event_cb(power_button, onPowerOffPressed, LV_EVENT_SHORT_CLICKED, this); lv_obj_set_style_shadow_width(power_button, 0, LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(power_button, 0, LV_PART_MAIN); From 5e2062ad881f472c415e84411d2cf816144e57aa Mon Sep 17 00:00:00 2001 From: Crazypedia Date: Sun, 5 Jul 2026 21:57:41 -0400 Subject: [PATCH 3/7] fix(launcher): acquire the LVGL lock before drawing the shutdown screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit showPoweredOffScreenAndWait() is called from App::onResult, which runs on the loader dispatcher thread — not the LVGL thread. Every other piece of code in this project that touches LVGL objects from a non-LVGL context (e.g. DisplayIdleService's screensaver overlay) acquires the LVGL lock first; this call site didn't, so it was racing the LVGL render task. Confirmed on hardware: without the lock, the shutdown screen sometimes failed to render correctly before power cut. Co-Authored-By: Claude Fable 5 --- Tactility/Source/app/launcher/Launcher.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index cd3aaa0e0..edea02221 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -92,8 +93,16 @@ class LauncherApp final : public App { * synchronously, and waits for the display to confirm the draw physically * finished. On e-paper this is what's left showing once power cuts, so it * doubles as visual confirmation that the device shut down cleanly rather - * than crashed or hung. */ + * than crashed or hung. + * + * Called from App::onResult, which runs on the loader dispatcher thread, + * not the LVGL thread — every LVGL call here must happen under the LVGL + * lock, same as DisplayIdleService's screensaver overlay. */ static void showPoweredOffScreenAndWait(hal::display::DisplayDevice* display) { + if (!lvgl::lock(lvgl::defaultLockTime)) { + return; + } + auto* screen = lv_obj_create(nullptr); lv_obj_set_style_bg_color(screen, lv_color_white(), 0); lv_obj_set_flex_flow(screen, LV_FLEX_FLOW_COLUMN); @@ -115,6 +124,8 @@ class LauncherApp final : public App { } display->waitForFlushComplete(); } + + lvgl::unlock(); } static void performPowerOff() { From badf62c8fecb55040e0cfb95fc08d4b27b63e7e0 Mon Sep 17 00:00:00 2001 From: Crazypedia Date: Mon, 6 Jul 2026 09:34:31 -0400 Subject: [PATCH 4/7] feat(app): move power-off confirmation into its own fullscreen app Launcher was doing too much (Ken review, PR #555): the confirmation dialog, the 'powered off' shutdown screen, and the power-off call all lived in LauncherApp. Move that into a standalone PowerOff app (HideStatusBar + Hidden) with its own Yes/No buttons; Launcher now just starts it. As a side effect, the LVGL-lock workaround from the previous commit is no longer needed: the power-off flow now runs from an LVGL button click callback (already on the LVGL thread) instead of from App::onResult (loader dispatcher thread), so it can call LVGL functions directly. --- Tactility/Source/Tactility.cpp | 2 + Tactility/Source/app/launcher/Launcher.cpp | 79 +--------------- Tactility/Source/app/poweroff/PowerOff.cpp | 100 +++++++++++++++++++++ 3 files changed, 105 insertions(+), 76 deletions(-) create mode 100644 Tactility/Source/app/poweroff/PowerOff.cpp diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index 8fbb966c5..b79f49887 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -103,6 +103,7 @@ namespace app { namespace localesettings { extern const AppManifest manifest; } namespace notes { extern const AppManifest manifest; } namespace power { extern const AppManifest manifest; } + namespace poweroff { extern const AppManifest manifest; } namespace selectiondialog { extern const AppManifest manifest; } namespace settings { extern const AppManifest manifest; } namespace setup { extern const AppManifest manifest; } @@ -157,6 +158,7 @@ static void registerInternalApps() { addAppManifest(app::launcher::manifest); addAppManifest(app::localesettings::manifest); addAppManifest(app::notes::manifest); + addAppManifest(app::poweroff::manifest); addAppManifest(app::settings::manifest); addAppManifest(app::selectiondialog::manifest); addAppManifest(app::setup::manifest); diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index edea02221..61ddfc562 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -3,11 +3,8 @@ #include #include #include -#include #include -#include #include -#include #include #include @@ -38,8 +35,6 @@ static int32_t computeButtonMargin(int32_t available_span, int32_t total_button_ class LauncherApp final : public App { - LaunchId powerOffConfirmLaunchId = 0; - static lv_obj_t* createAppButton(lv_obj_t* parent, UiDensity uiDensity, const char* imageFile, const char* appId, int32_t itemMargin, bool isLandscape) { const auto button_size = lvgl_get_launcher_icon_font_height(); const auto button_padding = getButtonPadding(uiDensity, button_size); @@ -89,66 +84,8 @@ class LauncherApp final : public App { start(appId); } - /** Replaces the screen with a plain "powered off" message, forces it to draw - * synchronously, and waits for the display to confirm the draw physically - * finished. On e-paper this is what's left showing once power cuts, so it - * doubles as visual confirmation that the device shut down cleanly rather - * than crashed or hung. - * - * Called from App::onResult, which runs on the loader dispatcher thread, - * not the LVGL thread — every LVGL call here must happen under the LVGL - * lock, same as DisplayIdleService's screensaver overlay. */ - static void showPoweredOffScreenAndWait(hal::display::DisplayDevice* display) { - if (!lvgl::lock(lvgl::defaultLockTime)) { - return; - } - - auto* screen = lv_obj_create(nullptr); - lv_obj_set_style_bg_color(screen, lv_color_white(), 0); - lv_obj_set_flex_flow(screen, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(screen, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - - auto* title = lv_label_create(screen); - lv_label_set_text(title, "Tactility OS"); - lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_LARGE), 0); - - auto* subtitle = lv_label_create(screen); - lv_label_set_text(subtitle, "Powered off"); - - lv_screen_load(screen); - - if (display != nullptr) { - auto* lvgl_display = display->getLvglDisplay(); - if (lvgl_display != nullptr) { - lv_refr_now(lvgl_display); - } - display->waitForFlushComplete(); - } - - lvgl::unlock(); - } - - static void performPowerOff() { - auto power = hal::findFirstDevice(hal::Device::Type::Power); - if (power == nullptr || !power->supportsPowerOff()) { - return; - } - - auto display = hal::findFirstDevice(hal::Device::Type::Display); - showPoweredOffScreenAndWait(display.get()); - - power->powerOff(); - } - - static void onPowerOffPressed(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - auto power = hal::findFirstDevice(hal::Device::Type::Power); - if (power == nullptr || !power->supportsPowerOff()) { - return; - } - - auto choices = std::vector { "Power off", "Cancel" }; - self->powerOffConfirmLaunchId = alertdialog::start("Power off?", "Are you sure you want to power off?", choices); + static void onPowerOffPressed(lv_event_t* /*e*/) { + start("PowerOff"); } // The screen object outlives the launcher's views (it's recreated by GuiService::redraw() @@ -222,16 +159,6 @@ class LauncherApp final : public App { } } - void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr resultData) override { - if (launchId == powerOffConfirmLaunchId && - result == Result::Ok && - resultData != nullptr && - alertdialog::getResultIndex(*resultData) == 0 - ) { - performPowerOff(); - } - } - void onShow(AppContext& app, lv_obj_t* parent) override { auto* buttons_wrapper = lv_obj_create(parent); @@ -276,7 +203,7 @@ class LauncherApp final : public App { auto* power_button = lv_button_create(parent); lv_obj_set_style_pad_all(power_button, 8, 0); lv_obj_align(power_button, LV_ALIGN_BOTTOM_MID, 0, -10); - lv_obj_add_event_cb(power_button, onPowerOffPressed, LV_EVENT_SHORT_CLICKED, this); + lv_obj_add_event_cb(power_button, onPowerOffPressed, LV_EVENT_SHORT_CLICKED, nullptr); lv_obj_set_style_shadow_width(power_button, 0, LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(power_button, 0, LV_PART_MAIN); diff --git a/Tactility/Source/app/poweroff/PowerOff.cpp b/Tactility/Source/app/poweroff/PowerOff.cpp new file mode 100644 index 000000000..d60ce6cf0 --- /dev/null +++ b/Tactility/Source/app/poweroff/PowerOff.cpp @@ -0,0 +1,100 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace tt::app::poweroff { + +extern const AppManifest manifest; + +class PowerOffApp final : public App { + + /** Replaces the screen with a plain "powered off" message, forces it to draw + * synchronously, and waits for the display to confirm the draw physically + * finished. On e-paper this is what's left showing once power cuts, so it + * doubles as visual confirmation that the device shut down cleanly rather + * than crashed or hung. Called from an LVGL button click callback, so it's + * already running on the LVGL thread. */ + static void showPoweredOffScreenAndWait(hal::display::DisplayDevice* display) { + auto* screen = lv_obj_create(nullptr); + lv_obj_set_style_bg_color(screen, lv_color_white(), 0); + lv_obj_set_flex_flow(screen, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(screen, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + auto* title = lv_label_create(screen); + lv_label_set_text(title, "Tactility OS"); + lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_LARGE), 0); + + auto* subtitle = lv_label_create(screen); + lv_label_set_text(subtitle, "Powered off"); + + lv_screen_load(screen); + + if (display != nullptr) { + auto* lvgl_display = display->getLvglDisplay(); + if (lvgl_display != nullptr) { + lv_refr_now(lvgl_display); + } + display->waitForFlushComplete(); + } + } + + static void onYesPressed(lv_event_t* /*event*/) { + auto power = hal::findFirstDevice(hal::Device::Type::Power); + if (power == nullptr || !power->supportsPowerOff()) { + return; + } + + auto display = hal::findFirstDevice(hal::Device::Type::Display); + showPoweredOffScreenAndWait(display.get()); + + power->powerOff(); + } + + static void onNoPressed(lv_event_t* /*event*/) { + stop(manifest.appId); + } + +public: + + void onShow(AppContext& /*app*/, lv_obj_t* parent) override { + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(parent, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + auto* label = lv_label_create(parent); + lv_label_set_text(label, "Power off?"); + lv_obj_set_style_text_font(label, lvgl_get_text_font(FONT_SIZE_LARGE), 0); + + auto* button_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW); + lv_obj_set_size(button_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(button_wrapper, 0, 0); + lv_obj_set_style_border_width(button_wrapper, 0, 0); + lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + auto* yes_button = lv_button_create(button_wrapper); + auto* yes_label = lv_label_create(yes_button); + lv_label_set_text(yes_label, "Yes"); + lv_obj_add_event_cb(yes_button, onYesPressed, LV_EVENT_SHORT_CLICKED, nullptr); + + auto* no_button = lv_button_create(button_wrapper); + auto* no_label = lv_label_create(no_button); + lv_label_set_text(no_label, "No"); + lv_obj_add_event_cb(no_button, onNoPressed, LV_EVENT_SHORT_CLICKED, nullptr); + } +}; + +extern const AppManifest manifest = { + .appId = "PowerOff", + .appName = "Power Off", + .appCategory = Category::System, + .appFlags = AppManifest::Flags::HideStatusBar | AppManifest::Flags::Hidden, + .createApp = create +}; + +} // namespace From 0d54177424df479c74546bed3ef5c1d7dfc946a0 Mon Sep 17 00:00:00 2001 From: Crazypedia Date: Mon, 6 Jul 2026 10:46:30 -0400 Subject: [PATCH 5/7] fix(app): set dark text color on the powered-off screen CodeRabbit flagged this on the original PR: the screen forces a white background, but the title/subtitle labels had no explicit text color, so the default theme's (potentially light) text color could render unreadable on it. --- Tactility/Source/app/poweroff/PowerOff.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tactility/Source/app/poweroff/PowerOff.cpp b/Tactility/Source/app/poweroff/PowerOff.cpp index d60ce6cf0..4d56dd90d 100644 --- a/Tactility/Source/app/poweroff/PowerOff.cpp +++ b/Tactility/Source/app/poweroff/PowerOff.cpp @@ -29,9 +29,11 @@ class PowerOffApp final : public App { auto* title = lv_label_create(screen); lv_label_set_text(title, "Tactility OS"); lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_LARGE), 0); + lv_obj_set_style_text_color(title, lv_color_black(), 0); auto* subtitle = lv_label_create(screen); lv_label_set_text(subtitle, "Powered off"); + lv_obj_set_style_text_color(subtitle, lv_color_black(), 0); lv_screen_load(screen); From 1b1f64c3c4a6cd93ff2c6db93a51ebedfb52d122 Mon Sep 17 00:00:00 2001 From: Crazypedia Date: Mon, 6 Jul 2026 11:55:29 -0400 Subject: [PATCH 6/7] refactor(app): move PowerOff out of Launcher into Settings Per further discussion of Ken's PR feedback: the power button itself shouldn't live in the Launcher UI either, not just the power-off logic. Drop the power icon from Launcher entirely and list PowerOff as a regular Settings entry (Category::Settings, own icon), the same way the existing Power (battery info) app is reached. --- Tactility/Source/app/launcher/Launcher.cpp | 31 ---------------------- Tactility/Source/app/poweroff/PowerOff.cpp | 6 +++-- 2 files changed, 4 insertions(+), 33 deletions(-) diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index 61ddfc562..729376cd7 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include @@ -66,28 +65,11 @@ class LauncherApp final : public App { return apps_button; } - static bool shouldShowPowerButton() { - bool show_power_button = false; - hal::findDevices(hal::Device::Type::Power, [&show_power_button](const auto& device) { - if (device->supportsPowerOff()) { - show_power_button = true; - return false; // stop iterating - } else { - return true; // continue iterating - } - }); - return show_power_button; - } - static void onAppPressed(lv_event_t* e) { auto* appId = static_cast(lv_event_get_user_data(e)); start(appId); } - static void onPowerOffPressed(lv_event_t* /*e*/) { - start("PowerOff"); - } - // The screen object outlives the launcher's views (it's recreated by GuiService::redraw() // via lv_obj_clean() on every app switch), so the LV_EVENT_SIZE_CHANGED callback registered // on it must be removed once buttons_wrapper is destroyed, to avoid a dangling user-data @@ -198,19 +180,6 @@ class LauncherApp final : public App { // handler is attached there, with buttons_wrapper passed through as user data. lv_obj_add_event_cb(lv_obj_get_screen(parent), onButtonsWrapperResized, LV_EVENT_SIZE_CHANGED, buttons_wrapper); lv_obj_add_event_cb(buttons_wrapper, onButtonsWrapperDeleted, LV_EVENT_DELETE, nullptr); - - if (shouldShowPowerButton()) { - auto* power_button = lv_button_create(parent); - lv_obj_set_style_pad_all(power_button, 8, 0); - lv_obj_align(power_button, LV_ALIGN_BOTTOM_MID, 0, -10); - lv_obj_add_event_cb(power_button, onPowerOffPressed, LV_EVENT_SHORT_CLICKED, nullptr); - lv_obj_set_style_shadow_width(power_button, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(power_button, 0, LV_PART_MAIN); - - auto* power_label = lv_label_create(power_button); - lv_label_set_text(power_label, LV_SYMBOL_POWER); - lv_obj_set_style_text_color(power_label, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT); - } } }; diff --git a/Tactility/Source/app/poweroff/PowerOff.cpp b/Tactility/Source/app/poweroff/PowerOff.cpp index 4d56dd90d..04e8c3c4d 100644 --- a/Tactility/Source/app/poweroff/PowerOff.cpp +++ b/Tactility/Source/app/poweroff/PowerOff.cpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace tt::app::poweroff { @@ -94,8 +95,9 @@ class PowerOffApp final : public App { extern const AppManifest manifest = { .appId = "PowerOff", .appName = "Power Off", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::HideStatusBar | AppManifest::Flags::Hidden, + .appIcon = LVGL_ICON_SHARED_POWER_SETTINGS_NEW, + .appCategory = Category::Settings, + .appFlags = AppManifest::Flags::HideStatusBar, .createApp = create }; From 944689e98e7ac858d49c5e0de756bfb1da058e28 Mon Sep 17 00:00:00 2001 From: Crazypedia Date: Wed, 8 Jul 2026 21:39:28 -0400 Subject: [PATCH 7/7] fix(launcher): restore the power button as a link to the PowerOff app Per Ken's review: removing the launcher button breaks the T-Lora Pager and other devices where it's the only way to power off. Keep the button (still gated on supportsPowerOff) but have it start the PowerOff app, which owns the confirmation and shutdown-screen flow. Co-Authored-By: Claude Fable 5 --- Tactility/Source/app/launcher/Launcher.cpp | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index 729376cd7..ee510b033 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -70,6 +71,19 @@ class LauncherApp final : public App { start(appId); } + static bool shouldShowPowerButton() { + bool show_power_button = false; + hal::findDevices(hal::Device::Type::Power, [&show_power_button](const auto& device) { + if (device->supportsPowerOff()) { + show_power_button = true; + return false; // stop iterating + } else { + return true; // continue iterating + } + }); + return show_power_button; + } + // The screen object outlives the launcher's views (it's recreated by GuiService::redraw() // via lv_obj_clean() on every app switch), so the LV_EVENT_SIZE_CHANGED callback registered // on it must be removed once buttons_wrapper is destroyed, to avoid a dangling user-data @@ -180,6 +194,21 @@ class LauncherApp final : public App { // handler is attached there, with buttons_wrapper passed through as user data. lv_obj_add_event_cb(lv_obj_get_screen(parent), onButtonsWrapperResized, LV_EVENT_SIZE_CHANGED, buttons_wrapper); lv_obj_add_event_cb(buttons_wrapper, onButtonsWrapperDeleted, LV_EVENT_DELETE, nullptr); + + // Some devices (e.g. T-Lora Pager) have no other way to power off, so the + // button stays in the launcher; the confirmation flow lives in the PowerOff app. + if (shouldShowPowerButton()) { + auto* power_button = lv_button_create(parent); + lv_obj_set_style_pad_all(power_button, 8, 0); + lv_obj_align(power_button, LV_ALIGN_BOTTOM_MID, 0, -10); + lv_obj_add_event_cb(power_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)"PowerOff"); + lv_obj_set_style_shadow_width(power_button, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(power_button, 0, LV_PART_MAIN); + + auto* power_label = lv_label_create(power_button); + lv_label_set_text(power_label, LV_SYMBOL_POWER); + lv_obj_set_style_text_color(power_label, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT); + } } };