From 27a6a60bff2b46a284be167296308c5d8ed32f73 Mon Sep 17 00:00:00 2001 From: rashevskyv Date: Thu, 7 May 2026 21:03:23 +0300 Subject: [PATCH 01/24] Implement configurable hold duration for toggles and system actions --- Makefile | 2 +- README.md | 2 +- lang/en.json | 2 + lib/libultrahand | 2 +- source/main.cpp | 202 +++++++++++++++++++++++++++++++++++++++++++---- source/utils.hpp | 62 +++++++++++++++ 6 files changed, 252 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index e6015622..ed20caf7 100644 --- a/Makefile +++ b/Makefile @@ -57,7 +57,7 @@ include $(DEVKITPRO)/libnx/switch_rules #--------------------------------------------------------------------------------- APP_TITLE := Ultrahand APP_AUTHOR := ppkantorski -APP_VERSION := 2.4.2 +APP_VERSION := 2.4.4 TARGET := ovlmenu BUILD := build SOURCES := source common diff --git a/README.md b/README.md index 51b0ea52..d5804f52 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ For a fuller list, see [Ultrahand Overlays](https://github.com/ppkantorski#ultra A rich INI-based GUI scripting environment with: - **Launch integration** — assignable hotkey combos per package, hide/star state, and boot/exit package hooks (`boot_package.ini` / `exit_package.ini`) - **Overlay control** — launch overlays, execute package sections, navigate back, exit to menu -- **Dynamic UI** — toggles, sliders, dropdowns, tables (drawn directly or loaded from a text file), rich toast notifications (title, duration, alignment, icon), `set-footer`, and page/theme/wallpaper `refresh` +- **Dynamic UI** — toggles, `toggle_state` (sync initial state with filesystem/INI/hex), sliders, dropdowns, tables (drawn directly or loaded from a text file), rich toast notifications (title, duration, alignment, icon), `set-footer`, and page/theme/wallpaper `refresh` - **Status bar widget** — opt-in clock, temperature, and battery overlay widget - **Language translations** — package UI strings are automatically translated at render time based on the active system language - **Notifications** — `notify` / `notify-now` commands push inline toast messages from scripts; dropping a `.notify` JSON file to `/config/ultrahand/notifications/` queues a persistent API notification that displays until dismissed diff --git a/lang/en.json b/lang/en.json index 1de26e7c..443c2240 100644 --- a/lang/en.json +++ b/lang/en.json @@ -35,6 +35,8 @@ "FAVORITE": "Favorite", "MAIN_SETTINGS": "Main Settings", "UI_SETTINGS": "UI Settings", + "INPUT": "Input", + "HOLD_TIME": "Hold Time", "WIDGET": "Widget", "WIDGET_ITEMS": "Widget Items", "WIDGET_SETTINGS": "Widget Settings", diff --git a/lib/libultrahand b/lib/libultrahand index b6f83833..dd7c3867 160000 --- a/lib/libultrahand +++ b/lib/libultrahand @@ -1 +1 @@ -Subproject commit b6f83833365448ed8e109fbbd9bc7883a31b00c2 +Subproject commit dd7c38676ba3a438e74e891561abd828ba2208a7 diff --git a/source/main.cpp b/source/main.cpp index 4b1eb8a5..fd820889 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -185,6 +185,7 @@ static std::string lastCommandMode; static bool lastCommandIsHold; static bool lastFooterHighlight; static bool lastFooterHighlightDefined; +static bool lastToggleTargetState = false; static std::unordered_map selectedFooterDict; @@ -418,7 +419,7 @@ bool processHold(uint64_t keysDown, uint64_t keysHeld, u64& holdStartTick, bool& // Update hold progress const u64 elapsedMs = armTicksToNs(armGetSystemTick() - holdStartTick) / 1000000; - const int percentage = std::min(100, static_cast((elapsedMs * 100) / 3000)); + const int percentage = std::min(100, static_cast((elapsedMs * 100) / ult::holdDurationMs)); displayPercentage.store(percentage, std::memory_order_release); // Threshold-crossing rumble pulses — fired at ~33%, ~66%, ~100% of the hold. @@ -599,13 +600,20 @@ static void handleTriggerExit() { // Returns true when a hold is in progress so the caller can return early. [[gnu::noinline]] static bool handleCommandHold(uint64_t keysDown, uint64_t keysHeld, const std::string& cmdPath) { - bool isHolding = (lastCommandIsHold && runningInterpreter.load(std::memory_order_acquire)); + bool isHolding = lastCommandIsHold; if (!isHolding) return false; processHold(keysDown, keysHeld, holdStartTick, isHolding, [&cmdPath]() { displayPercentage.store(-1, std::memory_order_release); lastCommandIsHold = false; lastSelectedListItem->setValue(INPROGRESS_SYMBOL); triggerEnterFeedback(); + + if (lastCommandMode == TOGGLE_STR && lastSelectedListItem) { + static_cast(lastSelectedListItem)->setState(lastToggleTargetState); + const std::string configPath = cmdPath + "config.ini"; + setIniFileValue(configPath, lastKeyName, FOOTER_STR, lastToggleTargetState ? CAPITAL_ON_STR : CAPITAL_OFF_STR); + } + executeInterpreterCommands(std::move(storedCommands), cmdPath, lastKeyName); lastRunningInterpreter.store(true, std::memory_order_release); }, nullptr, true); @@ -1748,6 +1756,28 @@ class UltrahandSettingsMenu : public tsl::Gui { rightAlignmentState = useRightAlignment = getBoolValue("right_alignment"); // FALSE_STR default createToggleListItem(list, RIGHT_SIDE_MODE, useRightAlignment, "right_alignment"); + addHeader(list, INPUT); + std::vector holdLabels = {"0.5s", "1.0s", "1.5s", "2.0s", "2.5s", "3.0s", "3.5s", "4.0s", "4.5s", "5.0s"}; + auto* holdTrackbar = new tsl::elm::NamedStepTrackBarV2( + HOLD_TIME, + "", + holdLabels, + nullptr, nullptr, {}, "", + false, + false + ); + holdTrackbar->setSimpleCallback([this](s16 /*value*/, s16 index) { + u32 newVal = (index + 1) * 500; + ult::holdDurationMs = newVal; + setUltrahandConfig("hold_time", std::to_string(newVal)); + }); + u32 currentProgress = (ult::holdDurationMs / 500); + if (currentProgress > 0) currentProgress--; + if (currentProgress > 9) currentProgress = 9; + holdTrackbar->setProgress(static_cast(currentProgress)); + holdTrackbar->disableClickAnimation(); + list->addItem(holdTrackbar); + addHeader(list, MENU_SETTINGS); hidePackages = getBoolValue("hide_packages", false); // FALSE_STR default @@ -3021,6 +3051,12 @@ class SelectionOverlay : public tsl::Gui { bool isHold = false; bool isMini = false; + std::string toggleStateMode = ""; + std::string toggleStatePath = ""; + std::string toggleStateArg = ""; + std::string toggleStateArg2 = ""; + std::string toggleStateArg3 = ""; + size_t maxItemsLimit = 250; // 0 = uncapped, any other value = max size // Helper function to apply size limit to any vector @@ -3120,6 +3156,25 @@ class SelectionOverlay : public tsl::Gui { currentSection = ON_STR; else if (commandName == "off:") currentSection = OFF_STR; + else if (commandName == "toggle_state" && cmd.size() >= 2) { + toggleStateMode = cmd[1]; + if (cmd.size() >= 3) { + toggleStatePath = cmd[2]; + preprocessPath(toggleStatePath, filePath); + } + if (cmd.size() >= 4) { + toggleStateArg = cmd[3]; + removeQuotes(toggleStateArg); + } + if (cmd.size() >= 5) { + toggleStateArg2 = cmd[4]; + removeQuotes(toggleStateArg2); + } + if (cmd.size() >= 6) { + toggleStateArg3 = cmd[5]; + removeQuotes(toggleStateArg3); + } + } } if (cmd.size() > 1) { @@ -3725,8 +3780,32 @@ class SelectionOverlay : public tsl::Gui { toggleListItem->m_shortHoldKey = SCRIPT_KEY; // Use const iterators for better performance - const bool toggleStateOn = std::find(selectedItemsListOn.cbegin(), selectedItemsListOn.cend(), selectedItem) != selectedItemsListOn.cend(); + bool toggleStateOn = false; + if (!toggleStateMode.empty()) { + if (toggleStateMode == "file_exists") { + toggleStateOn = ult::isFileOrDirectory(toggleStatePath); + } else if (toggleStateMode == "has_line") { + toggleStateOn = isLineExistInIni(toggleStatePath, toggleStateArg); + } else if (toggleStateMode == "hex_check") { + uint32_t offset = 0; + if (isValidNumber(toggleStateArg)) { + offset = std::stoul(toggleStateArg, nullptr, 0); // Handles 0x prefix + } + toggleStateOn = checkHexValue(toggleStatePath, offset, toggleStateArg2); + } else if (toggleStateMode == "ini_val") { + // toggle_state ini_val
+ std::string currentVal = parseValueFromIniSection(toggleStatePath, toggleStateArg, toggleStateArg2); + removeQuotes(currentVal); + toggleStateOn = (currentVal == toggleStateArg3); + } + } else { + toggleStateOn = std::find(selectedItemsListOn.cbegin(), selectedItemsListOn.cend(), selectedItem) != selectedItemsListOn.cend(); + } toggleListItem->setState(toggleStateOn); + if (isHold) { + toggleListItem->disableClickAnimation(); + toggleListItem->enableTouchHolding(); + } toggleListItem->setStateChangedListener([this, i, toggleListItem, selectedItem, itemName](bool state) { if (runningInterpreter.load(std::memory_order_acquire)) { @@ -3798,6 +3877,21 @@ class SelectionOverlay : public tsl::Gui { if (usingProgress) toggleListItem->setValue(INPROGRESS_SYMBOL); + if (isHold && !lastCommandIsHold) { + runningInterpreter.store(true, std::memory_order_release); + toggleListItem->setState(!state); + runningInterpreter.store(false, std::memory_order_release); + + lastSelectedListItem = toggleListItem; + holdStartTick = armGetSystemTick(); + storedCommands = std::move(modifiedCmds); + lastCommandMode = commandMode; + lastCommandIsHold = true; + lastKeyName = specificKey; + lastToggleTargetState = state; + return; + } + nextToggleState = !state ? CAPITAL_OFF_STR : CAPITAL_ON_STR; runningInterpreter.store(true, release); lastRunningInterpreter.store(true, release); @@ -4336,6 +4430,12 @@ bool drawCommandsMenu( sourceTypeOn = DEFAULT_STR; sourceTypeOff = DEFAULT_STR; + std::string toggleStateMode = ""; + std::string toggleStatePath = ""; + std::string toggleStateArg = ""; + std::string toggleStateArg2 = ""; + std::string toggleStateArg3 = ""; + bool isSlot = false; @@ -4797,7 +4897,25 @@ bool drawCommandsMenu( else if (commandName.compare(0, 4, "off:") == 0) currentSection = OFF_STR; - if (currentSection == GLOBAL_STR) { + if (commandName == "toggle_state" && cmd.size() >= 2) { + toggleStateMode = cmd[1]; + if (cmd.size() >= 3) { + toggleStatePath = cmd[2]; + preprocessPath(toggleStatePath, packagePath); + } + if (cmd.size() >= 4) { + toggleStateArg = cmd[3]; + removeQuotes(toggleStateArg); + } + if (cmd.size() >= 5) { + toggleStateArg2 = cmd[4]; + removeQuotes(toggleStateArg2); + } + if (cmd.size() >= 6) { + toggleStateArg3 = cmd[5]; + removeQuotes(toggleStateArg3); + } + } else if (currentSection == GLOBAL_STR) { commandsOn.push_back(cmd); commandsOff.push_back(cmd); } else if (currentSection == ON_STR) { @@ -5419,12 +5537,25 @@ bool drawCommandsMenu( } else if (commandMode == TOGGLE_STR) { cleanOptionName = optionName; - auto* toggleListItem = new tsl::elm::ToggleListItem(cleanOptionName, false, ON, OFF, isMini, true); - toggleListItem->enableShortHoldKey(); - toggleListItem->m_shortHoldKey = SCRIPT_KEY; - // Set the initial state of the toggle item - if (!pathPatternOn.empty()){ + if (!toggleStateMode.empty()) { + if (toggleStateMode == "file_exists") { + toggleStateOn = isFileOrDirectory(toggleStatePath); + } else if (toggleStateMode == "has_line") { + toggleStateOn = isLineExistInIni(toggleStatePath, toggleStateArg); + } else if (toggleStateMode == "hex_check") { + uint32_t offset = 0; + if (isValidNumber(toggleStateArg)) { + offset = std::stoul(toggleStateArg, nullptr, 0); // Handles 0x prefix + } + toggleStateOn = checkHexValue(toggleStatePath, offset, toggleStateArg2); + } else if (toggleStateMode == "ini_val") { + // toggle_state ini_val
+ std::string currentVal = parseValueFromIniSection(toggleStatePath, toggleStateArg, toggleStateArg2); + removeQuotes(currentVal); + toggleStateOn = (currentVal == toggleStateArg3); + } + } else if (!pathPatternOn.empty()){ toggleStateOn = isFileOrDirectory(pathPatternOn); } else { @@ -5437,30 +5568,56 @@ bool drawCommandsMenu( toggleStateOn = (footer == CAPITAL_ON_STR); } - + + auto* toggleListItem = new tsl::elm::ToggleListItem(cleanOptionName, false, ON, OFF, isMini, true); + toggleListItem->enableShortHoldKey(); + toggleListItem->m_shortHoldKey = SCRIPT_KEY; toggleListItem->setState(toggleStateOn); + + if (isHold) { + toggleListItem->disableClickAnimation(); + toggleListItem->enableTouchHolding(); + } - toggleListItem->setStateChangedListener([i, usingProgress, toggleListItem, commandsOn, commandsOff, keyName = originalOptionName, packagePath, - pathPatternOn, pathPatternOff](bool state) { + toggleListItem->setStateChangedListener([i, usingProgress, toggleListItem, commandsOn, commandsOff, keyName = originalOptionName, packagePath, packageConfigIniPath, + pathPatternOn, pathPatternOff, isHold, commandMode](bool state) { if (runningInterpreter.load(std::memory_order_acquire)) { return; } - tsl::Overlay::get()->getCurrentGui()->requestFocus(toggleListItem, tsl::FocusDirection::None); + auto modifiedCmds = state ? getSourceReplacement(commandsOn, pathPatternOn, i, packagePath) : + getSourceReplacement(commandsOff, pathPatternOff, i, packagePath); + + if (isHold && !lastCommandIsHold) { + lastToggleTargetState = state; + runningInterpreter.store(true, std::memory_order_release); + toggleListItem->setState(!state); + runningInterpreter.store(false, std::memory_order_release); + + lastSelectedListItem = toggleListItem; + holdStartTick = armGetSystemTick(); + storedCommands = std::move(modifiedCmds); + lastCommandMode = commandMode; + lastCommandIsHold = true; + lastKeyName = keyName; + return; + } + if (usingProgress) toggleListItem->setValue(INPROGRESS_SYMBOL); - nextToggleState = !state ? CAPITAL_OFF_STR : CAPITAL_ON_STR; + + nextToggleState = state ? CAPITAL_ON_STR : CAPITAL_OFF_STR; + setIniFileValue(packageConfigIniPath, keyName, FOOTER_STR, nextToggleState); + lastKeyName = keyName; runningInterpreter.store(true, release); lastRunningInterpreter.store(true, release); lastSelectedListItem = toggleListItem; - executeInterpreterCommands(std::move(state ? getSourceReplacement(commandsOn, pathPatternOn, i, packagePath) : - getSourceReplacement(commandsOff, pathPatternOff, i, packagePath)), packagePath, keyName); - + executeInterpreterCommands(std::move(modifiedCmds), packagePath, keyName); }); // Set the script key listener (for SCRIPT_KEY) @@ -7209,6 +7366,7 @@ void initializeSettingsAndDirectories() { ensureDefault("extended_widget_backdrop", FALSE_STR); ensureDefault("datetime_format", DEFAULT_DT_FORMAT); ensureDefault(DEFAULT_LANG_STR, "en"); + ensureDefault("hold_time", "3000"); // Launcher-only keys (variables also set by parseOverlaySettings where accessible, // the rest are static to main.cpp so setDefaultValue is still needed here) @@ -7247,6 +7405,16 @@ void initializeSettingsAndDirectories() { if (needsUpdate) saveIniFileData(ULTRAHAND_CONFIG_INI_PATH, iniData); + const std::string holdTimeStr = parseValueFromIniSection(ULTRAHAND_CONFIG_INI_PATH, ULTRAHAND_PROJECT_NAME, "hold_time"); + if (!holdTimeStr.empty()) { + int parsed = std::atoi(holdTimeStr.c_str()); + if (parsed < 500) parsed = 500; + if (parsed > 10000) parsed = 10000; + ult::holdDurationMs = static_cast(parsed); + } else { + ult::holdDurationMs = 3000; + } + // Sync combo and set initial menu page (run once) updateMenuCombos = copyTeslaKeyComboToUltrahand(); diff --git a/source/utils.hpp b/source/utils.hpp index 3eb2a668..72455c3b 100644 --- a/source/utils.hpp +++ b/source/utils.hpp @@ -1766,6 +1766,68 @@ void drawDevImage(tsl::gfx::Renderer* renderer) { * directories. */ +/** + * @brief Checks if a specific line exists in an INI file. + * + * @param filePath The path to the INI file. + * @param line The line content to search for. + * @return true if the line exists, false otherwise. + */ +inline bool isLineExistInIni(const std::string& filePath, const std::string& line) { + if (!ult::isFile(filePath)) return false; + FILE* file = fopen(filePath.c_str(), "r"); + if (!file) return false; + + char buffer[1024]; + bool found = false; + while (fgets(buffer, sizeof(buffer), file)) { + if (strstr(buffer, line.c_str())) { + found = true; + break; + } + } + fclose(file); + return found; +} + +/** + * @brief Checks if a hex value matches at a specific offset. + * + * @param filePath The path to the file. + * @param offset The offset to check at. + * @param expectedHex The expected hex value (string of hex characters). + * @return true if the hex value matches, false otherwise. + */ +inline bool checkHexValue(const std::string& filePath, uint32_t offset, std::string expectedHex) { + if (!ult::isFile(filePath)) return false; + FILE* file = fopen(filePath.c_str(), "rb"); + if (!file) return false; + + fseek(file, offset, SEEK_SET); + + // Remove spaces from expectedHex + expectedHex.erase(std::remove(expectedHex.begin(), expectedHex.end(), ' '), expectedHex.end()); + if (expectedHex.length() % 2 != 0) { + fclose(file); + return false; + } + + size_t len = expectedHex.length() / 2; + std::vector buffer(len); + size_t readLen = fread(buffer.data(), 1, len, file); + fclose(file); + + if (readLen < len) return false; + + for (size_t i = 0; i < len; ++i) { + unsigned int byte; + sscanf(expectedHex.substr(i * 2, 2).c_str(), "%x", &byte); + if (buffer[i] != (unsigned char)byte) return false; + } + return true; +} + + bool isDangerousCombination(const std::string& originalPath) { // Early exit: Check for double wildcards first (cheapest check) if (originalPath.find("**") != std::string::npos) { From 7a1df88d70cdc1da4d2171e92905762004589f5b Mon Sep 17 00:00:00 2001 From: rashevskyv Date: Thu, 7 May 2026 21:10:40 +0300 Subject: [PATCH 02/24] Implement toggle_visibility directive for dynamic menu item visibility --- README.md | 2 +- source/main.cpp | 72 +++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d5804f52..13e8b889 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ For a fuller list, see [Ultrahand Overlays](https://github.com/ppkantorski#ultra A rich INI-based GUI scripting environment with: - **Launch integration** — assignable hotkey combos per package, hide/star state, and boot/exit package hooks (`boot_package.ini` / `exit_package.ini`) - **Overlay control** — launch overlays, execute package sections, navigate back, exit to menu -- **Dynamic UI** — toggles, `toggle_state` (sync initial state with filesystem/INI/hex), sliders, dropdowns, tables (drawn directly or loaded from a text file), rich toast notifications (title, duration, alignment, icon), `set-footer`, and page/theme/wallpaper `refresh` +- **Dynamic UI** — toggles, `toggle_state` (sync initial state with filesystem/INI/hex), `toggle_visibility` (dynamically hide menu items based on conditions), sliders, dropdowns, tables (drawn directly or loaded from a text file), rich toast notifications (title, duration, alignment, icon), `set-footer`, and page/theme/wallpaper `refresh` - **Status bar widget** — opt-in clock, temperature, and battery overlay widget - **Language translations** — package UI strings are automatically translated at render time based on the active system language - **Notifications** — `notify` / `notify-now` commands push inline toast messages from scripts; dropping a `.notify` JSON file to `/config/ultrahand/notifications/` queues a persistent API notification that displays until dismissed diff --git a/source/main.cpp b/source/main.cpp index fd820889..4ec01371 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -4436,6 +4436,12 @@ bool drawCommandsMenu( std::string toggleStateArg2 = ""; std::string toggleStateArg3 = ""; + std::string toggleVisibilityMode = ""; + std::string toggleVisibilityPath = ""; + std::string toggleVisibilityArg = ""; + std::string toggleVisibilityArg2 = ""; + std::string toggleVisibilityArg3 = ""; + bool isSlot = false; @@ -4692,6 +4698,27 @@ bool drawCommandsMenu( commandName = cmd[0]; + if (commandName == "toggle_visibility" && cmd.size() >= 2) { + toggleVisibilityMode = cmd[1]; + if (cmd.size() >= 3) { + toggleVisibilityPath = cmd[2]; + preprocessPath(toggleVisibilityPath, packagePath); + } + if (cmd.size() >= 4) { + toggleVisibilityArg = cmd[3]; + removeQuotes(toggleVisibilityArg); + } + if (cmd.size() >= 5) { + toggleVisibilityArg2 = cmd[4]; + removeQuotes(toggleVisibilityArg2); + } + if (cmd.size() >= 6) { + toggleVisibilityArg3 = cmd[5]; + removeQuotes(toggleVisibilityArg3); + } + continue; + } + // Quick check for section markers if (commandName.length() == 7) { if ((commandName[0] == 'e' || commandName[0] == 'E') && @@ -4915,6 +4942,24 @@ bool drawCommandsMenu( toggleStateArg3 = cmd[5]; removeQuotes(toggleStateArg3); } + } else if (commandName == "toggle_visibility" && cmd.size() >= 2) { + toggleVisibilityMode = cmd[1]; + if (cmd.size() >= 3) { + toggleVisibilityPath = cmd[2]; + preprocessPath(toggleVisibilityPath, packagePath); + } + if (cmd.size() >= 4) { + toggleVisibilityArg = cmd[3]; + removeQuotes(toggleVisibilityArg); + } + if (cmd.size() >= 5) { + toggleVisibilityArg2 = cmd[4]; + removeQuotes(toggleVisibilityArg2); + } + if (cmd.size() >= 6) { + toggleVisibilityArg3 = cmd[5]; + removeQuotes(toggleVisibilityArg3); + } } else if (currentSection == GLOBAL_STR) { commandsOn.push_back(cmd); commandsOff.push_back(cmd); @@ -5044,7 +5089,26 @@ bool drawCommandsMenu( skipSystem = true; } - if (!skipSection && !skipSystem) { // for skipping the drawing of sections + bool skipVisibility = false; + if (!toggleVisibilityMode.empty()) { + if (toggleVisibilityMode == "file_exists") { + skipVisibility = !isFileOrDirectory(toggleVisibilityPath); + } else if (toggleVisibilityMode == "has_line") { + skipVisibility = !isLineExistInIni(toggleVisibilityPath, toggleVisibilityArg); + } else if (toggleVisibilityMode == "hex_check") { + uint32_t offset = 0; + if (isValidNumber(toggleVisibilityArg)) { + offset = static_cast(std::strtoul(toggleVisibilityArg.c_str(), nullptr, 0)); + } + skipVisibility = !checkHexValue(toggleVisibilityPath, offset, toggleVisibilityArg2); + } else if (toggleVisibilityMode == "ini_val") { + std::string currentVal = parseValueFromIniSection(toggleVisibilityPath, toggleVisibilityArg, toggleVisibilityArg2); + removeQuotes(currentVal); + skipVisibility = (currentVal != toggleVisibilityArg3); + } + } + + if (!skipSection && !skipSystem && !skipVisibility) { // for skipping the drawing of sections if (commandMode == TABLE_STR) { if (useHeaderIndent) { tableColumnOffset = 164; @@ -7405,14 +7469,12 @@ void initializeSettingsAndDirectories() { if (needsUpdate) saveIniFileData(ULTRAHAND_CONFIG_INI_PATH, iniData); - const std::string holdTimeStr = parseValueFromIniSection(ULTRAHAND_CONFIG_INI_PATH, ULTRAHAND_PROJECT_NAME, "hold_time"); - if (!holdTimeStr.empty()) { + const std::string& holdTimeStr = sec["hold_time"]; + { int parsed = std::atoi(holdTimeStr.c_str()); if (parsed < 500) parsed = 500; if (parsed > 10000) parsed = 10000; ult::holdDurationMs = static_cast(parsed); - } else { - ult::holdDurationMs = 3000; } // Sync combo and set initial menu page (run once) From f46d4d0e5a9068cb482e40b003138dcba4679a2b Mon Sep 17 00:00:00 2001 From: rashevskyv Date: Fri, 8 May 2026 12:56:02 +0300 Subject: [PATCH 03/24] Fix hold progress bar not rendering on toggle items --- lib/libultrahand | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/libultrahand b/lib/libultrahand index dd7c3867..8fdea36e 160000 --- a/lib/libultrahand +++ b/lib/libultrahand @@ -1 +1 @@ -Subproject commit dd7c38676ba3a438e74e891561abd828ba2208a7 +Subproject commit 8fdea36ed927baa77cb842783d020a794b7966a2 From afc11b9901c81f1ebc7c445a060b5926e4c83837 Mon Sep 17 00:00:00 2001 From: rashevskyv Date: Fri, 8 May 2026 18:26:07 +0300 Subject: [PATCH 04/24] Fix toggle ON/OFF text disappearing after hold cancel --- source/main.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index 4ec01371..b2716250 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -5657,10 +5657,9 @@ bool drawCommandsMenu( if (isHold && !lastCommandIsHold) { lastToggleTargetState = state; - runningInterpreter.store(true, std::memory_order_release); toggleListItem->setState(!state); - runningInterpreter.store(false, std::memory_order_release); + lastSelectedListItemFooter = toggleListItem->getValue(); lastSelectedListItem = toggleListItem; holdStartTick = armGetSystemTick(); storedCommands = std::move(modifiedCmds); From 077e4bcdd8977f5a8fff2d6eb25e63f829702df2 Mon Sep 17 00:00:00 2001 From: rashevskyv Date: Fri, 8 May 2026 18:47:58 +0300 Subject: [PATCH 05/24] Skip config.ini read/write for toggles using toggle_state directive --- source/main.cpp | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index b2716250..d36c2df8 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -186,6 +186,7 @@ static bool lastCommandIsHold; static bool lastFooterHighlight; static bool lastFooterHighlightDefined; static bool lastToggleTargetState = false; +static bool lastToggleHasState = false; static std::unordered_map selectedFooterDict; @@ -549,7 +550,8 @@ static void handleInterpreterCompletion(const std::string& packageConfigIniPath) lastSelectedListItem->setValue(finalState); static_cast(lastSelectedListItem) ->setState(finalState == CAPITAL_ON_STR); - setIniFileValue(packageConfigIniPath, lastKeyName, FOOTER_STR, finalState); + if (!lastToggleHasState) + setIniFileValue(packageConfigIniPath, lastKeyName, FOOTER_STR, finalState); lastKeyName.clear(); nextToggleState.clear(); @@ -610,8 +612,10 @@ static bool handleCommandHold(uint64_t keysDown, uint64_t keysHeld, const std::s if (lastCommandMode == TOGGLE_STR && lastSelectedListItem) { static_cast(lastSelectedListItem)->setState(lastToggleTargetState); - const std::string configPath = cmdPath + "config.ini"; - setIniFileValue(configPath, lastKeyName, FOOTER_STR, lastToggleTargetState ? CAPITAL_ON_STR : CAPITAL_OFF_STR); + if (!lastToggleHasState) { + const std::string configPath = cmdPath + "config.ini"; + setIniFileValue(configPath, lastKeyName, FOOTER_STR, lastToggleTargetState ? CAPITAL_ON_STR : CAPITAL_OFF_STR); + } } executeInterpreterCommands(std::move(storedCommands), cmdPath, lastKeyName); @@ -5644,8 +5648,9 @@ bool drawCommandsMenu( toggleListItem->enableTouchHolding(); } + const bool hasToggleState = !toggleStateMode.empty(); toggleListItem->setStateChangedListener([i, usingProgress, toggleListItem, commandsOn, commandsOff, keyName = originalOptionName, packagePath, packageConfigIniPath, - pathPatternOn, pathPatternOff, isHold, commandMode](bool state) { + pathPatternOn, pathPatternOff, isHold, commandMode, hasToggleState](bool state) { if (runningInterpreter.load(std::memory_order_acquire)) { return; } @@ -5657,6 +5662,7 @@ bool drawCommandsMenu( if (isHold && !lastCommandIsHold) { lastToggleTargetState = state; + lastToggleHasState = hasToggleState; toggleListItem->setState(!state); lastSelectedListItemFooter = toggleListItem->getValue(); @@ -5673,7 +5679,9 @@ bool drawCommandsMenu( toggleListItem->setValue(INPROGRESS_SYMBOL); nextToggleState = state ? CAPITAL_ON_STR : CAPITAL_OFF_STR; - setIniFileValue(packageConfigIniPath, keyName, FOOTER_STR, nextToggleState); + lastToggleHasState = hasToggleState; + if (!hasToggleState) + setIniFileValue(packageConfigIniPath, keyName, FOOTER_STR, nextToggleState); lastKeyName = keyName; runningInterpreter.store(true, release); From 24e6d9124b4787404941f499baad1af27d7039d2 Mon Sep 17 00:00:00 2001 From: rashevskyv Date: Fri, 8 May 2026 18:52:23 +0300 Subject: [PATCH 06/24] Fix package.iniconfig.ini: MainMenu passed packageIniPath instead of PACKAGE_PATH to handleCommandHold --- source/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/main.cpp b/source/main.cpp index d36c2df8..edba35cd 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -7099,7 +7099,7 @@ class MainMenu : public tsl::Gui { */ virtual bool handleInput(uint64_t keysDown, uint64_t keysHeld, touchPosition touchInput, JoystickPosition leftJoyStick, JoystickPosition rightJoyStick) override { - if (handleCommandHold(keysDown, keysHeld, packageIniPath)) return true; + if (handleCommandHold(keysDown, keysHeld, PACKAGE_PATH)) return true; if (ult::launchingOverlay.load(acquire)) return true; From a43851ef405afd291ef51d1aa934d3651dfc02d4 Mon Sep 17 00:00:00 2001 From: rashevskyv Date: Fri, 8 May 2026 19:01:53 +0300 Subject: [PATCH 07/24] Skip config.ini read/write entirely for toggles with toggle_state --- source/main.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/source/main.cpp b/source/main.cpp index edba35cd..c13c335b 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -5003,6 +5003,8 @@ bool drawCommandsMenu( } + // Skip config.ini for toggles with toggle_state — external source is the single source of truth + if (toggleStateMode.empty()) { if (isFile(packageConfigIniPath)) { packageConfigData = getParsedDataFromIniFile(packageConfigIniPath); @@ -5054,6 +5056,8 @@ bool drawCommandsMenu( } packageConfigData.clear(); } + } // end toggleStateMode.empty() + // Get Option name and footer From b53d4eadaac70be338cecb30633ad6cf969660cf Mon Sep 17 00:00:00 2001 From: rashevskyv Date: Sat, 9 May 2026 14:20:07 +0300 Subject: [PATCH 08/24] Parse toggle_state and toggle_visibility independently of commandMode order --- source/main.cpp | 81 +++++++++++++++++++++++++++---------------------- 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index c13c335b..83e118cd 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -4922,49 +4922,56 @@ bool drawCommandsMenu( continue; } + // Parse toggle_state and toggle_visibility independently of commandMode + // so they work regardless of order relative to ;mode=toggle + if (commandName == "toggle_state" && cmd.size() >= 2) { + toggleStateMode = cmd[1]; + if (cmd.size() >= 3) { + toggleStatePath = cmd[2]; + preprocessPath(toggleStatePath, packagePath); + } + if (cmd.size() >= 4) { + toggleStateArg = cmd[3]; + removeQuotes(toggleStateArg); + } + if (cmd.size() >= 5) { + toggleStateArg2 = cmd[4]; + removeQuotes(toggleStateArg2); + } + if (cmd.size() >= 6) { + toggleStateArg3 = cmd[5]; + removeQuotes(toggleStateArg3); + } + continue; + } else if (commandName == "toggle_visibility" && cmd.size() >= 2) { + toggleVisibilityMode = cmd[1]; + if (cmd.size() >= 3) { + toggleVisibilityPath = cmd[2]; + preprocessPath(toggleVisibilityPath, packagePath); + } + if (cmd.size() >= 4) { + toggleVisibilityArg = cmd[3]; + removeQuotes(toggleVisibilityArg); + } + if (cmd.size() >= 5) { + toggleVisibilityArg2 = cmd[4]; + removeQuotes(toggleVisibilityArg2); + } + if (cmd.size() >= 6) { + toggleVisibilityArg3 = cmd[5]; + removeQuotes(toggleVisibilityArg3); + } + continue; + } + if (commandMode == TOGGLE_STR) { if (commandName.compare(0, 3, "on:") == 0) currentSection = ON_STR; else if (commandName.compare(0, 4, "off:") == 0) currentSection = OFF_STR; - if (commandName == "toggle_state" && cmd.size() >= 2) { - toggleStateMode = cmd[1]; - if (cmd.size() >= 3) { - toggleStatePath = cmd[2]; - preprocessPath(toggleStatePath, packagePath); - } - if (cmd.size() >= 4) { - toggleStateArg = cmd[3]; - removeQuotes(toggleStateArg); - } - if (cmd.size() >= 5) { - toggleStateArg2 = cmd[4]; - removeQuotes(toggleStateArg2); - } - if (cmd.size() >= 6) { - toggleStateArg3 = cmd[5]; - removeQuotes(toggleStateArg3); - } - } else if (commandName == "toggle_visibility" && cmd.size() >= 2) { - toggleVisibilityMode = cmd[1]; - if (cmd.size() >= 3) { - toggleVisibilityPath = cmd[2]; - preprocessPath(toggleVisibilityPath, packagePath); - } - if (cmd.size() >= 4) { - toggleVisibilityArg = cmd[3]; - removeQuotes(toggleVisibilityArg); - } - if (cmd.size() >= 5) { - toggleVisibilityArg2 = cmd[4]; - removeQuotes(toggleVisibilityArg2); - } - if (cmd.size() >= 6) { - toggleVisibilityArg3 = cmd[5]; - removeQuotes(toggleVisibilityArg3); - } - } else if (currentSection == GLOBAL_STR) { + if (currentSection == GLOBAL_STR) { + commandsOn.push_back(cmd); commandsOff.push_back(cmd); } else if (currentSection == ON_STR) { From 7afd801c7161b4b341275eeb893bf39f66eecf90 Mon Sep 17 00:00:00 2001 From: xHR Date: Sun, 10 May 2026 08:25:14 +0000 Subject: [PATCH 09/24] Inline warning-confirm: ;warning / ;warning_on / ;warning_off Phase 1 (overlay-only). Adds three INI-level directives that, when set on a list item, expand a multi-line warning banner + a hold-A Accept button directly under the item instead of executing immediately. Holding A on Accept to completion runs the original action through the existing handleCommandHold pipeline; pressing B (or activating another warning-armed item) collapses the expansion. Multi-line text uses single-line syntax with backslash-n escape sequences (e.g. ;warning=Line 1\nLine 2). Triple-backtick fence syntax is deferred to a Phase 2 libultrahand parser change. Toggle items support direction-specific texts via ;warning_on= / ;warning_off=; ;warning= alone applies to both directions. Toggle visual state and config.ini value are flipped only after Accept-hold completion, and reverted on cancel. Signed-off-by: Devin Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- source/main.cpp | 295 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 292 insertions(+), 3 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index 83e118cd..742fb4a1 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -98,6 +98,9 @@ constexpr std::string_view GROUPING_PATTERN = ";grouping="; constexpr std::string_view FOOTER_PATTERN = ";footer="; constexpr std::string_view FOOTER_HIGHLIGHT_PATTERN = ";footer_highlight="; constexpr std::string_view HOLD_PATTERN = ";hold="; +constexpr std::string_view WARNING_PATTERN = ";warning="; +constexpr std::string_view WARNING_ON_PATTERN = ";warning_on="; +constexpr std::string_view WARNING_OFF_PATTERN = ";warning_off="; constexpr std::string_view MINI_PATTERN = ";mini="; constexpr std::string_view SELECTION_MINI_PATTERN = ";selection_mini="; @@ -141,6 +144,9 @@ constexpr size_t GROUPING_PATTERN_LEN = GROUPING_PATTERN.size(); constexpr size_t FOOTER_PATTERN_LEN = FOOTER_PATTERN.size(); constexpr size_t FOOTER_HIGHLIGHT_PATTERN_LEN = FOOTER_HIGHLIGHT_PATTERN.size(); constexpr size_t HOLD_PATTERN_LEN = HOLD_PATTERN.size(); +constexpr size_t WARNING_PATTERN_LEN = WARNING_PATTERN.size(); +constexpr size_t WARNING_ON_PATTERN_LEN = WARNING_ON_PATTERN.size(); +constexpr size_t WARNING_OFF_PATTERN_LEN = WARNING_OFF_PATTERN.size(); constexpr size_t MINI_PATTERN_LEN = MINI_PATTERN.size(); constexpr size_t SELECTION_MINI_PATTERN_LEN = SELECTION_MINI_PATTERN.size(); constexpr size_t PROGRESS_PATTERN_LEN = PROGRESS_PATTERN.size(); @@ -361,6 +367,11 @@ bool handleRunningInterpreter(uint64_t& keysDown, uint64_t& keysHeld) { static u64 holdStartTick = 0; static std::string lastSelectedListItemFooter; static std::vector> storedCommands; +// Optional callback fired AFTER handleCommandHold runs the stored commands. +// Used by inline warning-confirm to apply toggle state changes that would +// otherwise depend on lastCommandMode == TOGGLE_STR + lastSelectedListItem +// being the toggle (which is no longer true once Accept becomes the hold target). +static std::function warningOnConfirmCallback; static bool holdRumbleFired[3] = {false, false, false}; // guards for the ~33%, ~66%, ~100% rumble pulses bool processHold(uint64_t keysDown, uint64_t keysHeld, u64& holdStartTick, bool& isHolding, @@ -406,6 +417,7 @@ bool processHold(uint64_t keysDown, uint64_t keysHeld, u64& holdStartTick, bool& lastCommandMode.clear(); lastCommandIsHold = false; lastKeyName.clear(); + warningOnConfirmCallback = nullptr; } if (onRelease) onRelease(); @@ -596,6 +608,13 @@ static void handleTriggerExit() { } } +// Forward-decls for the inline warning-confirm helpers; the namespace itself +// is defined further down (after the input-helper free functions). +namespace WarningConfirm { + bool isActive(); + void collapse(); +} + // The interpreter hold-and-launch pattern shared by SelectionOverlay, // PackageMenu, and MainMenu. The callers differ only in the path string // passed to executeInterpreterCommands; all other logic is identical. @@ -620,6 +639,12 @@ static bool handleCommandHold(uint64_t keysDown, uint64_t keysHeld, const std::s executeInterpreterCommands(std::move(storedCommands), cmdPath, lastKeyName); lastRunningInterpreter.store(true, std::memory_order_release); + if (warningOnConfirmCallback) { + auto cb = std::move(warningOnConfirmCallback); + warningOnConfirmCallback = nullptr; + cb(); + } + if (WarningConfirm::isActive()) WarningConfirm::collapse(); }, nullptr, true); return true; } @@ -665,6 +690,194 @@ static void setSystemSettingsReturn(const std::string& jumpName) { returnJumpItemName = jumpName; returnJumpItemValue.clear(); } +// ============================================================================= +// Inline warning/confirmation expansion +// ============================================================================= +// When a list item carries a `;warning=`, `;warning_on=` or `;warning_off=` +// directive, pressing A on it does NOT immediately execute the action. Instead +// the routines below splice a multi-line warning banner directly under the item +// plus a hold-A "Accept" list item. Only when the Accept hold completes does +// the original action run. Pressing B (or activating any other warning-armed +// item) collapses the expansion. +namespace WarningConfirm { + + // The banner element (CustomDrawer) and Accept item, both pending in the + // currently visible list. Non-null between expand() and collapse(). + inline tsl::elm::Element* g_banner = nullptr; + inline tsl::elm::ListItem* g_acceptItem = nullptr; + inline tsl::elm::List* g_list = nullptr; + inline tsl::elm::ListItem* g_sourceItem = nullptr; + + inline bool isActive() { return g_acceptItem != nullptr; } + + // Decode `\n` escape sequences in-place so package authors can write + // ;warning=Line 1\nLine 2 + // and get a real line break. + inline void unescapeWarningText(std::string& s) { + if (s.empty()) return; + std::string out; + out.reserve(s.size()); + for (size_t i = 0; i < s.size(); ++i) { + if (s[i] == '\\' && i + 1 < s.size()) { + const char nxt = s[i + 1]; + if (nxt == 'n') { out.push_back('\n'); ++i; continue; } + if (nxt == 't') { out.push_back('\t'); ++i; continue; } + if (nxt == '\\') { out.push_back('\\'); ++i; continue; } + } + out.push_back(s[i]); + } + s = std::move(out); + } + + // Cancel any in-flight hold targeting our Accept item, then queue removal + // of banner + Accept. Safe to call when nothing is active. + inline void collapse() { + if (lastSelectedListItem == g_acceptItem && g_acceptItem != nullptr) { + lastCommandIsHold = false; + displayPercentage.store(0, std::memory_order_release); + runningInterpreter.store(false, std::memory_order_release); + storedCommands.clear(); + lastCommandMode.clear(); + lastKeyName.clear(); + lastSelectedListItem = nullptr; + } + warningOnConfirmCallback = nullptr; + if (g_list != nullptr) { + if (g_banner) g_list->removeItem(g_banner); + if (g_acceptItem) g_list->removeItem(g_acceptItem); + } + g_banner = nullptr; + g_acceptItem = nullptr; + g_list = nullptr; + g_sourceItem = nullptr; + } + + // Insert the warning banner + Accept item right under `sourceItem` in + // `list`. The Accept item, when held to completion, runs `commandsToRun` + // through the existing handleCommandHold pipeline. + inline void expand(tsl::elm::List* list, + tsl::elm::ListItem* sourceItem, + const std::string& warningText, + std::vector> commandsToRun, + const std::string& packagePath, + const std::string& keyName, + std::function onConfirmExtra = nullptr) { + if (list == nullptr || sourceItem == nullptr || warningText.empty()) + return; + + // Collapse any other active warning first (single-active rule). + if (isActive()) collapse(); + + const s32 sourceIdx = list->getIndexInList(sourceItem); + if (sourceIdx < 0) return; + const ssize_t insertAt = sourceIdx + 1; + + // Banner: yellow accent bar on the left + warning glyph + multi-line + // text (split on '\n'). Width-wise, full list width minus padding; + // height computed from line count. + const int lineCount = 1 + static_cast( + std::count(warningText.begin(), warningText.end(), '\n')); + const s32 lineHeight = 22; + const s32 topPad = 8; + const s32 botPad = 8; + const u16 bannerH = static_cast(topPad + lineCount * lineHeight + botPad); + + std::string textCopy = warningText; + auto* banner = new tsl::elm::CustomDrawer( + [text = std::move(textCopy), lineHeight](tsl::gfx::Renderer* r, + s32 x, s32 y, s32 w, s32 h) { + // Yellow accent bar along the left edge. + const s32 accentX = x + 12; + const s32 accentW = 4; + r->drawRect(accentX, y + 4, accentW, h - 8, tsl::warningTextColor); + + // Warning glyph then text. Glyph is drawn once on the first line. + const s32 textX = accentX + accentW + 12; + const u32 fontSize = 17; + s32 cursor = y + 4 + lineHeight; + + // Glyph (Unicode warning sign). + r->drawString("\u26A0", false, textX, cursor, fontSize, + tsl::warningTextColor); + const s32 glyphAdvance = 24; + + // Walk text by '\n', drawing each line. First line is offset + // past the glyph; subsequent lines start at textX. + size_t start = 0; + bool firstLine = true; + while (start <= text.size()) { + size_t end = text.find('\n', start); + const std::string line = (end == std::string::npos) + ? text.substr(start) + : text.substr(start, end - start); + + const s32 lx = firstLine ? (textX + glyphAdvance) : textX; + r->drawString(line, false, lx, cursor, fontSize, + tsl::defaultTextColor); + cursor += lineHeight; + firstLine = false; + + if (end == std::string::npos) break; + start = end + 1; + } + }); + + list->addItem(banner, bannerH, insertAt); + + // Accept item: regular ListItem with hold-A behaviour. We piggy-back + // on the existing global lastCommandIsHold + handleCommandHold pipeline + // so the user gets the same progress bar / rumble feedback as a + // ;hold=true item. + // TODO: localise via libultrahand string table once a Phase-2 PR adds it. + auto* acceptItem = new tsl::elm::ListItem("Hold A to confirm"); + acceptItem->enableTouchHolding(); + acceptItem->setValue(HOLD_A_SYMBOL, true); + acceptItem->disableClickAnimation(); + + std::vector> capturedCmds = std::move(commandsToRun); + const std::string capturedPkgPath = packagePath; + const std::string capturedKeyName = keyName; + std::function capturedOnConfirm = std::move(onConfirmExtra); + + acceptItem->setClickListener( + [acceptItem, + cmds = std::move(capturedCmds), + pkgPath = capturedPkgPath, + keyName = capturedKeyName, + onConfirm = std::move(capturedOnConfirm)](uint64_t keys) mutable -> bool { + if (runningInterpreter.load(std::memory_order_acquire)) + return false; + + if ((keys & KEY_A) && !(keys & ~KEY_A & ALL_KEYS_MASK)) { + if (lastCommandIsHold) return true; // already counting + + lastSelectedListItemFooter = acceptItem->getValue(); + lastFooterHighlight = false; + lastFooterHighlightDefined = false; + acceptItem->setValue(INPROGRESS_SYMBOL); + lastSelectedListItem = acceptItem; + + holdStartTick = armGetSystemTick(); + storedCommands = cmds; // copy; allows re-hold + lastCommandMode = DEFAULT_STR; + lastCommandIsHold = true; + lastKeyName = keyName; + warningOnConfirmCallback = onConfirm; // copy; persists across re-holds + return true; + } + return false; + }); + + list->addItem(acceptItem, 0, insertAt + 1); + + g_banner = banner; + g_acceptItem = acceptItem; + g_list = list; + g_sourceItem = sourceItem; + } + +} // namespace WarningConfirm + // Forward declaration of the MainMenu class. class MainMenu; @@ -4306,6 +4519,9 @@ bool drawCommandsMenu( bool commandFooterHighlight; bool commandFooterHighlightDefined; bool isHold; + std::string warningText; + std::string warningOnText; + std::string warningOffText; std::string commandSystem; std::string commandState; @@ -4421,6 +4637,9 @@ bool drawCommandsMenu( commandFooterHighlight = false; commandFooterHighlightDefined = false; isHold = false; + warningText.clear(); + warningOnText.clear(); + warningOffText.clear(); commandSystem = DEFAULT_STR; commandState = DEFAULT_STR; commandHOSFirmware = ""; @@ -4900,6 +5119,31 @@ bool drawCommandsMenu( break; case 'w': + // Warning patterns must be checked _OFF / _ON before plain to avoid prefix collision. + if (commandName.size() >= WARNING_OFF_PATTERN_LEN && + commandName.compare(0, WARNING_OFF_PATTERN_LEN, WARNING_OFF_PATTERN) == 0) { + warningOffText = commandName.substr(WARNING_OFF_PATTERN_LEN); + for (size_t j = 1; j < cmd.size(); ++j) warningOffText += " " + cmd[j]; + removeQuotes(warningOffText); + WarningConfirm::unescapeWarningText(warningOffText); + continue; + } + if (commandName.size() >= WARNING_ON_PATTERN_LEN && + commandName.compare(0, WARNING_ON_PATTERN_LEN, WARNING_ON_PATTERN) == 0) { + warningOnText = commandName.substr(WARNING_ON_PATTERN_LEN); + for (size_t j = 1; j < cmd.size(); ++j) warningOnText += " " + cmd[j]; + removeQuotes(warningOnText); + WarningConfirm::unescapeWarningText(warningOnText); + continue; + } + if (commandName.size() >= WARNING_PATTERN_LEN && + commandName.compare(0, WARNING_PATTERN_LEN, WARNING_PATTERN) == 0) { + warningText = commandName.substr(WARNING_PATTERN_LEN); + for (size_t j = 1; j < cmd.size(); ++j) warningText += " " + cmd[j]; + removeQuotes(warningText); + WarningConfirm::unescapeWarningText(warningText); + continue; + } if (commandName.compare(0, WRAPPING_MODE_PATTERN_LEN, WRAPPING_MODE_PATTERN) == 0) { tableWrappingMode = commandName.substr(WRAPPING_MODE_PATTERN_LEN); continue; @@ -5552,7 +5796,7 @@ bool drawCommandsMenu( } listItem->setClickListener([i, commands, keyName = originalOptionName, cleanOptionName, packagePath, packageName, - selectedItem, listItem, lastPackageHeader, commandMode, footer, isHold, showWidget, commandFooterHighlight, commandFooterHighlightDefined](uint64_t keys) { + selectedItem, listItem, list, warningText, lastPackageHeader, commandMode, footer, isHold, showWidget, commandFooterHighlight, commandFooterHighlightDefined](uint64_t keys) { if (runningInterpreter.load(acquire)) { return false; @@ -5563,6 +5807,11 @@ bool drawCommandsMenu( } if (((keys & KEY_A && !(keys & ~KEY_A & ALL_KEYS_MASK)))) { + if (!warningText.empty()) { + auto warnCmds = getSourceReplacement(commands, selectedItem, i, packagePath); + WarningConfirm::expand(list, listItem, warningText, std::move(warnCmds), packagePath, keyName); + return true; + } isDownloadCommand.store(false, release); runningInterpreter.store(true, release); @@ -5661,13 +5910,47 @@ bool drawCommandsMenu( const bool hasToggleState = !toggleStateMode.empty(); toggleListItem->setStateChangedListener([i, usingProgress, toggleListItem, commandsOn, commandsOff, keyName = originalOptionName, packagePath, packageConfigIniPath, - pathPatternOn, pathPatternOff, isHold, commandMode, hasToggleState](bool state) { + pathPatternOn, pathPatternOff, isHold, commandMode, hasToggleState, + list, warningText, warningOnText, warningOffText](bool state) { if (runningInterpreter.load(std::memory_order_acquire)) { return; } tsl::Overlay::get()->getCurrentGui()->requestFocus(toggleListItem, tsl::FocusDirection::None); + // Inline warning-confirm: if the package author set a warning, expand a banner+Accept + // pair underneath this toggle and defer execution. Direction-specific texts + // (warning_on / warning_off) win when set; otherwise fall back to plain warning. + const std::string& directionalWarning = + state ? (!warningOnText.empty() ? warningOnText : warningText) + : (!warningOffText.empty() ? warningOffText : warningText); + if (!directionalWarning.empty()) { + // Revert visual; Accept-hold completion will flip it back. + toggleListItem->setState(!state); + + auto warnCmds = state ? getSourceReplacement(commandsOn, pathPatternOn, i, packagePath) : + getSourceReplacement(commandsOff, pathPatternOff, i, packagePath); + + const bool targetState = state; + const bool noConfigPath = hasToggleState; + std::string capturedConfigPath = packageConfigIniPath; + std::string capturedKeyName = keyName; + auto* capturedToggle = toggleListItem; + + WarningConfirm::expand( + list, toggleListItem, directionalWarning, + std::move(warnCmds), packagePath, keyName, + [capturedToggle, targetState, noConfigPath, + capturedConfigPath, capturedKeyName]() { + capturedToggle->setState(targetState); + if (!noConfigPath) { + setIniFileValue(capturedConfigPath, capturedKeyName, FOOTER_STR, + targetState ? CAPITAL_ON_STR : CAPITAL_OFF_STR); + } + }); + return; + } + auto modifiedCmds = state ? getSourceReplacement(commandsOn, pathPatternOn, i, packagePath) : getSourceReplacement(commandsOff, pathPatternOff, i, packagePath); @@ -5926,7 +6209,13 @@ class PackageMenu : public tsl::Gui { virtual bool handleInput(uint64_t keysDown, uint64_t keysHeld, touchPosition touchInput, JoystickPosition leftJoyStick, JoystickPosition rightJoyStick) override { if (handleCommandHold(keysDown, keysHeld, packagePath)) return true; - + + // B-cancel for an active inline warning panel: collapse banner+Accept and consume B. + if (WarningConfirm::isActive() && !stillTouching.load(acquire) && + (keysDown & KEY_B) && !(keysHeld & ~KEY_B & ALL_KEYS_MASK)) { + WarningConfirm::collapse(); + return true; + } const bool isRunningInterp = runningInterpreter.load(acquire); const bool isTouching = stillTouching.load(acquire); From 99ad7707ce14974dcdeb60b8d4c2b6a535bab918 Mon Sep 17 00:00:00 2001 From: xHR Date: Sun, 10 May 2026 09:41:04 +0000 Subject: [PATCH 10/24] Inline warning-confirm: crash fix, custom triangle glyph, ;accept= override * Crash on Accept-hold completion: collapse() was clearing runningInterpreter and lastSelectedListItem inside onComplete, racing the interpreter thread and producing a use-after-free in the next frame's handleInterpreterCompletion. Split the helper into two: - collapseUI(): UI-only removal; nulls lastSelectedListItem if it still points at the (about-to-be-deleted) Accept item. - collapse(): UI removal + full hold-pipeline reset, used for B-cancel and single-active-rule resets. onComplete now calls collapseUI(); B-cancel keeps using collapse(). * Warning glyph: the bundled font lacks U+26A0 so it rendered as a red crossed square. Replaced with a custom-drawn filled yellow triangle + dark exclamation mark, painted via drawLine + drawRect inside the banner's CustomDrawer. * New ;accept=TEXT directive lets package authors override the default 'Hold A to confirm' Accept-button label. Same parsing pipeline as ;warning= (quotes stripped, \n / \t escapes decoded). * Banner content is indented (left margin 20 px instead of 12 px) and the Accept item label is prefixed with two spaces so both elements visually nest under the source item. Removes the gap between banner and Accept by leaving them as immediately adjacent list rows. * Banner content fades in over ~150 ms via per-channel alpha scaling computed from armTicksToNs(now - expandStartTick). Signed-off-by: Devin Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- source/main.cpp | 139 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 99 insertions(+), 40 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index 742fb4a1..3d2f6665 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -101,6 +101,7 @@ constexpr std::string_view HOLD_PATTERN = ";hold="; constexpr std::string_view WARNING_PATTERN = ";warning="; constexpr std::string_view WARNING_ON_PATTERN = ";warning_on="; constexpr std::string_view WARNING_OFF_PATTERN = ";warning_off="; +constexpr std::string_view ACCEPT_PATTERN = ";accept="; constexpr std::string_view MINI_PATTERN = ";mini="; constexpr std::string_view SELECTION_MINI_PATTERN = ";selection_mini="; @@ -147,6 +148,7 @@ constexpr size_t HOLD_PATTERN_LEN = HOLD_PATTERN.size(); constexpr size_t WARNING_PATTERN_LEN = WARNING_PATTERN.size(); constexpr size_t WARNING_ON_PATTERN_LEN = WARNING_ON_PATTERN.size(); constexpr size_t WARNING_OFF_PATTERN_LEN = WARNING_OFF_PATTERN.size(); +constexpr size_t ACCEPT_PATTERN_LEN = ACCEPT_PATTERN.size(); constexpr size_t MINI_PATTERN_LEN = MINI_PATTERN.size(); constexpr size_t SELECTION_MINI_PATTERN_LEN = SELECTION_MINI_PATTERN.size(); constexpr size_t PROGRESS_PATTERN_LEN = PROGRESS_PATTERN.size(); @@ -612,7 +614,8 @@ static void handleTriggerExit() { // is defined further down (after the input-helper free functions). namespace WarningConfirm { bool isActive(); - void collapse(); + void collapse(); // full reset; safe to call on B-cancel + void collapseUI(); // UI-only; safe to call after a successful Accept-hold } // The interpreter hold-and-launch pattern shared by SelectionOverlay, @@ -644,7 +647,9 @@ static bool handleCommandHold(uint64_t keysDown, uint64_t keysHeld, const std::s warningOnConfirmCallback = nullptr; cb(); } - if (WarningConfirm::isActive()) WarningConfirm::collapse(); + // UI-only collapse here: leave lastSelectedListItem / runningInterpreter + // alone so the existing interpreter-completion pipeline can finish cleanly. + if (WarningConfirm::isActive()) WarningConfirm::collapseUI(); }, nullptr, true); return true; } @@ -731,17 +736,17 @@ namespace WarningConfirm { // Cancel any in-flight hold targeting our Accept item, then queue removal // of banner + Accept. Safe to call when nothing is active. - inline void collapse() { + // UI-only collapse: just drops the banner+Accept items from the list. + // Used after a successful Accept-hold (the existing handleCommandHold + + // interpreter pipeline already manages lastSelectedListItem / runningInterpreter + // and we must NOT clobber them here). + inline void collapseUI() { + // The Accept ListItem is about to be queued for deletion by libultrahand; + // null the global pointer if it still points at us so the next-frame + // handleInterpreterCompletion path does not dereference freed memory. if (lastSelectedListItem == g_acceptItem && g_acceptItem != nullptr) { - lastCommandIsHold = false; - displayPercentage.store(0, std::memory_order_release); - runningInterpreter.store(false, std::memory_order_release); - storedCommands.clear(); - lastCommandMode.clear(); - lastKeyName.clear(); lastSelectedListItem = nullptr; } - warningOnConfirmCallback = nullptr; if (g_list != nullptr) { if (g_banner) g_list->removeItem(g_banner); if (g_acceptItem) g_list->removeItem(g_acceptItem); @@ -752,6 +757,21 @@ namespace WarningConfirm { g_sourceItem = nullptr; } + // Full collapse: cancels any in-flight hold targeting our Accept item, + // then drops the UI. Used by B-cancel and by single-active-rule resets. + inline void collapse() { + if (lastSelectedListItem == g_acceptItem && g_acceptItem != nullptr) { + lastCommandIsHold = false; + displayPercentage.store(0, std::memory_order_release); + storedCommands.clear(); + lastCommandMode.clear(); + lastKeyName.clear(); + lastSelectedListItem = nullptr; + } + warningOnConfirmCallback = nullptr; + collapseUI(); + } + // Insert the warning banner + Accept item right under `sourceItem` in // `list`. The Accept item, when held to completion, runs `commandsToRun` // through the existing handleCommandHold pipeline. @@ -761,6 +781,7 @@ namespace WarningConfirm { std::vector> commandsToRun, const std::string& packagePath, const std::string& keyName, + const std::string& acceptText = std::string(), std::function onConfirmExtra = nullptr) { if (list == nullptr || sourceItem == nullptr || warningText.empty()) return; @@ -783,40 +804,66 @@ namespace WarningConfirm { const u16 bannerH = static_cast(topPad + lineCount * lineHeight + botPad); std::string textCopy = warningText; + const u64 expandStartTick = armGetSystemTick(); auto* banner = new tsl::elm::CustomDrawer( - [text = std::move(textCopy), lineHeight](tsl::gfx::Renderer* r, - s32 x, s32 y, s32 w, s32 h) { - // Yellow accent bar along the left edge. - const s32 accentX = x + 12; - const s32 accentW = 4; - r->drawRect(accentX, y + 4, accentW, h - 8, tsl::warningTextColor); + [text = std::move(textCopy), lineHeight, expandStartTick](tsl::gfx::Renderer* r, + s32 x, s32 y, s32 w, s32 h) { + // Slide-in / fade-in over ~150 ms. Compute alpha factor in [0, 0xF]. + const u64 nowTick = armGetSystemTick(); + const u64 elapsedNs = armTicksToNs(nowTick - expandStartTick); + const float t = (elapsedNs >= 150000000ULL) ? 1.0f + : (static_cast(elapsedNs) / 150000000.0f); + const u8 alphaScale = static_cast(0xF * t); + auto fade = [alphaScale](const tsl::Color& c) { + const u8 a = static_cast((static_cast(c.a) * alphaScale) / 0xF); + return tsl::Color(c.r, c.g, c.b, a); + }; - // Warning glyph then text. Glyph is drawn once on the first line. - const s32 textX = accentX + accentW + 12; + // Indent banner content slightly relative to source-item left edge. + const s32 indent = 20; + const s32 accentX = x + indent; + const s32 accentW = 4; + r->drawRect(accentX, y + 4, accentW, h - 8, fade(tsl::warningTextColor)); + + // Custom-drawn yellow warning triangle (filled isosceles, apex up) + // followed by a small dark "!" inside. Avoids relying on the + // built-in font containing U+26A0 (which it does not). + const s32 glyphSize = 18; + const s32 glyphX = accentX + accentW + 8; // left edge of glyph box + const s32 glyphY = y + 6; // top edge + { + const s32 cx = glyphX + glyphSize / 2; + const s32 apexY = glyphY + 1; + const s32 baseY = glyphY + glyphSize - 1; + const s32 baseHalfW = glyphSize / 2 - 1; + // Filled triangle via horizontal scanlines. + const tsl::Color tri = fade(tsl::warningTextColor); + const s32 height = baseY - apexY; + for (s32 dy = 0; dy <= height; ++dy) { + const s32 halfW = (baseHalfW * dy) / (height == 0 ? 1 : height); + r->drawLine(cx - halfW, apexY + dy, cx + halfW, apexY + dy, tri); + } + // Dark "!" mark inside the triangle (3-pixel-wide vertical stem + // and a 2x2 dot below it). + const tsl::Color mark = fade(tsl::Color(0x0, 0x0, 0x0, 0xF)); + const s32 stemH = std::max(4, glyphSize / 2 - 4); + const s32 stemTop = apexY + (height / 2) - stemH / 2; + r->drawRect(cx - 1, stemTop, 2, stemH, mark); + r->drawRect(cx - 1, stemTop + stemH + 1, 2, 2, mark); + } + const s32 textX = glyphX + glyphSize + 8; const u32 fontSize = 17; s32 cursor = y + 4 + lineHeight; - // Glyph (Unicode warning sign). - r->drawString("\u26A0", false, textX, cursor, fontSize, - tsl::warningTextColor); - const s32 glyphAdvance = 24; - - // Walk text by '\n', drawing each line. First line is offset - // past the glyph; subsequent lines start at textX. + const tsl::Color textCol = fade(tsl::defaultTextColor); size_t start = 0; - bool firstLine = true; while (start <= text.size()) { size_t end = text.find('\n', start); const std::string line = (end == std::string::npos) ? text.substr(start) : text.substr(start, end - start); - - const s32 lx = firstLine ? (textX + glyphAdvance) : textX; - r->drawString(line, false, lx, cursor, fontSize, - tsl::defaultTextColor); - cursor += lineHeight; - firstLine = false; - + r->drawString(line, false, textX, cursor, fontSize, textCol); + cursor += lineHeight; if (end == std::string::npos) break; start = end + 1; } @@ -827,9 +874,11 @@ namespace WarningConfirm { // Accept item: regular ListItem with hold-A behaviour. We piggy-back // on the existing global lastCommandIsHold + handleCommandHold pipeline // so the user gets the same progress bar / rumble feedback as a - // ;hold=true item. - // TODO: localise via libultrahand string table once a Phase-2 PR adds it. - auto* acceptItem = new tsl::elm::ListItem("Hold A to confirm"); + // ;hold=true item. Two leading spaces nest the label visually under + // the source item, matching the banner indent below. + const std::string acceptLabel = std::string(" ") + + (acceptText.empty() ? std::string("Hold A to confirm") : acceptText); + auto* acceptItem = new tsl::elm::ListItem(acceptLabel); acceptItem->enableTouchHolding(); acceptItem->setValue(HOLD_A_SYMBOL, true); acceptItem->disableClickAnimation(); @@ -4522,6 +4571,7 @@ bool drawCommandsMenu( std::string warningText; std::string warningOnText; std::string warningOffText; + std::string acceptText; // optional ;accept=TEXT override for hold-A button label std::string commandSystem; std::string commandState; @@ -4640,6 +4690,7 @@ bool drawCommandsMenu( warningText.clear(); warningOnText.clear(); warningOffText.clear(); + acceptText.clear(); commandSystem = DEFAULT_STR; commandState = DEFAULT_STR; commandHOSFirmware = ""; @@ -5108,6 +5159,14 @@ bool drawCommandsMenu( break; case 'a': + if (commandName.size() >= ACCEPT_PATTERN_LEN && + commandName.compare(0, ACCEPT_PATTERN_LEN, ACCEPT_PATTERN) == 0) { + acceptText = commandName.substr(ACCEPT_PATTERN_LEN); + for (size_t j = 1; j < cmd.size(); ++j) acceptText += " " + cmd[j]; + removeQuotes(acceptText); + WarningConfirm::unescapeWarningText(acceptText); + continue; + } if (commandName.compare(0, AMS_VERSION_PATTERN_LEN, AMS_VERSION_PATTERN) == 0) { commandAMSFirmware = commandName.substr(AMS_VERSION_PATTERN_LEN); continue; @@ -5796,7 +5855,7 @@ bool drawCommandsMenu( } listItem->setClickListener([i, commands, keyName = originalOptionName, cleanOptionName, packagePath, packageName, - selectedItem, listItem, list, warningText, lastPackageHeader, commandMode, footer, isHold, showWidget, commandFooterHighlight, commandFooterHighlightDefined](uint64_t keys) { + selectedItem, listItem, list, warningText, acceptText, lastPackageHeader, commandMode, footer, isHold, showWidget, commandFooterHighlight, commandFooterHighlightDefined](uint64_t keys) { if (runningInterpreter.load(acquire)) { return false; @@ -5809,7 +5868,7 @@ bool drawCommandsMenu( if (((keys & KEY_A && !(keys & ~KEY_A & ALL_KEYS_MASK)))) { if (!warningText.empty()) { auto warnCmds = getSourceReplacement(commands, selectedItem, i, packagePath); - WarningConfirm::expand(list, listItem, warningText, std::move(warnCmds), packagePath, keyName); + WarningConfirm::expand(list, listItem, warningText, std::move(warnCmds), packagePath, keyName, acceptText); return true; } isDownloadCommand.store(false, release); @@ -5911,7 +5970,7 @@ bool drawCommandsMenu( const bool hasToggleState = !toggleStateMode.empty(); toggleListItem->setStateChangedListener([i, usingProgress, toggleListItem, commandsOn, commandsOff, keyName = originalOptionName, packagePath, packageConfigIniPath, pathPatternOn, pathPatternOff, isHold, commandMode, hasToggleState, - list, warningText, warningOnText, warningOffText](bool state) { + list, warningText, warningOnText, warningOffText, acceptText](bool state) { if (runningInterpreter.load(std::memory_order_acquire)) { return; } @@ -5939,7 +5998,7 @@ bool drawCommandsMenu( WarningConfirm::expand( list, toggleListItem, directionalWarning, - std::move(warnCmds), packagePath, keyName, + std::move(warnCmds), packagePath, keyName, acceptText, [capturedToggle, targetState, noConfigPath, capturedConfigPath, capturedKeyName]() { capturedToggle->setState(targetState); From d40a26cd01a2b1bc930f926630394ea0a9a6c29e Mon Sep 17 00:00:00 2001 From: xHR Date: Sun, 10 May 2026 10:00:58 +0000 Subject: [PATCH 11/24] warning-confirm: defer banner+Accept removal until interpreter completes Prior fix split collapse into collapseUI() (UI removal) and collapse() (full reset) and called collapseUI() from the Accept-hold onComplete. Removing the Accept item there races with the running interpreter and the next-frame handleInterpreterCompletion(), which still tries to update the Accept item with CHECKMARK/CROSSMARK. The result was a Data Abort at offset 0x28 (m_width on a freed Element). Defer the UI removal: onComplete now sets g_pendingCollapse instead of mutating the list. Each handleInput() call site that consumes lastRunningInterpreter calls WarningConfirm::consumeDeferredCollapse() AFTER the completion update has been applied, so the Accept ListItem is removed from m_items only once it is guaranteed to be unused by the rest of the pipeline. --- source/main.cpp | 44 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index 3d2f6665..158c1242 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -614,8 +614,10 @@ static void handleTriggerExit() { // is defined further down (after the input-helper free functions). namespace WarningConfirm { bool isActive(); - void collapse(); // full reset; safe to call on B-cancel - void collapseUI(); // UI-only; safe to call after a successful Accept-hold + void collapse(); // full reset; safe to call on B-cancel + void collapseUI(); // UI-only; safe to call after a successful Accept-hold + void requestDeferredCollapse(); // mark pending; consumed once interpreter completes + bool consumeDeferredCollapse(); // returns true once and runs collapseUI() } // The interpreter hold-and-launch pattern shared by SelectionOverlay, @@ -712,6 +714,7 @@ namespace WarningConfirm { inline tsl::elm::ListItem* g_acceptItem = nullptr; inline tsl::elm::List* g_list = nullptr; inline tsl::elm::ListItem* g_sourceItem = nullptr; + inline bool g_pendingCollapse = false; // set by onComplete; consumed after handleInterpreterCompletion finishes inline bool isActive() { return g_acceptItem != nullptr; } @@ -741,9 +744,10 @@ namespace WarningConfirm { // interpreter pipeline already manages lastSelectedListItem / runningInterpreter // and we must NOT clobber them here). inline void collapseUI() { - // The Accept ListItem is about to be queued for deletion by libultrahand; - // null the global pointer if it still points at us so the next-frame - // handleInterpreterCompletion path does not dereference freed memory. + // Defensive: clear lastSelectedListItem if it still aliases the Accept item + // we are about to remove. In the deferred path, handleInterpreterCompletion + // has already cleared it, but B-cancel and single-active-rule resets reach + // this via collapse() while the global may still be live. if (lastSelectedListItem == g_acceptItem && g_acceptItem != nullptr) { lastSelectedListItem = nullptr; } @@ -751,10 +755,11 @@ namespace WarningConfirm { if (g_banner) g_list->removeItem(g_banner); if (g_acceptItem) g_list->removeItem(g_acceptItem); } - g_banner = nullptr; - g_acceptItem = nullptr; - g_list = nullptr; - g_sourceItem = nullptr; + g_banner = nullptr; + g_acceptItem = nullptr; + g_list = nullptr; + g_sourceItem = nullptr; + g_pendingCollapse = false; } // Full collapse: cancels any in-flight hold targeting our Accept item, @@ -772,6 +777,22 @@ namespace WarningConfirm { collapseUI(); } + // Mark that the banner+Accept should be removed once the interpreter + // completion pipeline has finished updating the Accept ListItem. + inline void requestDeferredCollapse() { + if (g_acceptItem != nullptr) g_pendingCollapse = true; + } + + // Consume the deferred-collapse flag if set. Called from the tail of + // the interpreter-completion handlers in PackageMenu / SelectionOverlay + // / MainMenu / ScriptOverlay so the Accept item is removed AFTER its + // CHECKMARK / CROSSMARK update has been applied. + inline bool consumeDeferredCollapse() { + if (!g_pendingCollapse) return false; + collapseUI(); + return true; + } + // Insert the warning banner + Accept item right under `sourceItem` in // `list`. The Accept item, when held to completion, runs `commandsToRun` // through the existing handleCommandHold pipeline. @@ -2169,6 +2190,7 @@ class UltrahandSettingsMenu : public tsl::Gui { } signalFeedback(); + WarningConfirm::consumeDeferredCollapse(); return true; } @@ -3172,6 +3194,7 @@ class ScriptOverlay : public tsl::Gui { reloadSoundCacheNow.store(true, std::memory_order_release); } signalFeedback(); + WarningConfirm::consumeDeferredCollapse(); return true; } @@ -4285,6 +4308,7 @@ class SelectionOverlay : public tsl::Gui { } signalFeedback(); + WarningConfirm::consumeDeferredCollapse(); return true; } @@ -6285,6 +6309,7 @@ class PackageMenu : public tsl::Gui { if (lastRunningInterpreter.exchange(false, std::memory_order_acq_rel)) { handleInterpreterCompletion(packageConfigIniPath); + WarningConfirm::consumeDeferredCollapse(); return true; } @@ -7470,6 +7495,7 @@ class MainMenu : public tsl::Gui { if (lastRunningInterpreter.exchange(false, std::memory_order_acq_rel)) { handleInterpreterCompletion(packageConfigIniPath); + WarningConfirm::consumeDeferredCollapse(); return true; } From 5a75ac95954d110cef997c7421508477f2e4313c Mon Sep 17 00:00:00 2001 From: xHR Date: Sun, 10 May 2026 10:15:31 +0000 Subject: [PATCH 12/24] warning-confirm: transfer focus to source before deleting Accept Crash log v3 (LR=0xbebebebebebebebe poison) showed the next handleInput frame deref'd the freed Accept ListItem via tsl::Gui::m_focusedElement. List::removePendingItems() decrements its internal m_focusedIndex when an item is erased, but it does NOT touch the Gui-level raw m_focusedElement pointer. After our deferred-collapse path deleted Accept, that pointer went dangling and the very next frame's for (Element* p = currentFocus; p; p = p->getParent()) handled = p->onClick(...) || p->handleInput(...); loop crashed inside p->onClick(). Fix: in collapseUI(), BEFORE queueing the banner+Accept removal, move focus back to the source item via tsl::Gui::requestFocus + List:: setFocusedIndex. After the move, m_focusedElement points at the still-live source item, m_focusedIndex points at its index, and List::removePendingItems' subsequent decrement keeps both consistent once Accept and banner are erased. Also: actually wire the previously-added requestDeferredCollapse() into the Accept-hold onComplete callback (the prior commit added the helper but kept onComplete calling collapseUI() synchronously, which still raced with handleInterpreterCompletion's CHECKMARK / CROSSMARK update). With this commit, onComplete only marks the deferred flag, and consumeDeferredCollapse() at the 5 interpreter-completion sites runs the actual removal AFTER the completion-update pass. --- source/main.cpp | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index 158c1242..39263760 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -649,9 +649,13 @@ static bool handleCommandHold(uint64_t keysDown, uint64_t keysHeld, const std::s warningOnConfirmCallback = nullptr; cb(); } - // UI-only collapse here: leave lastSelectedListItem / runningInterpreter - // alone so the existing interpreter-completion pipeline can finish cleanly. - if (WarningConfirm::isActive()) WarningConfirm::collapseUI(); + // Defer the UI removal until AFTER the interpreter has finished and + // handleInterpreterCompletion has updated the Accept item with + // CHECKMARK / CROSSMARK / footer. Removing the banner+Accept here would + // run synchronously while the click animation triggered by processHold is + // still in flight and lastSelectedListItem still points at us, producing + // a use-after-free (LR=0xbebebebebebebebe) on the next handleInput frame. + if (WarningConfirm::isActive()) WarningConfirm::requestDeferredCollapse(); }, nullptr, true); return true; } @@ -751,6 +755,30 @@ namespace WarningConfirm { if (lastSelectedListItem == g_acceptItem && g_acceptItem != nullptr) { lastSelectedListItem = nullptr; } + + // Move focus back to the source item BEFORE removing banner+Accept so + // that neither m_focusedElement (Gui-level raw pointer) nor m_focusedIndex + // (List-level index) end up dangling on a freed Element. removePendingItems + // only adjusts m_focusedIndex; it does NOT clear the Gui's m_focusedElement, + // so without this transfer the next handleInput frame would call + // p->onClick(...) on freed memory (LR=0xbebebebebebebebe poison crash). + { + auto* tslOverlay = tsl::Overlay::get(); + auto* gui = (tslOverlay != nullptr) ? tslOverlay->getCurrentGui().get() : nullptr; + if (gui != nullptr) { + if (g_list != nullptr && g_sourceItem != nullptr) { + const s32 srcIdx = g_list->getIndexInList(g_sourceItem); + if (srcIdx >= 0) { + g_list->setFocusedIndex(static_cast(srcIdx)); + } + gui->requestFocus(g_sourceItem, tsl::FocusDirection::None, false); + } else { + if (g_acceptItem != nullptr) gui->removeFocus(g_acceptItem); + if (g_banner != nullptr) gui->removeFocus(g_banner); + } + } + } + if (g_list != nullptr) { if (g_banner) g_list->removeItem(g_banner); if (g_acceptItem) g_list->removeItem(g_acceptItem); From be44c52085e72811ff97e9a23e696d16e2055665 Mon Sep 17 00:00:00 2001 From: xHR Date: Sun, 10 May 2026 10:37:12 +0000 Subject: [PATCH 13/24] warning-confirm: jump focus + scroll to Accept after expand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX feedback: cursor should land directly on the Accept item once the banner is expanded, so the user does not have to scroll past the source item and the banner to reach it. This also fixes the secondary 'last item misbehaves' report — when the source is the last item, banner+ Accept get appended past the visible viewport; setFocusedIndex on Accept routes through List::updateScrollOffset which centers the viewport on Accept, so it always becomes visible after expand. Direct requestFocus() in expand() is a no-op because List::requestFocus() returns nullptr while m_itemsToAdd is non-empty (banner+Accept have not yet been moved into m_items by the next List::draw -> addPendingItems pass). Defer the transfer: - expand() ends by calling requestFocusToAccept() which sets g_pendingFocusToAccept = true. - PackageMenu / MainMenu handleInput() call consumePendingFocusToAccept() at the very top. On the first frame after expand, Accept is now in m_items, so getIndexInList() returns a valid idx; we then call list->setFocusedIndex(idx) + gui->requestFocus(acceptItem, None, false) and clear the flag. If Accept is somehow still pending, we just wait for the following frame. --- source/main.cpp | 51 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/source/main.cpp b/source/main.cpp index 39263760..01e31394 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -618,6 +618,8 @@ namespace WarningConfirm { void collapseUI(); // UI-only; safe to call after a successful Accept-hold void requestDeferredCollapse(); // mark pending; consumed once interpreter completes bool consumeDeferredCollapse(); // returns true once and runs collapseUI() + void requestFocusToAccept(); // mark pending focus transfer to Accept (one-shot) + bool consumePendingFocusToAccept(); // run the focus transfer once Accept is in m_items } // The interpreter hold-and-launch pattern shared by SelectionOverlay, @@ -719,6 +721,7 @@ namespace WarningConfirm { inline tsl::elm::List* g_list = nullptr; inline tsl::elm::ListItem* g_sourceItem = nullptr; inline bool g_pendingCollapse = false; // set by onComplete; consumed after handleInterpreterCompletion finishes + inline bool g_pendingFocusToAccept = false; // set by expand(); consumed once Accept is live in m_items inline bool isActive() { return g_acceptItem != nullptr; } @@ -821,6 +824,40 @@ namespace WarningConfirm { return true; } + // Mark that focus should jump to the Accept item once it has been actually + // inserted into m_items (which only happens during the next List::draw + // pass). Calling tsl::Gui::requestFocus(acceptItem, ...) directly from + // expand() is a no-op because List::requestFocus() returns nullptr while + // m_itemsToAdd is non-empty. So we set a flag here and let the next + // handleInput frame in PackageMenu / MainMenu run the actual transfer + // through consumePendingFocusToAccept(). + inline void requestFocusToAccept() { + if (g_acceptItem != nullptr) g_pendingFocusToAccept = true; + } + + // Try to transfer focus to the Accept item. Returns true once the + // transfer was actually performed (or aborted because state went stale). + // Returns false if Accept is not yet in m_items so the caller knows to + // try again on the following frame. + inline bool consumePendingFocusToAccept() { + if (!g_pendingFocusToAccept) return false; + if (g_list == nullptr || g_acceptItem == nullptr) { + g_pendingFocusToAccept = false; + return true; + } + const s32 idx = g_list->getIndexInList(g_acceptItem); + if (idx < 0) return false; // not yet inserted; try next frame + + auto* tslOverlay = tsl::Overlay::get(); + auto* gui = (tslOverlay != nullptr) ? tslOverlay->getCurrentGui().get() : nullptr; + if (gui != nullptr) { + g_list->setFocusedIndex(static_cast(idx)); + gui->requestFocus(g_acceptItem, tsl::FocusDirection::None, false); + } + g_pendingFocusToAccept = false; + return true; + } + // Insert the warning banner + Accept item right under `sourceItem` in // `list`. The Accept item, when held to completion, runs `commandsToRun` // through the existing handleCommandHold pipeline. @@ -972,6 +1009,12 @@ namespace WarningConfirm { g_acceptItem = acceptItem; g_list = list; g_sourceItem = sourceItem; + + // Defer focus transfer to Accept until it is actually inserted into + // m_items by List::draw -> addPendingItems on the next frame. Calling + // requestFocus() now is a no-op because List::requestFocus() returns + // nullptr while m_itemsToAdd is non-empty. + requestFocusToAccept(); } } // namespace WarningConfirm @@ -6319,6 +6362,10 @@ class PackageMenu : public tsl::Gui { */ virtual bool handleInput(uint64_t keysDown, uint64_t keysHeld, touchPosition touchInput, JoystickPosition leftJoyStick, JoystickPosition rightJoyStick) override { + // After expand() inserts banner+Accept, focus transfer is deferred + // until Accept is actually present in m_items (next frame). + WarningConfirm::consumePendingFocusToAccept(); + if (handleCommandHold(keysDown, keysHeld, packagePath)) return true; // B-cancel for an active inline warning panel: collapse banner+Accept and consume B. @@ -7511,6 +7558,10 @@ class MainMenu : public tsl::Gui { */ virtual bool handleInput(uint64_t keysDown, uint64_t keysHeld, touchPosition touchInput, JoystickPosition leftJoyStick, JoystickPosition rightJoyStick) override { + // After expand() inserts banner+Accept, focus transfer is deferred + // until Accept is actually present in m_items (next frame). + WarningConfirm::consumePendingFocusToAccept(); + if (handleCommandHold(keysDown, keysHeld, PACKAGE_PATH)) return true; if (ult::launchingOverlay.load(acquire)) return true; From 5fa5472d126b1e729df3701942fc3a31cebee2bf Mon Sep 17 00:00:00 2001 From: Devin AI Date: Mon, 11 May 2026 08:45:30 +0000 Subject: [PATCH 14/24] warning-confirm: collapse fade-out, deeper indent, ;hold_seconds= override UX polish based on user feedback after first stable test of the inline warning-confirm panel: - Collapse no longer snaps off in a single frame. Both B-cancel and the post-Accept-hold deferred dismissal now route through beginCollapseAnim(); the banner CustomDrawer's alpha multiplies the existing fade-in by a 220 ms fade-out, and tickCollapseAnim() runs once per handleInput frame to drop banner+Accept after the fade. Single-active-rule replacement still uses an immediate collapse so the new banner does not visually overlap with a fading old one. - Banner content indent bumped from 20 px to 36 px, and the Accept label prefix widened from two to four spaces, so banner+Accept clearly nest under their originating list item. - New `;hold_seconds=N` per-item directive (float). Parsed alongside `;hold=` in case 'h', threaded through WarningConfirm::expand() as a trailing parameter, captured by the Accept item's click listener, and consumed by processHold() via a static holdDurationMsOverride that overrides ult::holdDurationMs for that hold only. Reset to 0 on release / completion / cancel so unrelated holds keep the global default. --- source/main.cpp | 135 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 111 insertions(+), 24 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index 01e31394..be192017 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -98,6 +98,7 @@ constexpr std::string_view GROUPING_PATTERN = ";grouping="; constexpr std::string_view FOOTER_PATTERN = ";footer="; constexpr std::string_view FOOTER_HIGHLIGHT_PATTERN = ";footer_highlight="; constexpr std::string_view HOLD_PATTERN = ";hold="; +constexpr std::string_view HOLD_SECONDS_PATTERN = ";hold_seconds="; constexpr std::string_view WARNING_PATTERN = ";warning="; constexpr std::string_view WARNING_ON_PATTERN = ";warning_on="; constexpr std::string_view WARNING_OFF_PATTERN = ";warning_off="; @@ -145,6 +146,7 @@ constexpr size_t GROUPING_PATTERN_LEN = GROUPING_PATTERN.size(); constexpr size_t FOOTER_PATTERN_LEN = FOOTER_PATTERN.size(); constexpr size_t FOOTER_HIGHLIGHT_PATTERN_LEN = FOOTER_HIGHLIGHT_PATTERN.size(); constexpr size_t HOLD_PATTERN_LEN = HOLD_PATTERN.size(); +constexpr size_t HOLD_SECONDS_PATTERN_LEN = HOLD_SECONDS_PATTERN.size(); constexpr size_t WARNING_PATTERN_LEN = WARNING_PATTERN.size(); constexpr size_t WARNING_ON_PATTERN_LEN = WARNING_ON_PATTERN.size(); constexpr size_t WARNING_OFF_PATTERN_LEN = WARNING_OFF_PATTERN.size(); @@ -376,6 +378,12 @@ static std::vector> storedCommands; static std::function warningOnConfirmCallback; static bool holdRumbleFired[3] = {false, false, false}; // guards for the ~33%, ~66%, ~100% rumble pulses +// Optional per-hold override of ult::holdDurationMs. Set by callers (e.g. the +// inline warning-confirm Accept item) before the first frame of a hold; reset +// to 0 on completion / cancellation so the next unrelated hold falls back to +// the global default again. +static u64 holdDurationMsOverride = 0; + bool processHold(uint64_t keysDown, uint64_t keysHeld, u64& holdStartTick, bool& isHolding, std::function onComplete, std::function onRelease = nullptr, @@ -432,9 +440,12 @@ bool processHold(uint64_t keysDown, uint64_t keysHeld, u64& holdStartTick, bool& else if (keysDown & KEY_LEFT) lastSelectedListItem->shakeHighlight(tsl::FocusDirection::Left); else if (keysDown & KEY_RIGHT) lastSelectedListItem->shakeHighlight(tsl::FocusDirection::Right); - // Update hold progress + // Update hold progress. If an override has been set (e.g. by an + // inline-warning Accept item with `;hold_seconds=` configured), use it + // instead of the global ult::holdDurationMs default. + const u64 holdMs = (holdDurationMsOverride > 0) ? holdDurationMsOverride : static_cast(ult::holdDurationMs); const u64 elapsedMs = armTicksToNs(armGetSystemTick() - holdStartTick) / 1000000; - const int percentage = std::min(100, static_cast((elapsedMs * 100) / ult::holdDurationMs)); + const int percentage = std::min(100, static_cast((elapsedMs * 100) / holdMs)); displayPercentage.store(percentage, std::memory_order_release); // Threshold-crossing rumble pulses — fired at ~33%, ~66%, ~100% of the hold. @@ -465,6 +476,10 @@ bool processHold(uint64_t keysDown, uint64_t keysHeld, u64& holdStartTick, bool& } if (onComplete) onComplete(); + + // Clear the per-hold duration override so the next unrelated hold + // falls back to the global ult::holdDurationMs default. + holdDurationMsOverride = 0; return true; } @@ -614,10 +629,11 @@ static void handleTriggerExit() { // is defined further down (after the input-helper free functions). namespace WarningConfirm { bool isActive(); - void collapse(); // full reset; safe to call on B-cancel + void collapse(bool animate = true); // full reset; B-cancel animates, single-active replace skips void collapseUI(); // UI-only; safe to call after a successful Accept-hold void requestDeferredCollapse(); // mark pending; consumed once interpreter completes - bool consumeDeferredCollapse(); // returns true once and runs collapseUI() + bool consumeDeferredCollapse(); // returns true once and starts collapse fade-out + bool tickCollapseAnim(); // per-frame poll: finalizes removal once fade-out elapsed void requestFocusToAccept(); // mark pending focus transfer to Accept (one-shot) bool consumePendingFocusToAccept(); // run the focus transfer once Accept is in m_items } @@ -722,6 +738,13 @@ namespace WarningConfirm { inline tsl::elm::ListItem* g_sourceItem = nullptr; inline bool g_pendingCollapse = false; // set by onComplete; consumed after handleInterpreterCompletion finishes inline bool g_pendingFocusToAccept = false; // set by expand(); consumed once Accept is live in m_items + inline u64 g_collapseStartTick = 0; // armGetSystemTick() when collapse fade-out began (0 = not collapsing) + + // Animation timings. Expand fade-in is read directly inside the banner + // CustomDrawer lambda; the collapse fade-out is what tickCollapseAnim() + // measures against to decide when to actually drop banner+Accept. + constexpr u64 EXPAND_ANIM_NS = 150ULL * 1000000ULL; // 150 ms + constexpr u64 COLLAPSE_ANIM_NS = 220ULL * 1000000ULL; // 220 ms inline bool isActive() { return g_acceptItem != nullptr; } @@ -791,11 +814,17 @@ namespace WarningConfirm { g_list = nullptr; g_sourceItem = nullptr; g_pendingCollapse = false; + g_collapseStartTick = 0; } + // Forward decls needed below. + inline void beginCollapseAnim(); + // Full collapse: cancels any in-flight hold targeting our Accept item, - // then drops the UI. Used by B-cancel and by single-active-rule resets. - inline void collapse() { + // then either animates the UI fade-out (default) or drops it immediately + // (used by single-active-rule replacement so the new banner doesn't visually + // overlap with a fading old one). + inline void collapse(bool animate = true) { if (lastSelectedListItem == g_acceptItem && g_acceptItem != nullptr) { lastCommandIsHold = false; displayPercentage.store(0, std::memory_order_release); @@ -805,7 +834,11 @@ namespace WarningConfirm { lastSelectedListItem = nullptr; } warningOnConfirmCallback = nullptr; - collapseUI(); + if (animate) { + beginCollapseAnim(); + } else { + collapseUI(); + } } // Mark that the banner+Accept should be removed once the interpreter @@ -820,10 +853,32 @@ namespace WarningConfirm { // CHECKMARK / CROSSMARK update has been applied. inline bool consumeDeferredCollapse() { if (!g_pendingCollapse) return false; - collapseUI(); + g_pendingCollapse = false; + beginCollapseAnim(); return true; } + // Mark the start of a collapse fade-out. Idempotent across frames; only + // the first call per dismissal is meaningful. + inline void beginCollapseAnim() { + if (g_acceptItem == nullptr) return; // nothing to collapse + if (g_collapseStartTick != 0) return; // already collapsing + g_collapseStartTick = armGetSystemTick(); + } + + // Per-frame poll: once the collapse fade-out has elapsed, actually drop + // banner+Accept from the list. Called from PackageMenu/MainMenu + // handleInput overrides every frame. + inline bool tickCollapseAnim() { + if (g_collapseStartTick == 0) return false; + const u64 elapsedNs = armTicksToNs(armGetSystemTick() - g_collapseStartTick); + if (elapsedNs >= COLLAPSE_ANIM_NS) { + collapseUI(); // also clears g_collapseStartTick + return true; + } + return false; + } + // Mark that focus should jump to the Accept item once it has been actually // inserted into m_items (which only happens during the next List::draw // pass). Calling tsl::Gui::requestFocus(acceptItem, ...) directly from @@ -868,12 +923,15 @@ namespace WarningConfirm { const std::string& packagePath, const std::string& keyName, const std::string& acceptText = std::string(), - std::function onConfirmExtra = nullptr) { + std::function onConfirmExtra = nullptr, + u64 holdMsOverride = 0) { if (list == nullptr || sourceItem == nullptr || warningText.empty()) return; // Collapse any other active warning first (single-active rule). - if (isActive()) collapse(); + // Use immediate (non-animated) collapse so the new banner doesn't visually + // overlap with a fading old one. + if (isActive()) collapse(false); const s32 sourceIdx = list->getIndexInList(sourceItem); if (sourceIdx < 0) return; @@ -894,19 +952,29 @@ namespace WarningConfirm { auto* banner = new tsl::elm::CustomDrawer( [text = std::move(textCopy), lineHeight, expandStartTick](tsl::gfx::Renderer* r, s32 x, s32 y, s32 w, s32 h) { - // Slide-in / fade-in over ~150 ms. Compute alpha factor in [0, 0xF]. + // Expand fade-in over ~150 ms, multiplicatively combined with + // collapse fade-out over ~220 ms (when active). This avoids + // the previous one-frame snap on dismissal. const u64 nowTick = armGetSystemTick(); - const u64 elapsedNs = armTicksToNs(nowTick - expandStartTick); - const float t = (elapsedNs >= 150000000ULL) ? 1.0f - : (static_cast(elapsedNs) / 150000000.0f); - const u8 alphaScale = static_cast(0xF * t); + const u64 elapsedInNs = armTicksToNs(nowTick - expandStartTick); + float alpha = (elapsedInNs >= EXPAND_ANIM_NS) ? 1.0f + : (static_cast(elapsedInNs) / static_cast(EXPAND_ANIM_NS)); + if (g_collapseStartTick != 0) { + const u64 elapsedOutNs = armTicksToNs(nowTick - g_collapseStartTick); + const float tout = (elapsedOutNs >= COLLAPSE_ANIM_NS) ? 1.0f + : (static_cast(elapsedOutNs) / static_cast(COLLAPSE_ANIM_NS)); + alpha *= (1.0f - tout); + } + if (alpha < 0.0f) alpha = 0.0f; + const u8 alphaScale = static_cast(0xF * alpha); auto fade = [alphaScale](const tsl::Color& c) { const u8 a = static_cast((static_cast(c.a) * alphaScale) / 0xF); return tsl::Color(c.r, c.g, c.b, a); }; - // Indent banner content slightly relative to source-item left edge. - const s32 indent = 20; + // Indent banner content noticeably relative to source-item left edge + // so banner+Accept visually nest under the originating item. + const s32 indent = 36; const s32 accentX = x + indent; const s32 accentW = 4; r->drawRect(accentX, y + 4, accentW, h - 8, fade(tsl::warningTextColor)); @@ -960,9 +1028,9 @@ namespace WarningConfirm { // Accept item: regular ListItem with hold-A behaviour. We piggy-back // on the existing global lastCommandIsHold + handleCommandHold pipeline // so the user gets the same progress bar / rumble feedback as a - // ;hold=true item. Two leading spaces nest the label visually under + // ;hold=true item. Four leading spaces nest the label visually under // the source item, matching the banner indent below. - const std::string acceptLabel = std::string(" ") + const std::string acceptLabel = std::string(" ") + (acceptText.empty() ? std::string("Hold A to confirm") : acceptText); auto* acceptItem = new tsl::elm::ListItem(acceptLabel); acceptItem->enableTouchHolding(); @@ -979,7 +1047,8 @@ namespace WarningConfirm { cmds = std::move(capturedCmds), pkgPath = capturedPkgPath, keyName = capturedKeyName, - onConfirm = std::move(capturedOnConfirm)](uint64_t keys) mutable -> bool { + onConfirm = std::move(capturedOnConfirm), + overrideMs = holdMsOverride](uint64_t keys) mutable -> bool { if (runningInterpreter.load(std::memory_order_acquire)) return false; @@ -992,6 +1061,9 @@ namespace WarningConfirm { acceptItem->setValue(INPROGRESS_SYMBOL); lastSelectedListItem = acceptItem; + // Per-item ;hold_seconds= override. processHold() reads + // holdDurationMsOverride before falling back to ult::holdDurationMs. + holdDurationMsOverride = overrideMs; holdStartTick = armGetSystemTick(); storedCommands = cmds; // copy; allows re-hold lastCommandMode = DEFAULT_STR; @@ -4663,6 +4735,7 @@ bool drawCommandsMenu( bool commandFooterHighlight; bool commandFooterHighlightDefined; bool isHold; + u64 holdMsOverride = 0; // optional ;hold_seconds=N override (0 = use ult::holdDurationMs) std::string warningText; std::string warningOnText; std::string warningOffText; @@ -4782,6 +4855,7 @@ bool drawCommandsMenu( commandFooterHighlight = false; commandFooterHighlightDefined = false; isHold = false; + holdMsOverride = 0; warningText.clear(); warningOnText.clear(); warningOffText.clear(); @@ -5208,6 +5282,12 @@ bool drawCommandsMenu( commandHOSFirmware = commandName.substr(HOS_VERSION_PATTERN_LEN); continue; } + if (commandName.size() > HOLD_SECONDS_PATTERN_LEN && + commandName.compare(0, HOLD_SECONDS_PATTERN_LEN, HOLD_SECONDS_PATTERN) == 0) { + const float sec = ult::stof(commandName.substr(HOLD_SECONDS_PATTERN_LEN)); + if (sec > 0.0f) holdMsOverride = static_cast(sec * 1000.0f); + continue; + } if (parseBoolFlag(commandName, HOLD_PATTERN, isHold)) continue; if (parseBoolFlag(commandName, HEADER_INDENT_PATTERN, useHeaderIndent)) continue; break; @@ -5950,7 +6030,7 @@ bool drawCommandsMenu( } listItem->setClickListener([i, commands, keyName = originalOptionName, cleanOptionName, packagePath, packageName, - selectedItem, listItem, list, warningText, acceptText, lastPackageHeader, commandMode, footer, isHold, showWidget, commandFooterHighlight, commandFooterHighlightDefined](uint64_t keys) { + selectedItem, listItem, list, warningText, acceptText, holdMsOverride, lastPackageHeader, commandMode, footer, isHold, showWidget, commandFooterHighlight, commandFooterHighlightDefined](uint64_t keys) { if (runningInterpreter.load(acquire)) { return false; @@ -5963,7 +6043,7 @@ bool drawCommandsMenu( if (((keys & KEY_A && !(keys & ~KEY_A & ALL_KEYS_MASK)))) { if (!warningText.empty()) { auto warnCmds = getSourceReplacement(commands, selectedItem, i, packagePath); - WarningConfirm::expand(list, listItem, warningText, std::move(warnCmds), packagePath, keyName, acceptText); + WarningConfirm::expand(list, listItem, warningText, std::move(warnCmds), packagePath, keyName, acceptText, nullptr, holdMsOverride); return true; } isDownloadCommand.store(false, release); @@ -6065,7 +6145,7 @@ bool drawCommandsMenu( const bool hasToggleState = !toggleStateMode.empty(); toggleListItem->setStateChangedListener([i, usingProgress, toggleListItem, commandsOn, commandsOff, keyName = originalOptionName, packagePath, packageConfigIniPath, pathPatternOn, pathPatternOff, isHold, commandMode, hasToggleState, - list, warningText, warningOnText, warningOffText, acceptText](bool state) { + list, warningText, warningOnText, warningOffText, acceptText, holdMsOverride](bool state) { if (runningInterpreter.load(std::memory_order_acquire)) { return; } @@ -6101,7 +6181,8 @@ bool drawCommandsMenu( setIniFileValue(capturedConfigPath, capturedKeyName, FOOTER_STR, targetState ? CAPITAL_ON_STR : CAPITAL_OFF_STR); } - }); + }, + holdMsOverride); return; } @@ -6365,6 +6446,9 @@ class PackageMenu : public tsl::Gui { // After expand() inserts banner+Accept, focus transfer is deferred // until Accept is actually present in m_items (next frame). WarningConfirm::consumePendingFocusToAccept(); + // Per-frame poll: once the collapse fade-out has elapsed, actually drop + // banner+Accept from the list. + WarningConfirm::tickCollapseAnim(); if (handleCommandHold(keysDown, keysHeld, packagePath)) return true; @@ -7561,6 +7645,9 @@ class MainMenu : public tsl::Gui { // After expand() inserts banner+Accept, focus transfer is deferred // until Accept is actually present in m_items (next frame). WarningConfirm::consumePendingFocusToAccept(); + // Per-frame poll: once the collapse fade-out has elapsed, actually drop + // banner+Accept from the list. + WarningConfirm::tickCollapseAnim(); if (handleCommandHold(keysDown, keysHeld, PACKAGE_PATH)) return true; From 75bff1e877920f422b63be892235a890f2a044d2 Mon Sep 17 00:00:00 2001 From: Devin AI Date: Mon, 11 May 2026 08:53:26 +0000 Subject: [PATCH 15/24] warning-confirm: drop duplicate default-arg on collapse() definition Forward-declaration in the WarningConfirm namespace at the top of main.cpp already supplies the default 'animate = true', so the definition further down must take a bare 'bool animate'. GCC errors out under -fpermissive when both sites carry the default. --- source/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/main.cpp b/source/main.cpp index be192017..f106ac10 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -824,7 +824,7 @@ namespace WarningConfirm { // then either animates the UI fade-out (default) or drops it immediately // (used by single-active-rule replacement so the new banner doesn't visually // overlap with a fading old one). - inline void collapse(bool animate = true) { + inline void collapse(bool animate) { if (lastSelectedListItem == g_acceptItem && g_acceptItem != nullptr) { lastCommandIsHold = false; displayPercentage.store(0, std::memory_order_release); From 809962085dd3ff9380dba48e896ea1c31e8f152f Mon Sep 17 00:00:00 2001 From: Devin AI Date: Mon, 11 May 2026 09:17:35 +0000 Subject: [PATCH 16/24] warning-confirm: extend accent strip onto Accept + per-item color/icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New WarningAcceptListItem ListItem subclass. Its draw() overlays a 4px-wide vertical accent strip at the banner's indent on top of the base ListItem render, using g_accentColor and the same expand/collapse alpha curve so banner+Accept appear as a single connected panel. - Two new directives: ;warning_color=#RRGGBB (or RRGGBB) — accent bar + icon tint. Defaults to theme yellow. ;warning_icon=triangle|info|error|none — icon shape next to text. Defaults to triangle. Parsed in case 'w' before warning_off/warning_on/warning (longest prefix first), threaded through WarningConfirm::expand() as trailing parameters, and captured in the banner CustomDrawer lambda. - Icon rendering refactored to a switch on IconKind: Triangle (existing) — filled isosceles + dark '!'. Info — filled circle + dark 'i' (dot + short stem). Error — filled circle + dark crossed 'X'. None — skip glyph entirely; text starts right after accent strip. Banner indent constant (36 px) and accent width (4 px) factored to namespace constants so the banner and the Accept overlay use the exact same X coordinate, preventing visible seams. --- source/main.cpp | 207 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 170 insertions(+), 37 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index f106ac10..b2947b06 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -102,6 +102,8 @@ constexpr std::string_view HOLD_SECONDS_PATTERN = ";hold_seconds="; constexpr std::string_view WARNING_PATTERN = ";warning="; constexpr std::string_view WARNING_ON_PATTERN = ";warning_on="; constexpr std::string_view WARNING_OFF_PATTERN = ";warning_off="; +constexpr std::string_view WARNING_COLOR_PATTERN = ";warning_color="; +constexpr std::string_view WARNING_ICON_PATTERN = ";warning_icon="; constexpr std::string_view ACCEPT_PATTERN = ";accept="; constexpr std::string_view MINI_PATTERN = ";mini="; @@ -150,6 +152,8 @@ constexpr size_t HOLD_SECONDS_PATTERN_LEN = HOLD_SECONDS_PATTERN.size(); constexpr size_t WARNING_PATTERN_LEN = WARNING_PATTERN.size(); constexpr size_t WARNING_ON_PATTERN_LEN = WARNING_ON_PATTERN.size(); constexpr size_t WARNING_OFF_PATTERN_LEN = WARNING_OFF_PATTERN.size(); +constexpr size_t WARNING_COLOR_PATTERN_LEN = WARNING_COLOR_PATTERN.size(); +constexpr size_t WARNING_ICON_PATTERN_LEN = WARNING_ICON_PATTERN.size(); constexpr size_t ACCEPT_PATTERN_LEN = ACCEPT_PATTERN.size(); constexpr size_t MINI_PATTERN_LEN = MINI_PATTERN.size(); constexpr size_t SELECTION_MINI_PATTERN_LEN = SELECTION_MINI_PATTERN.size(); @@ -746,6 +750,88 @@ namespace WarningConfirm { constexpr u64 EXPAND_ANIM_NS = 150ULL * 1000000ULL; // 150 ms constexpr u64 COLLAPSE_ANIM_NS = 220ULL * 1000000ULL; // 220 ms + // Indent shared by the banner accent strip and the Accept-side accent strip. + // Keep these in sync so the bar is one continuous vertical line across both. + constexpr s32 BANNER_INDENT_PX = 36; + constexpr s32 ACCENT_WIDTH_PX = 4; + + // Builtin icon shapes drawn next to the warning text. All are rendered + // via drawRect / drawLine / drawCircle so they don't depend on font glyph + // tables (which lack U+26A0 etc on the bundled system font). + enum class IconKind { Triangle, Info, Error, None }; + + inline IconKind parseIconKind(const std::string& s) { + if (s.empty()) return IconKind::Triangle; + if (s == "triangle" || s == "warning") return IconKind::Triangle; + if (s == "info" || s == "i") return IconKind::Info; + if (s == "error" || s == "x" || s == "danger") return IconKind::Error; + if (s == "none" || s == "off") return IconKind::None; + return IconKind::Triangle; + } + + // Parse a "#RRGGBB" / "RRGGBB" hex string into a 4-bit-per-channel tsl::Color. + // Falls back to `fallback` on any parse error (wrong length, non-hex char). + inline tsl::Color parseHexColor(const std::string& hexIn, tsl::Color fallback) { + std::string s = hexIn; + if (!s.empty() && s.front() == '#') s.erase(0, 1); + if (s.size() != 6) return fallback; + auto h = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + const int r8 = h(s[0]) * 16 + h(s[1]); + const int g8 = h(s[2]) * 16 + h(s[3]); + const int b8 = h(s[4]) * 16 + h(s[5]); + if (r8 < 0 || g8 < 0 || b8 < 0) return fallback; + return tsl::Color(static_cast(r8 >> 4), + static_cast(g8 >> 4), + static_cast(b8 >> 4), + 0xF); + } + + // The currently-active warning's accent color, exposed at namespace scope + // so the WarningAcceptListItem subclass can read it from its draw() override. + // Updated in expand() and read each frame. + inline tsl::Color g_accentColor = tsl::warningTextColor; + + // ListItem subclass that overlays the same vertical accent strip used by + // the banner above it, so banner+Accept appear as one connected panel. + class WarningAcceptListItem : public tsl::elm::ListItem { + public: + using tsl::elm::ListItem::ListItem; + + u64 m_expandStartTick = 0; + + virtual void draw(tsl::gfx::Renderer* r) override { + tsl::elm::ListItem::draw(r); + + // Compute the same alpha factor as the banner so both fade in / out + // together. + const u64 nowTick = armGetSystemTick(); + const u64 elapsedInNs = armTicksToNs(nowTick - m_expandStartTick); + float alpha = (elapsedInNs >= EXPAND_ANIM_NS) ? 1.0f + : (static_cast(elapsedInNs) / static_cast(EXPAND_ANIM_NS)); + if (g_collapseStartTick != 0) { + const u64 elapsedOutNs = armTicksToNs(nowTick - g_collapseStartTick); + const float tout = (elapsedOutNs >= COLLAPSE_ANIM_NS) ? 1.0f + : (static_cast(elapsedOutNs) / static_cast(COLLAPSE_ANIM_NS)); + alpha *= (1.0f - tout); + } + if (alpha < 0.0f) alpha = 0.0f; + const u8 alphaScale = static_cast(0xF * alpha); + const tsl::Color& c = g_accentColor; + const u8 aFinal = static_cast((static_cast(c.a) * alphaScale) / 0xF); + const tsl::Color tint(c.r, c.g, c.b, aFinal); + + const s32 ax = this->getX() + BANNER_INDENT_PX; + const s32 ay = this->getY() + 4; + const s32 ah = static_cast(this->getHeight()) - 8; + r->drawRect(ax, ay, ACCENT_WIDTH_PX, ah, tint); + } + }; + inline bool isActive() { return g_acceptItem != nullptr; } // Decode `\n` escape sequences in-place so package authors can write @@ -924,7 +1010,9 @@ namespace WarningConfirm { const std::string& keyName, const std::string& acceptText = std::string(), std::function onConfirmExtra = nullptr, - u64 holdMsOverride = 0) { + u64 holdMsOverride = 0, + const std::string& accentHex = std::string(), + const std::string& iconName = std::string()) { if (list == nullptr || sourceItem == nullptr || warningText.empty()) return; @@ -949,9 +1037,17 @@ namespace WarningConfirm { std::string textCopy = warningText; const u64 expandStartTick = armGetSystemTick(); + + // Resolve runtime-overridable accent color + icon shape. parseHexColor() + // falls back to the default warning yellow on parse error; parseIconKind() + // falls back to Triangle on unknown/empty. + const tsl::Color accentColor = parseHexColor(accentHex, tsl::warningTextColor); + const IconKind iconKind = parseIconKind(iconName); + g_accentColor = accentColor; // shared with WarningAcceptListItem::draw() + auto* banner = new tsl::elm::CustomDrawer( - [text = std::move(textCopy), lineHeight, expandStartTick](tsl::gfx::Renderer* r, - s32 x, s32 y, s32 w, s32 h) { + [text = std::move(textCopy), lineHeight, expandStartTick, accentColor, iconKind] + (tsl::gfx::Renderer* r, s32 x, s32 y, s32 w, s32 h) { // Expand fade-in over ~150 ms, multiplicatively combined with // collapse fade-out over ~220 ms (when active). This avoids // the previous one-frame snap on dismissal. @@ -974,38 +1070,56 @@ namespace WarningConfirm { // Indent banner content noticeably relative to source-item left edge // so banner+Accept visually nest under the originating item. - const s32 indent = 36; - const s32 accentX = x + indent; - const s32 accentW = 4; - r->drawRect(accentX, y + 4, accentW, h - 8, fade(tsl::warningTextColor)); - - // Custom-drawn yellow warning triangle (filled isosceles, apex up) - // followed by a small dark "!" inside. Avoids relying on the - // built-in font containing U+26A0 (which it does not). + const s32 accentX = x + BANNER_INDENT_PX; + r->drawRect(accentX, y + 4, ACCENT_WIDTH_PX, h - 8, fade(accentColor)); + + // Icon glyph next to the accent strip. All shapes drawn via + // drawRect / drawLine / drawCircle so we don't depend on the + // bundled font containing U+26A0 etc. const s32 glyphSize = 18; - const s32 glyphX = accentX + accentW + 8; // left edge of glyph box + const s32 glyphX = accentX + ACCENT_WIDTH_PX + 8; // left edge of glyph box const s32 glyphY = y + 6; // top edge - { - const s32 cx = glyphX + glyphSize / 2; - const s32 apexY = glyphY + 1; - const s32 baseY = glyphY + glyphSize - 1; - const s32 baseHalfW = glyphSize / 2 - 1; - // Filled triangle via horizontal scanlines. - const tsl::Color tri = fade(tsl::warningTextColor); - const s32 height = baseY - apexY; - for (s32 dy = 0; dy <= height; ++dy) { - const s32 halfW = (baseHalfW * dy) / (height == 0 ? 1 : height); - r->drawLine(cx - halfW, apexY + dy, cx + halfW, apexY + dy, tri); - } - // Dark "!" mark inside the triangle (3-pixel-wide vertical stem - // and a 2x2 dot below it). + if (iconKind != IconKind::None) { + const s32 cx = glyphX + glyphSize / 2; + const s32 cy = glyphY + glyphSize / 2; + const tsl::Color fill = fade(accentColor); const tsl::Color mark = fade(tsl::Color(0x0, 0x0, 0x0, 0xF)); - const s32 stemH = std::max(4, glyphSize / 2 - 4); - const s32 stemTop = apexY + (height / 2) - stemH / 2; - r->drawRect(cx - 1, stemTop, 2, stemH, mark); - r->drawRect(cx - 1, stemTop + stemH + 1, 2, 2, mark); + + if (iconKind == IconKind::Triangle) { + // Filled isosceles triangle, apex up, with dark "!" inside. + const s32 apexY = glyphY + 1; + const s32 baseY = glyphY + glyphSize - 1; + const s32 baseHalfW = glyphSize / 2 - 1; + const s32 height = baseY - apexY; + for (s32 dy = 0; dy <= height; ++dy) { + const s32 halfW = (baseHalfW * dy) / (height == 0 ? 1 : height); + r->drawLine(cx - halfW, apexY + dy, cx + halfW, apexY + dy, fill); + } + const s32 stemH = std::max(4, glyphSize / 2 - 4); + const s32 stemTop = apexY + (height / 2) - stemH / 2; + r->drawRect(cx - 1, stemTop, 2, stemH, mark); + r->drawRect(cx - 1, stemTop + stemH + 1, 2, 2, mark); + } else if (iconKind == IconKind::Info) { + // Filled circle with dark "i" (dot near top + short stem). + r->drawCircle(cx, cy, static_cast(glyphSize / 2), true, fill); + const s32 dotY = cy - glyphSize / 2 + 3; + r->drawRect(cx - 1, dotY, 2, 2, mark); + const s32 stemTop = dotY + 4; + const s32 stemH = std::max(4, glyphSize / 2 - 1); + r->drawRect(cx - 1, stemTop, 2, stemH, mark); + } else if (iconKind == IconKind::Error) { + // Filled circle with dark "X" (two crossed lines). + r->drawCircle(cx, cy, static_cast(glyphSize / 2), true, fill); + const s32 d = glyphSize / 2 - 3; + r->drawLine(cx - d, cy - d, cx + d, cy + d, mark); + r->drawLine(cx - d, cy - d + 1, cx + d, cy + d + 1, mark); + r->drawLine(cx - d, cy + d, cx + d, cy - d, mark); + r->drawLine(cx - d, cy + d - 1, cx + d, cy - d - 1, mark); + } } - const s32 textX = glyphX + glyphSize + 8; + const s32 textX = (iconKind == IconKind::None) + ? (accentX + ACCENT_WIDTH_PX + 8) + : (glyphX + glyphSize + 8); const u32 fontSize = 17; s32 cursor = y + 4 + lineHeight; @@ -1032,7 +1146,8 @@ namespace WarningConfirm { // the source item, matching the banner indent below. const std::string acceptLabel = std::string(" ") + (acceptText.empty() ? std::string("Hold A to confirm") : acceptText); - auto* acceptItem = new tsl::elm::ListItem(acceptLabel); + auto* acceptItem = new WarningAcceptListItem(acceptLabel); + acceptItem->m_expandStartTick = expandStartTick; // sync fade timing with banner acceptItem->enableTouchHolding(); acceptItem->setValue(HOLD_A_SYMBOL, true); acceptItem->disableClickAnimation(); @@ -4740,6 +4855,8 @@ bool drawCommandsMenu( std::string warningOnText; std::string warningOffText; std::string acceptText; // optional ;accept=TEXT override for hold-A button label + std::string warningColorHex; // optional ;warning_color=#RRGGBB (or RRGGBB) override + std::string warningIconName; // optional ;warning_icon=triangle|info|error|none std::string commandSystem; std::string commandState; @@ -4860,6 +4977,8 @@ bool drawCommandsMenu( warningOnText.clear(); warningOffText.clear(); acceptText.clear(); + warningColorHex.clear(); + warningIconName.clear(); commandSystem = DEFAULT_STR; commandState = DEFAULT_STR; commandHOSFirmware = ""; @@ -5353,7 +5472,19 @@ bool drawCommandsMenu( break; case 'w': - // Warning patterns must be checked _OFF / _ON before plain to avoid prefix collision. + // Warning patterns must be checked _COLOR / _ICON / _OFF / _ON before plain to avoid prefix collision. + if (commandName.size() > WARNING_COLOR_PATTERN_LEN && + commandName.compare(0, WARNING_COLOR_PATTERN_LEN, WARNING_COLOR_PATTERN) == 0) { + warningColorHex = commandName.substr(WARNING_COLOR_PATTERN_LEN); + removeQuotes(warningColorHex); + continue; + } + if (commandName.size() > WARNING_ICON_PATTERN_LEN && + commandName.compare(0, WARNING_ICON_PATTERN_LEN, WARNING_ICON_PATTERN) == 0) { + warningIconName = commandName.substr(WARNING_ICON_PATTERN_LEN); + removeQuotes(warningIconName); + continue; + } if (commandName.size() >= WARNING_OFF_PATTERN_LEN && commandName.compare(0, WARNING_OFF_PATTERN_LEN, WARNING_OFF_PATTERN) == 0) { warningOffText = commandName.substr(WARNING_OFF_PATTERN_LEN); @@ -6030,7 +6161,7 @@ bool drawCommandsMenu( } listItem->setClickListener([i, commands, keyName = originalOptionName, cleanOptionName, packagePath, packageName, - selectedItem, listItem, list, warningText, acceptText, holdMsOverride, lastPackageHeader, commandMode, footer, isHold, showWidget, commandFooterHighlight, commandFooterHighlightDefined](uint64_t keys) { + selectedItem, listItem, list, warningText, acceptText, holdMsOverride, warningColorHex, warningIconName, lastPackageHeader, commandMode, footer, isHold, showWidget, commandFooterHighlight, commandFooterHighlightDefined](uint64_t keys) { if (runningInterpreter.load(acquire)) { return false; @@ -6043,7 +6174,7 @@ bool drawCommandsMenu( if (((keys & KEY_A && !(keys & ~KEY_A & ALL_KEYS_MASK)))) { if (!warningText.empty()) { auto warnCmds = getSourceReplacement(commands, selectedItem, i, packagePath); - WarningConfirm::expand(list, listItem, warningText, std::move(warnCmds), packagePath, keyName, acceptText, nullptr, holdMsOverride); + WarningConfirm::expand(list, listItem, warningText, std::move(warnCmds), packagePath, keyName, acceptText, nullptr, holdMsOverride, warningColorHex, warningIconName); return true; } isDownloadCommand.store(false, release); @@ -6145,7 +6276,7 @@ bool drawCommandsMenu( const bool hasToggleState = !toggleStateMode.empty(); toggleListItem->setStateChangedListener([i, usingProgress, toggleListItem, commandsOn, commandsOff, keyName = originalOptionName, packagePath, packageConfigIniPath, pathPatternOn, pathPatternOff, isHold, commandMode, hasToggleState, - list, warningText, warningOnText, warningOffText, acceptText, holdMsOverride](bool state) { + list, warningText, warningOnText, warningOffText, acceptText, holdMsOverride, warningColorHex, warningIconName](bool state) { if (runningInterpreter.load(std::memory_order_acquire)) { return; } @@ -6182,7 +6313,9 @@ bool drawCommandsMenu( targetState ? CAPITAL_ON_STR : CAPITAL_OFF_STR); } }, - holdMsOverride); + holdMsOverride, + warningColorHex, + warningIconName); return; } From 0b4effdecc8c1e762ca33b987c149e0d149e5283 Mon Sep 17 00:00:00 2001 From: Devin AI Date: Mon, 11 May 2026 12:13:27 +0000 Subject: [PATCH 17/24] warning-confirm: source-press collapse + accent-color robustness - New behaviour: pressing A on the source list-item while its warning is already expanded now collapses the banner+Accept (instead of trying to re-expand them). Implemented for both default-mode click listeners (uses isActiveFor(listItem) before calling expand) and toggle-mode stateChangedListener (also reverts the toggle's visual flip that the press just triggered). Adds WarningConfirm::isActiveFor(item) which checks the cached g_sourceItem pointer. - WarningAcceptListItem now stores its accent color as a member instead of reading the namespace-level g_accentColor every frame. expand() copies parseHexColor(accentHex,...) directly into m_accentColor before inserting the item into the list. This avoids the (theoretical) race where the global is overwritten by a subsequent expand() before the Accept's draw() runs. - One-shot ult::logMessage in expand() that records the raw accentHex and iconName strings to /switch/.packages/log.txt, so we can verify what the per-item parser is actually feeding into expand() if colors don't appear to change in a build. --- source/main.cpp | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index b2947b06..924d670b 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -802,7 +802,8 @@ namespace WarningConfirm { public: using tsl::elm::ListItem::ListItem; - u64 m_expandStartTick = 0; + u64 m_expandStartTick = 0; + tsl::Color m_accentColor = tsl::warningTextColor; virtual void draw(tsl::gfx::Renderer* r) override { tsl::elm::ListItem::draw(r); @@ -821,10 +822,13 @@ namespace WarningConfirm { } if (alpha < 0.0f) alpha = 0.0f; const u8 alphaScale = static_cast(0xF * alpha); - const tsl::Color& c = g_accentColor; + const tsl::Color& c = m_accentColor; const u8 aFinal = static_cast((static_cast(c.a) * alphaScale) / 0xF); const tsl::Color tint(c.r, c.g, c.b, aFinal); + // Match the banner's accent strip exactly. ListItem internally + // pads its left content edge by 4 px; we offset relative to that + // same anchor so the strip on banner and Accept share one X. const s32 ax = this->getX() + BANNER_INDENT_PX; const s32 ay = this->getY() + 4; const s32 ah = static_cast(this->getHeight()) - 8; @@ -834,6 +838,12 @@ namespace WarningConfirm { inline bool isActive() { return g_acceptItem != nullptr; } + // True iff the currently-active warning was expanded for `item`. Used by + // source-item click listeners to implement "press source again to collapse". + inline bool isActiveFor(tsl::elm::ListItem* item) { + return isActive() && g_sourceItem == item; + } + // Decode `\n` escape sequences in-place so package authors can write // ;warning=Line 1\nLine 2 // and get a real line break. @@ -1041,6 +1051,10 @@ namespace WarningConfirm { // Resolve runtime-overridable accent color + icon shape. parseHexColor() // falls back to the default warning yellow on parse error; parseIconKind() // falls back to Triangle on unknown/empty. + // One-shot debug log to /switch/.packages/log.txt so package authors can + // confirm parsing of ;warning_color= / ;warning_icon=. + ult::logMessage(std::string("[WarningConfirm] expand accentHex='") + accentHex + + "' iconName='" + iconName + "' keyName='" + keyName + "'"); const tsl::Color accentColor = parseHexColor(accentHex, tsl::warningTextColor); const IconKind iconKind = parseIconKind(iconName); g_accentColor = accentColor; // shared with WarningAcceptListItem::draw() @@ -1148,6 +1162,7 @@ namespace WarningConfirm { + (acceptText.empty() ? std::string("Hold A to confirm") : acceptText); auto* acceptItem = new WarningAcceptListItem(acceptLabel); acceptItem->m_expandStartTick = expandStartTick; // sync fade timing with banner + acceptItem->m_accentColor = accentColor; // match banner's accent strip color acceptItem->enableTouchHolding(); acceptItem->setValue(HOLD_A_SYMBOL, true); acceptItem->disableClickAnimation(); @@ -6173,6 +6188,11 @@ bool drawCommandsMenu( if (((keys & KEY_A && !(keys & ~KEY_A & ALL_KEYS_MASK)))) { if (!warningText.empty()) { + // Second press of the same source item collapses the open warning. + if (WarningConfirm::isActiveFor(listItem)) { + WarningConfirm::collapse(true); + return true; + } auto warnCmds = getSourceReplacement(commands, selectedItem, i, packagePath); WarningConfirm::expand(list, listItem, warningText, std::move(warnCmds), packagePath, keyName, acceptText, nullptr, holdMsOverride, warningColorHex, warningIconName); return true; @@ -6290,6 +6310,13 @@ bool drawCommandsMenu( state ? (!warningOnText.empty() ? warningOnText : warningText) : (!warningOffText.empty() ? warningOffText : warningText); if (!directionalWarning.empty()) { + // Second press of the same toggle (while warning is already + // expanded) collapses the warning and reverts the visual flip. + if (WarningConfirm::isActiveFor(toggleListItem)) { + toggleListItem->setState(!state); // undo the flip the press just triggered + WarningConfirm::collapse(true); + return; + } // Revert visual; Accept-hold completion will flip it back. toggleListItem->setState(!state); From 3ac7eb2aeee9949493e51c7b9c5bd2fa62754202 Mon Sep 17 00:00:00 2001 From: Devin AI Date: Mon, 11 May 2026 12:22:09 +0000 Subject: [PATCH 18/24] warning-confirm DEBUG: cyan fallback, magenta probe, fixed-path log Temporary diagnostic commit. All three are intentionally garish and will be removed once the underlying issue is understood. 1. parseHexColor fallback inside expand() returns bright cyan instead of theme warningTextColor. If banners now show cyan instead of red/yellow, the new code path is executing and parseHexColor is simply receiving an empty accentHex. 2. WarningAcceptListItem::draw() now also overlays a 6 px tall bright magenta rectangle along the top of the Accept row. If this is visible, the override IS being called by the List render loop. If the magenta rect is NOT visible, the vtable / build is stale. 3. logMessage() (which routes to per-package log.txt and can be confusing) is replaced by direct fopen+fprintf to a fixed path sdmc:/config/wc-debug.log. Lines logged: [WC] parse 'w' commandName='' [WC] matched warning_color='' [WC] expand accentHex='' iconName='' keyName='' The user will rebuild, install and reproduce, then ship back the log file. From that we can tell exactly which of three failure modes is hitting (no parse / parse no match / parse matches but render ignores). --- source/main.cpp | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index 924d670b..23d274fb 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -833,6 +833,14 @@ namespace WarningConfirm { const s32 ay = this->getY() + 4; const s32 ah = static_cast(this->getHeight()) - 8; r->drawRect(ax, ay, ACCENT_WIDTH_PX, ah, tint); + + // DEBUG-VISIBILITY: 6px-tall bright-magenta probe along the very + // top of the Accept row. If this is visible, the WarningAccept + // ListItem override is definitely being called. If it's NOT + // visible, the override never runs (build stale or vtable issue). + r->drawRect(this->getX() + 4, this->getY() + 1, + this->getWidth() - 8, 6, + tsl::Color(0xF, 0x0, 0xF, 0xF)); } }; @@ -1051,11 +1059,19 @@ namespace WarningConfirm { // Resolve runtime-overridable accent color + icon shape. parseHexColor() // falls back to the default warning yellow on parse error; parseIconKind() // falls back to Triangle on unknown/empty. - // One-shot debug log to /switch/.packages/log.txt so package authors can - // confirm parsing of ;warning_color= / ;warning_icon=. - ult::logMessage(std::string("[WarningConfirm] expand accentHex='") + accentHex - + "' iconName='" + iconName + "' keyName='" + keyName + "'"); - const tsl::Color accentColor = parseHexColor(accentHex, tsl::warningTextColor); + // DEBUG: write to a fixed, package-independent path so the user + // doesn't have to guess where ult::logMessage routed the line. + { + FILE* dbg = std::fopen("sdmc:/config/wc-debug.log", "a"); + if (dbg != nullptr) { + std::fprintf(dbg, "[WC] expand accentHex='%s' iconName='%s' keyName='%s'\n", + accentHex.c_str(), iconName.c_str(), keyName.c_str()); + std::fclose(dbg); + } + } + // DEBUG-FRESHNESS: fallback intentionally bright cyan so we can + // visually distinguish 'parse failed/empty' from theme red/yellow. + const tsl::Color accentColor = parseHexColor(accentHex, tsl::Color(0x0, 0xF, 0xF, 0xF)); const IconKind iconKind = parseIconKind(iconName); g_accentColor = accentColor; // shared with WarningAcceptListItem::draw() @@ -5487,11 +5503,26 @@ bool drawCommandsMenu( break; case 'w': + // DEBUG: trace every ;w... directive we see to confirm we reach this case. + { + FILE* dbg = std::fopen("sdmc:/config/wc-debug.log", "a"); + if (dbg != nullptr) { + std::fprintf(dbg, "[WC] parse 'w' commandName='%s'\n", commandName.c_str()); + std::fclose(dbg); + } + } // Warning patterns must be checked _COLOR / _ICON / _OFF / _ON before plain to avoid prefix collision. if (commandName.size() > WARNING_COLOR_PATTERN_LEN && commandName.compare(0, WARNING_COLOR_PATTERN_LEN, WARNING_COLOR_PATTERN) == 0) { warningColorHex = commandName.substr(WARNING_COLOR_PATTERN_LEN); removeQuotes(warningColorHex); + { + FILE* dbg = std::fopen("sdmc:/config/wc-debug.log", "a"); + if (dbg != nullptr) { + std::fprintf(dbg, "[WC] matched warning_color='%s'\n", warningColorHex.c_str()); + std::fclose(dbg); + } + } continue; } if (commandName.size() > WARNING_ICON_PATTERN_LEN && From 0a2948a08a8974c0d312dc6552eb28556e8b8479 Mon Sep 17 00:00:00 2001 From: Devin AI Date: Mon, 11 May 2026 13:12:12 +0000 Subject: [PATCH 19/24] warning-confirm: remove debug probes; seamless accent strip The previous diagnostic commit confirmed (via screenshot) that: - ;warning_color= parsing is wired through correctly. - WarningAcceptListItem::draw() override is invoked by the List. - Accent strip on Accept renders at the same X as the banner. Strip both probes back out and tighten the rendering: - parseHexColor fallback restored to tsl::warningTextColor. - Magenta probe rectangle on Accept removed; the Accept strip is now drawn at full row height (getY() .. getY()+getHeight()) so it visually joins the banner's strip with no gap. - Banner strip likewise extended to full row height (y .. y+h) for the same seamless join. - ult::logMessage and fopen("sdmc:/config/wc-debug.log") debug writes removed; expand() and case 'w' parsing are silent again. --- source/main.cpp | 51 +++++++++---------------------------------------- 1 file changed, 9 insertions(+), 42 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index 23d274fb..39b1c69a 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -826,21 +826,13 @@ namespace WarningConfirm { const u8 aFinal = static_cast((static_cast(c.a) * alphaScale) / 0xF); const tsl::Color tint(c.r, c.g, c.b, aFinal); - // Match the banner's accent strip exactly. ListItem internally - // pads its left content edge by 4 px; we offset relative to that - // same anchor so the strip on banner and Accept share one X. + // Strip is full-height of the Accept row so it visually joins the + // banner's strip above with no gap (banner uses the same indent + + // width and its strip also covers the full row). const s32 ax = this->getX() + BANNER_INDENT_PX; - const s32 ay = this->getY() + 4; - const s32 ah = static_cast(this->getHeight()) - 8; + const s32 ay = this->getY(); + const s32 ah = static_cast(this->getHeight()); r->drawRect(ax, ay, ACCENT_WIDTH_PX, ah, tint); - - // DEBUG-VISIBILITY: 6px-tall bright-magenta probe along the very - // top of the Accept row. If this is visible, the WarningAccept - // ListItem override is definitely being called. If it's NOT - // visible, the override never runs (build stale or vtable issue). - r->drawRect(this->getX() + 4, this->getY() + 1, - this->getWidth() - 8, 6, - tsl::Color(0xF, 0x0, 0xF, 0xF)); } }; @@ -1059,19 +1051,7 @@ namespace WarningConfirm { // Resolve runtime-overridable accent color + icon shape. parseHexColor() // falls back to the default warning yellow on parse error; parseIconKind() // falls back to Triangle on unknown/empty. - // DEBUG: write to a fixed, package-independent path so the user - // doesn't have to guess where ult::logMessage routed the line. - { - FILE* dbg = std::fopen("sdmc:/config/wc-debug.log", "a"); - if (dbg != nullptr) { - std::fprintf(dbg, "[WC] expand accentHex='%s' iconName='%s' keyName='%s'\n", - accentHex.c_str(), iconName.c_str(), keyName.c_str()); - std::fclose(dbg); - } - } - // DEBUG-FRESHNESS: fallback intentionally bright cyan so we can - // visually distinguish 'parse failed/empty' from theme red/yellow. - const tsl::Color accentColor = parseHexColor(accentHex, tsl::Color(0x0, 0xF, 0xF, 0xF)); + const tsl::Color accentColor = parseHexColor(accentHex, tsl::warningTextColor); const IconKind iconKind = parseIconKind(iconName); g_accentColor = accentColor; // shared with WarningAcceptListItem::draw() @@ -1101,7 +1081,9 @@ namespace WarningConfirm { // Indent banner content noticeably relative to source-item left edge // so banner+Accept visually nest under the originating item. const s32 accentX = x + BANNER_INDENT_PX; - r->drawRect(accentX, y + 4, ACCENT_WIDTH_PX, h - 8, fade(accentColor)); + // Full-height strip so the banner and Accept accent strips join + // visually into one continuous vertical line. + r->drawRect(accentX, y, ACCENT_WIDTH_PX, h, fade(accentColor)); // Icon glyph next to the accent strip. All shapes drawn via // drawRect / drawLine / drawCircle so we don't depend on the @@ -5503,26 +5485,11 @@ bool drawCommandsMenu( break; case 'w': - // DEBUG: trace every ;w... directive we see to confirm we reach this case. - { - FILE* dbg = std::fopen("sdmc:/config/wc-debug.log", "a"); - if (dbg != nullptr) { - std::fprintf(dbg, "[WC] parse 'w' commandName='%s'\n", commandName.c_str()); - std::fclose(dbg); - } - } // Warning patterns must be checked _COLOR / _ICON / _OFF / _ON before plain to avoid prefix collision. if (commandName.size() > WARNING_COLOR_PATTERN_LEN && commandName.compare(0, WARNING_COLOR_PATTERN_LEN, WARNING_COLOR_PATTERN) == 0) { warningColorHex = commandName.substr(WARNING_COLOR_PATTERN_LEN); removeQuotes(warningColorHex); - { - FILE* dbg = std::fopen("sdmc:/config/wc-debug.log", "a"); - if (dbg != nullptr) { - std::fprintf(dbg, "[WC] matched warning_color='%s'\n", warningColorHex.c_str()); - std::fclose(dbg); - } - } continue; } if (commandName.size() > WARNING_ICON_PATTERN_LEN && From 8179a01e95648ab02b16307cd7c4fc1cd5836090 Mon Sep 17 00:00:00 2001 From: Devin AI Date: Mon, 11 May 2026 13:16:26 +0000 Subject: [PATCH 20/24] warning-confirm: align Accept strip with banner strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListItem::layout() in libtesla bumps its own X by +3 relative to its parent List (tesla.hpp:7390 'this->setBoundaries(this->getX() + 3, ...)'), while CustomDrawer leaves getX() at the raw List X. As a result, Accept's accent strip was rendering 3 px to the right of the banner's accent strip — visible in screenshots as a horizontal jog of roughly one strip-width at the banner/Accept boundary. Compensate explicitly inside WarningAcceptListItem::draw() so the two strips line up at the same screen X. Documented as a constant LIST_ITEM_X_OFFSET so the magic number is searchable if the libtesla internals change later. --- source/main.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index 39b1c69a..2b5c9c53 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -827,9 +827,13 @@ namespace WarningConfirm { const tsl::Color tint(c.r, c.g, c.b, aFinal); // Strip is full-height of the Accept row so it visually joins the - // banner's strip above with no gap (banner uses the same indent + - // width and its strip also covers the full row). - const s32 ax = this->getX() + BANNER_INDENT_PX; + // banner's strip above with no gap. ListItem::layout() shifts + // its own X by +3 relative to its parent List (libtesla + // internals at tesla.hpp:7390), while the banner CustomDrawer + // sits at the raw List X. Subtract 3 here so the banner strip + // and Accept strip share the exact same screen X. + constexpr s32 LIST_ITEM_X_OFFSET = 3; + const s32 ax = this->getX() + BANNER_INDENT_PX - LIST_ITEM_X_OFFSET; const s32 ay = this->getY(); const s32 ah = static_cast(this->getHeight()); r->drawRect(ax, ay, ACCENT_WIDTH_PX, ah, tint); From af5a56e91176a7cdf493504902026b81ea923a16 Mon Sep 17 00:00:00 2001 From: Devin AI Date: Mon, 11 May 2026 13:23:43 +0000 Subject: [PATCH 21/24] warning-confirm: hide focus highlight during collapse fade-out User report: collapse looks jumpy because Accept's bright focus ring stays at full opacity while banner+Accept content fades out, and then the highlight pops onto the source item the moment the items are removed. Sometimes the visible jump is small (focus already centered on source), sometimes large (focus stayed on Accept and snaps back to source). Fix: clear the m_focused flag on Accept (and defensively on banner) the instant the collapse animation starts. Gui's m_focusedElement pointer still references Accept (so onClick / handleInput routing is unchanged), but Element::frame() now skips both drawFocusBackground() and drawHighlight() because m_focused is false. Visual result: the banner+Accept simply dissolve, with no focus ring visible during the 220 ms fade. Once the animation finishes, the existing collapseUI() path calls gui->requestFocus(g_sourceItem, ...), which sets the source's m_focused = true and triggers the standard click-animation reset. libtesla draws the focus background and highlight using its built-in pulse animation, so the highlight smoothly fades back in on the original row. Also add an isCollapsing() predicate and swallow all input in PackageMenu::handleInput and MainMenu::handleInput while the animation is mid-flight. Without this, the player could press A again during the fade, retrigger the hold (Accept's click listener is still wired), and crash when the listener completes after Accept has been removed. The 220 ms lock is brief enough that B-cancel / navigation feel unaffected in practice. --- source/main.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/source/main.cpp b/source/main.cpp index 2b5c9c53..8e7539d8 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -633,6 +633,7 @@ static void handleTriggerExit() { // is defined further down (after the input-helper free functions). namespace WarningConfirm { bool isActive(); + bool isCollapsing(); // true while the fade-out animation is in progress void collapse(bool animate = true); // full reset; B-cancel animates, single-active replace skips void collapseUI(); // UI-only; safe to call after a successful Accept-hold void requestDeferredCollapse(); // mark pending; consumed once interpreter completes @@ -842,6 +843,12 @@ namespace WarningConfirm { inline bool isActive() { return g_acceptItem != nullptr; } + // True while a collapse animation is mid-flight (banner+Accept still in + // the list but fading out). PackageMenu / MainMenu handleInput overrides + // swallow all input during this window so the player can't trigger a + // second hold or scroll while items are vanishing. + inline bool isCollapsing() { return g_collapseStartTick != 0; } + // True iff the currently-active warning was expanded for `item`. Used by // source-item click listeners to implement "press source again to collapse". inline bool isActiveFor(tsl::elm::ListItem* item) { @@ -964,6 +971,18 @@ namespace WarningConfirm { if (g_acceptItem == nullptr) return; // nothing to collapse if (g_collapseStartTick != 0) return; // already collapsing g_collapseStartTick = armGetSystemTick(); + + // Hide focus highlight on Accept (and defensively on banner) so the + // 220 ms fade-out is visually clean: the user sees banner+Accept + // dissolve without a bright focus ring jumping around. Gui's + // m_focusedElement pointer still references Accept here, but its + // m_focused flag is false, so Element::frame() skips both + // drawFocusBackground() and drawHighlight(). collapseUI() at the end + // of the animation will transfer focus to the source item, which sets + // setFocused(true) and lets the highlight animate in smoothly on the + // original menu row. + if (g_acceptItem != nullptr) g_acceptItem->setFocused(false); + if (g_banner != nullptr) g_banner ->setFocused(false); } // Per-frame poll: once the collapse fade-out has elapsed, actually drop @@ -6612,6 +6631,11 @@ class PackageMenu : public tsl::Gui { // banner+Accept from the list. WarningConfirm::tickCollapseAnim(); + // While the collapse fade-out is in progress, swallow all input so + // the user can't trigger a second hold, scroll, or B-cancel while + // banner+Accept are still alive but fading. + if (WarningConfirm::isCollapsing()) return true; + if (handleCommandHold(keysDown, keysHeld, packagePath)) return true; // B-cancel for an active inline warning panel: collapse banner+Accept and consume B. @@ -7811,6 +7835,11 @@ class MainMenu : public tsl::Gui { // banner+Accept from the list. WarningConfirm::tickCollapseAnim(); + // While the collapse fade-out is in progress, swallow all input so + // the user can't trigger a second hold, scroll, or B-cancel while + // banner+Accept are still alive but fading. + if (WarningConfirm::isCollapsing()) return true; + if (handleCommandHold(keysDown, keysHeld, PACKAGE_PATH)) return true; if (ult::launchingOverlay.load(acquire)) return true; From c9bae435a58fefd02ec75b65376e583555b037cf Mon Sep 17 00:00:00 2001 From: Devin AI Date: Mon, 11 May 2026 13:30:41 +0000 Subject: [PATCH 22/24] warning-confirm: keep focus halo above accent strip User reported (with screenshot) that the accent strip paints over the source item's blue focus halo where they meet. Root cause: libtesla's drawBorderedRoundedRect renders the bottom edge of the focus halo at startY + adjustedHeight (= sourceY + sourceH + 1), with thickness = 5 px, so 5 px of the focused source's halo bleed DOWN into the banner row. Items render in list order, so the banner is drawn AFTER the source -- meaning the strip is on top of the source's halo at that overlap. Same logic applies to the bottom of Accept if the user navigates focus to the item directly below. Skip the first 6 px (5 thickness + 1 offset) of the banner strip and the last 6 px of the Accept strip. The strips still meet at the banner/Accept boundary with no gap (only the outer ends are inset), and any focus halo that bleeds into those insets now sits on top where the user expects it. --- source/main.cpp | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index 8e7539d8..799d66fc 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -827,16 +827,22 @@ namespace WarningConfirm { const u8 aFinal = static_cast((static_cast(c.a) * alphaScale) / 0xF); const tsl::Color tint(c.r, c.g, c.b, aFinal); - // Strip is full-height of the Accept row so it visually joins the - // banner's strip above with no gap. ListItem::layout() shifts - // its own X by +3 relative to its parent List (libtesla - // internals at tesla.hpp:7390), while the banner CustomDrawer - // sits at the raw List X. Subtract 3 here so the banner strip - // and Accept strip share the exact same screen X. + // Strip joins the banner's strip above with no gap at the top, + // and skips the bottom 6 px so the focus-border halo of the next + // item below (libtesla's top halo for a focused item extends 5 + // px UP, plus 1 px offset) isn't covered when the user navigates + // to that item with the warning still open. + // + // ListItem::layout() shifts its own X by +3 relative to its + // parent List (libtesla internals at tesla.hpp:7390), while the + // banner CustomDrawer sits at the raw List X. Subtract 3 here + // so the banner strip and Accept strip share the exact same + // screen X. constexpr s32 LIST_ITEM_X_OFFSET = 3; + constexpr s32 HALO_EXTENT_PX = 6; const s32 ax = this->getX() + BANNER_INDENT_PX - LIST_ITEM_X_OFFSET; const s32 ay = this->getY(); - const s32 ah = static_cast(this->getHeight()); + const s32 ah = static_cast(this->getHeight()) - HALO_EXTENT_PX; r->drawRect(ax, ay, ACCENT_WIDTH_PX, ah, tint); } }; @@ -1104,9 +1110,16 @@ namespace WarningConfirm { // Indent banner content noticeably relative to source-item left edge // so banner+Accept visually nest under the originating item. const s32 accentX = x + BANNER_INDENT_PX; - // Full-height strip so the banner and Accept accent strips join - // visually into one continuous vertical line. - r->drawRect(accentX, y, ACCENT_WIDTH_PX, h, fade(accentColor)); + // Banner strip skips its top 6 px so the focus-border halo of + // the source item above (libtesla draws its bottom halo at + // source.y + source.h + 1, height 5) is not painted over by + // the strip when the source is currently focused. Items render + // in list order, so the strip would otherwise be drawn AFTER + // (= on top of) the source's halo and visually mask it. + constexpr s32 HALO_EXTENT_PX = 6; + r->drawRect(accentX, y + HALO_EXTENT_PX, + ACCENT_WIDTH_PX, h - HALO_EXTENT_PX, + fade(accentColor)); // Icon glyph next to the accent strip. All shapes drawn via // drawRect / drawLine / drawCircle so we don't depend on the From 55f2e7588413abd109be2e60daf9da7d555ee804 Mon Sep 17 00:00:00 2001 From: Devin AI Date: Mon, 11 May 2026 13:36:38 +0000 Subject: [PATCH 23/24] warning-confirm: erase Accept's top separator so banner+Accept look unified libtesla's ListItem::draw paints a 1 px gray separator line at topBound (tesla.hpp:7350) whenever the previous item's bottomBound didn't match. The banner sits between the source ListItem and Accept, and because banner is a CustomDrawer (not a ListItem), the 'lastBottomBound' static stays at the source's bottom -- so when Accept renders next, its top separator gets drawn, producing the visible divider between the warning text and Accept that the user circled. Overwrite that line with tsl::defaultBackgroundColor right after the base ListItem::draw call and before the accent strip is rendered. Now the strip is drawn over the (erased) pixel, so it remains continuous, and the banner + Accept read as one cohesive warning panel with no horizontal divider between them. --- source/main.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/source/main.cpp b/source/main.cpp index 799d66fc..faeb3c08 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -809,6 +809,16 @@ namespace WarningConfirm { virtual void draw(tsl::gfx::Renderer* r) override { tsl::elm::ListItem::draw(r); + // Erase the top separator (libtesla draws a 1 px line at topBound + // in ListItem::draw -- tesla.hpp:7350). We want banner + Accept + // to look like one continuous panel, so overwrite the line with + // the overlay's default background color. Done before the accent + // strip so the strip itself, drawn next, sits on top of the + // erased pixel. + r->drawRect(this->getX() + 4, this->getY(), + this->getWidth() + 10, 1, + a(tsl::defaultBackgroundColor)); + // Compute the same alpha factor as the banner so both fade in / out // together. const u64 nowTick = armGetSystemTick(); From 43bcfbe54aada3706a2f8711ea70db8f4174064f Mon Sep 17 00:00:00 2001 From: Devin AI Date: Mon, 11 May 2026 14:10:56 +0000 Subject: [PATCH 24/24] warning-confirm: add post-collapse settle window for smooth focus restore User reported that after the fade-out completes, the blue focus ring 'pops' onto the source item -- which itself may still be sliding to its final scroll position because List had to recompute m_offset and m_listHeight after removing banner+Accept. The visible jump of the ring while the row is still moving reads as chaotic, especially when the collapsed warning was near the bottom of a long list with the scrollbar engaged. Add a brief post-collapse 'settle' window (250 ms): 1. collapseUI() finishes its existing work (move Gui focus to source, remove banner+Accept). 2. Before clearing globals, capture the source as g_settleTarget, record g_settleStartTick, and call g_settleTarget->setFocused(false) so the source's m_focused is OFF the instant items are removed. Element::frame() now skips drawFocusBackground/drawHighlight, so no ring is visible during the layout reflow. 3. tickSettle() polls every frame from PackageMenu / MainMenu handleInput. Once SETTLE_ANIM_NS (250 ms) elapses it checks that Gui's focused element is still the settle target (user might have navigated away or started a new warning) and only then calls setFocused(true). setFocused() resets m_clickAnimationProgress, so libtesla's built-in pulse + drawHighlight animates the ring in smoothly rather than popping. 4. expand() clears any in-flight settle state right after the single-active-rule collapse, so a new warning's Accept focus is never fighting an old settle that would otherwise flip the previous source's m_focused=true behind us. Input remains responsive during settle (only the highlight is hidden), because tickSettle is robust against the user navigating away or opening a new warning during the window. --- source/main.cpp | 89 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/source/main.cpp b/source/main.cpp index faeb3c08..583a7fd9 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -634,11 +634,13 @@ static void handleTriggerExit() { namespace WarningConfirm { bool isActive(); bool isCollapsing(); // true while the fade-out animation is in progress + bool isSettling(); // true during the brief post-collapse settling window void collapse(bool animate = true); // full reset; B-cancel animates, single-active replace skips void collapseUI(); // UI-only; safe to call after a successful Accept-hold void requestDeferredCollapse(); // mark pending; consumed once interpreter completes bool consumeDeferredCollapse(); // returns true once and starts collapse fade-out bool tickCollapseAnim(); // per-frame poll: finalizes removal once fade-out elapsed + bool tickSettle(); // per-frame poll: re-enables source highlight after settle window void requestFocusToAccept(); // mark pending focus transfer to Accept (one-shot) bool consumePendingFocusToAccept(); // run the focus transfer once Accept is in m_items } @@ -744,12 +746,23 @@ namespace WarningConfirm { inline bool g_pendingCollapse = false; // set by onComplete; consumed after handleInterpreterCompletion finishes inline bool g_pendingFocusToAccept = false; // set by expand(); consumed once Accept is live in m_items inline u64 g_collapseStartTick = 0; // armGetSystemTick() when collapse fade-out began (0 = not collapsing) + inline u64 g_settleStartTick = 0; // armGetSystemTick() when post-collapse settling began (0 = not settling) + inline tsl::elm::ListItem* g_settleTarget = nullptr; // source item whose focus highlight is suppressed during settle // Animation timings. Expand fade-in is read directly inside the banner // CustomDrawer lambda; the collapse fade-out is what tickCollapseAnim() // measures against to decide when to actually drop banner+Accept. constexpr u64 EXPAND_ANIM_NS = 150ULL * 1000000ULL; // 150 ms constexpr u64 COLLAPSE_ANIM_NS = 220ULL * 1000000ULL; // 220 ms + // After banner+Accept have been removed the List still needs a couple of + // frames to clamp m_offset, recompute m_listHeight and run its scroll + // animation toward the source item. During that window we keep the + // source's focus highlight invisible -- otherwise the blue ring snaps + // onto a row that may itself be sliding to a new position, which the + // user perceives as a chaotic flash. Once the window elapses we toggle + // m_focused back on and libtesla animates the ring in via its built-in + // pulse + m_clickAnimationProgress reset. + constexpr u64 SETTLE_ANIM_NS = 250ULL * 1000000ULL; // 250 ms // Indent shared by the banner accent strip and the Accept-side accent strip. // Keep these in sync so the bar is one continuous vertical line across both. @@ -865,6 +878,13 @@ namespace WarningConfirm { // second hold or scroll while items are vanishing. inline bool isCollapsing() { return g_collapseStartTick != 0; } + // True during the brief post-collapse window where banner+Accept are + // already gone but the source's highlight is still suppressed to let the + // List finish settling its scroll/layout state. PackageMenu / MainMenu + // handleInput overrides treat this exactly like isCollapsing() for the + // purposes of swallowing input. + inline bool isSettling() { return g_settleStartTick != 0; } + // True iff the currently-active warning was expanded for `item`. Used by // source-item click listeners to implement "press source again to collapse". inline bool isActiveFor(tsl::elm::ListItem* item) { @@ -932,6 +952,20 @@ namespace WarningConfirm { if (g_banner) g_list->removeItem(g_banner); if (g_acceptItem) g_list->removeItem(g_acceptItem); } + + // Hand off to the post-collapse settling window: the List still has + // a couple of frames of work (clamp m_offset, recompute m_listHeight, + // run its scroll animation) before the source row is at its final + // screen position. Suppress the source's focus highlight for the + // duration of that window so it doesn't snap on while the row is + // still sliding. tickSettle() restores the highlight when the timer + // elapses. + if (g_sourceItem != nullptr) { + g_settleTarget = g_sourceItem; + g_settleStartTick = armGetSystemTick(); + g_settleTarget->setFocused(false); + } + g_banner = nullptr; g_acceptItem = nullptr; g_list = nullptr; @@ -940,6 +974,36 @@ namespace WarningConfirm { g_collapseStartTick = 0; } + // Per-frame poll: once the post-collapse settling window has elapsed, + // re-enable the source item's focus highlight. Setting m_focused=true + // via setFocused() also resets m_clickAnimationProgress, which is what + // libtesla's drawHighlight uses to animate the ring fade-in -- so the + // highlight reappears smoothly rather than popping on. + inline bool tickSettle() { + if (g_settleStartTick == 0) return false; + const u64 elapsedNs = armTicksToNs(armGetSystemTick() - g_settleStartTick); + if (elapsedNs >= SETTLE_ANIM_NS) { + if (g_settleTarget != nullptr) { + // Only restore the highlight on the settle target if Gui's + // focused element is still the same item. If the player has + // navigated away during the settle window (or a new warning + // has grabbed focus through expand()), some OTHER element + // already has m_focused=true and is drawing its own ring; we + // must NOT also flip the settle target's flag on, otherwise + // two highlights paint on screen simultaneously. + auto* tslOverlay = tsl::Overlay::get(); + auto* gui = (tslOverlay != nullptr) ? tslOverlay->getCurrentGui().get() : nullptr; + if (gui != nullptr && gui->getFocusedElement() == g_settleTarget) { + g_settleTarget->setFocused(true); + } + } + g_settleTarget = nullptr; + g_settleStartTick = 0; + return true; + } + return false; + } + // Forward decls needed below. inline void beginCollapseAnim(); @@ -1070,6 +1134,15 @@ namespace WarningConfirm { // overlap with a fading old one. if (isActive()) collapse(false); + // After single-active-rule replacement, collapseUI started a settle + // window targeting the OLD source. We're about to install a brand + // new banner+Accept whose focus path is independent, so clear that + // pending settle now -- otherwise tickSettle could 250 ms from now + // call setFocused(true) on the old source while the new Accept is + // the actual focused element, painting two highlights on screen. + g_settleStartTick = 0; + g_settleTarget = nullptr; + const s32 sourceIdx = list->getIndexInList(sourceItem); if (sourceIdx < 0) return; const ssize_t insertAt = sourceIdx + 1; @@ -6653,10 +6726,16 @@ class PackageMenu : public tsl::Gui { // Per-frame poll: once the collapse fade-out has elapsed, actually drop // banner+Accept from the list. WarningConfirm::tickCollapseAnim(); + // Per-frame poll: once banner+Accept are gone and the List has had + // a couple of frames to settle, re-enable the source's focus highlight. + WarningConfirm::tickSettle(); // While the collapse fade-out is in progress, swallow all input so // the user can't trigger a second hold, scroll, or B-cancel while - // banner+Accept are still alive but fading. + // banner+Accept are still alive but fading. The subsequent settle + // window is purely visual (source highlight suppressed) and stays + // input-responsive: tickSettle() checks Gui's focused element before + // restoring m_focused, so navigation away during settle is safe. if (WarningConfirm::isCollapsing()) return true; if (handleCommandHold(keysDown, keysHeld, packagePath)) return true; @@ -7857,10 +7936,16 @@ class MainMenu : public tsl::Gui { // Per-frame poll: once the collapse fade-out has elapsed, actually drop // banner+Accept from the list. WarningConfirm::tickCollapseAnim(); + // Per-frame poll: once banner+Accept are gone and the List has had + // a couple of frames to settle, re-enable the source's focus highlight. + WarningConfirm::tickSettle(); // While the collapse fade-out is in progress, swallow all input so // the user can't trigger a second hold, scroll, or B-cancel while - // banner+Accept are still alive but fading. + // banner+Accept are still alive but fading. The subsequent settle + // window is purely visual (source highlight suppressed) and stays + // input-responsive: tickSettle() checks Gui's focused element before + // restoring m_focused, so navigation away during settle is safe. if (WarningConfirm::isCollapsing()) return true; if (handleCommandHold(keysDown, keysHeld, PACKAGE_PATH)) return true;