From 4e23472ed53cddce5e2d22d92cbe601e9ff5fd9a Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 6 May 2026 11:14:12 -0600 Subject: [PATCH] UI: Controller connect/disconnect toasts --- extern/aurora | 2 +- res/rml/overlay.rcss | 31 ++++++++++- src/dusk/ui/input.cpp | 2 +- src/dusk/ui/input.hpp | 5 +- src/dusk/ui/overlay.cpp | 19 +++++-- src/dusk/ui/ui.cpp | 118 ++++++++++++++++++++++++++++++++++++++-- src/dusk/ui/ui.hpp | 3 + 7 files changed, 166 insertions(+), 14 deletions(-) diff --git a/extern/aurora b/extern/aurora index 4cd8d2f009..b78bbf3f58 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 4cd8d2f009f6e38ac96cdf1ea249d0c155af9fcb +Subproject commit b78bbf3f585a6ccce81366f9b0bc1681e366ae15 diff --git a/res/rml/overlay.rcss b/res/rml/overlay.rcss index e9c4644476..2cf8c8a8de 100644 --- a/res/rml/overlay.rcss +++ b/res/rml/overlay.rcss @@ -65,13 +65,35 @@ toast heading { color: #92875B; } -toast message { +toast heading > span { + flex: 1 0 auto; +} + +toast heading > row { + flex: 1 0 auto; display: flex; align-items: center; + gap: 4dp; +} + +toast message { + display: flex; + flex-flow: column; + align-items: start; justify-content: start; gap: 8dp; } +toast message row { + display: flex; + align-items: start; + justify-content: start; +} + +toast message row.muted { + opacity: 0.5; +} + toast progress { height: 4dp; position: absolute; @@ -113,6 +135,13 @@ icon.trophy { decorator: text("" center center); } +icon.controller { + width: 24dp; + height: 24dp; + font-size: 24dp; + decorator: text("" center center); +} + logo { position: absolute; width: 100dp; diff --git a/src/dusk/ui/input.cpp b/src/dusk/ui/input.cpp index d3722f6efa..cdaa2360c0 100644 --- a/src/dusk/ui/input.cpp +++ b/src/dusk/ui/input.cpp @@ -12,7 +12,7 @@ #include #include -namespace dusk::ui { +namespace dusk::ui::input { namespace { constexpr double kGamepadRepeatInitialDelay = 0.32; diff --git a/src/dusk/ui/input.hpp b/src/dusk/ui/input.hpp index b83c9ab3d2..a2ad859530 100644 --- a/src/dusk/ui/input.hpp +++ b/src/dusk/ui/input.hpp @@ -1,7 +1,10 @@ #pragma once -namespace dusk::ui { +union SDL_Event; +namespace dusk::ui::input { + +void handle_event(const SDL_Event& event) noexcept; void update_input() noexcept; void reset_input_state() noexcept; void sync_input_block() noexcept; diff --git a/src/dusk/ui/overlay.cpp b/src/dusk/ui/overlay.cpp index 109ea7e0a3..c26ff51481 100644 --- a/src/dusk/ui/overlay.cpp +++ b/src/dusk/ui/overlay.cpp @@ -38,18 +38,29 @@ Rml::Element* create_toast(Rml::Element* parent, const Toast& toast) { } { auto* heading = append(elem, "heading"); - auto* span = append(heading, "span"); - span->SetInnerRML(toast.title); + if (toast.title.starts_with("<")) { + heading->SetInnerRML(toast.title); + } else { + auto* span = append(heading, "span"); + span->SetInnerRML(toast.title); + } if (toast.type == "achievement") { auto* icon = append(heading, "icon"); icon->SetClass("trophy", true); mDoAud_seStartMenu(kSoundAchievementUnlock); + } else if (toast.type == "controller") { + auto* icon = append(heading, "icon"); + icon->SetClass("controller", true); } } { auto* message = append(elem, "message"); - auto* span = append(message, "span"); - span->SetInnerRML(toast.content); + if (toast.content.starts_with("<")) { + message->SetInnerRML(toast.content); + } else { + auto* span = append(message, "span"); + span->SetInnerRML(toast.content); + } } { auto* progress = append(elem, "progress"); diff --git a/src/dusk/ui/ui.cpp b/src/dusk/ui/ui.cpp index 367dd69086..7fb8f483d4 100644 --- a/src/dusk/ui/ui.cpp +++ b/src/dusk/ui/ui.cpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include #include @@ -26,6 +28,12 @@ std::vector > sDocumentStack; std::vector > sPassiveDocuments; std::deque sToasts; +// Sometimes gamepads can connect and disconnect quickly, especially during +// connection negotiation. In this case, we'll receive an _ADDED event for a +// disconnected gamepad. Storing IDs here lets use only show disconnected +// notifications for gamepads that we sent a connected notification for. +absl::flat_hash_set sConnectedGamepads; + } // namespace bool initialize() noexcept { @@ -51,11 +59,109 @@ bool initialize() noexcept { void shutdown() noexcept { sDocumentStack.clear(); sPassiveDocuments.clear(); - reset_input_state(); - release_input_block(); + sConnectedGamepads.clear(); + input::reset_input_state(); + input::release_input_block(); sInitialized = false; } +const char* battery_icon(SDL_PowerState state, int level) noexcept { + if (state == SDL_POWERSTATE_UNKNOWN || state == SDL_POWERSTATE_NO_BATTERY) { + return "e1a6"; // Battery Unknown + } + if (state == SDL_POWERSTATE_ERROR) { + return "f7ea"; // Battery Error + } + if (state == SDL_POWERSTATE_CHARGED || level == 100) { + return "e1a4"; // Battery Full + } + if (state == SDL_POWERSTATE_CHARGING) { + if (level >= 90) + return "f0a7"; // Battery Charging 90 + if (level >= 80) + return "f0a6"; // Battery Charging 80 + if (level >= 60) + return "f0a5"; // Battery Charging 60 + if (level >= 50) + return "f0a4"; // Battery Charging 50 + if (level >= 30) + return "f0a3"; // Battery Charging 30 + if (level >= 20) + return "f0a2"; // Battery Charging 20 + return "e1a3"; // Battery Charging Full (we use it as empty) + } + if (level >= 85) + return "ebd2"; // Battery 6 Bar + if (level >= 70) + return "ebd4"; // Battery 5 Bar + if (level >= 55) + return "ebe2"; // Battery 4 Bar + if (level >= 40) + return "ebdd"; // Battery 3 Bar + if (level >= 25) + return "ebe0"; // Battery 2 Bar + if (level >= 10) + return "ebd9"; // Battery 1 Bar + return "e19c"; // Battery Alert +} + +const char* connection_state_icon(SDL_JoystickConnectionState state) noexcept { + switch (state) { + case SDL_JOYSTICK_CONNECTION_WIRELESS: + return "e1a7"; + case SDL_JOYSTICK_CONNECTION_WIRED: + return "e1e0"; + default: + return nullptr; + } +} + +void handle_event(const SDL_Event& event) noexcept { + if (event.type == SDL_EVENT_GAMEPAD_ADDED) { + auto* gamepad = SDL_GetGamepadFromID(event.gdevice.which); + if (SDL_GamepadConnected(gamepad)) { + const char* name = SDL_GetGamepadName(gamepad); + Rml::String content = fmt::format("{}", name ? name : "[Unknown]"); + Rml::String title = "Controller connected"; + if (const char* icon = connection_state_icon(SDL_GetGamepadConnectionState(gamepad))) { + title = fmt::format( + "{} &#x{};", title, + icon); + } + int batteryLevel = -1; + const auto powerState = SDL_GetGamepadPowerInfo(gamepad, &batteryLevel); + if (powerState != SDL_POWERSTATE_UNKNOWN) { + content = fmt::format( + "{}&#x{};", + content, battery_icon(powerState, batteryLevel)); + if (batteryLevel > -1) { + content = fmt::format("{} {}%", content, batteryLevel); + } + content += ""; + } + push_toast({ + .type = "controller", + .title = title, + .content = content, + .duration = std::chrono::seconds(4), + }); + sConnectedGamepads.insert(event.gdevice.which); + } + } else if (event.type == SDL_EVENT_GAMEPAD_REMOVED && + sConnectedGamepads.contains(event.gdevice.which)) + { + const char* name = SDL_GetGamepadNameForID(event.gdevice.which); + push_toast({ + .type = "controller", + .title = "Controller disconnected", + .content = name ? name : "[Unknown]", + .duration = std::chrono::seconds(4), + }); + sConnectedGamepads.erase(event.gdevice.which); + } + input::handle_event(event); +} + Document& push_document(std::unique_ptr doc, bool show, bool passive) noexcept { Document& ret = *doc; if (passive) { @@ -66,7 +172,7 @@ Document& push_document(std::unique_ptr doc, bool show, bool passive) if (show) { ret.show(); } - sync_input_block(); + input::sync_input_block(); return ret; } @@ -74,7 +180,7 @@ void show_top_document() noexcept { if (auto* doc = top_document()) { doc->show(); } - sync_input_block(); + input::sync_input_block(); } bool any_document_visible() noexcept { @@ -99,7 +205,7 @@ Document* top_document() noexcept { } void update() noexcept { - update_input(); + input::update_input(); for (const auto& doc : sDocumentStack) { doc->update(); } @@ -131,7 +237,7 @@ void update() noexcept { } } - sync_input_block(); + input::sync_input_block(); } std::filesystem::path resource_path(const std::filesystem::path& filename) noexcept { diff --git a/src/dusk/ui/ui.hpp b/src/dusk/ui/ui.hpp index 7f59114ca1..7d63030b2f 100644 --- a/src/dusk/ui/ui.hpp +++ b/src/dusk/ui/ui.hpp @@ -85,4 +85,7 @@ Insets safe_area_insets(Rml::Context* context) noexcept; void push_toast(Toast toast) noexcept; std::deque& get_toasts() noexcept; +const char* battery_icon(SDL_PowerState state, int level) noexcept; +const char* connection_state_icon(SDL_JoystickConnectionState state) noexcept; + } // namespace dusk::ui