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..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, 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/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..8fdea36e 160000 --- a/lib/libultrahand +++ b/lib/libultrahand @@ -1 +1 @@ -Subproject commit b6f83833365448ed8e109fbbd9bc7883a31b00c2 +Subproject commit 8fdea36ed927baa77cb842783d020a794b7966a2 diff --git a/source/main.cpp b/source/main.cpp index 4b1eb8a5..583a7fd9 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -98,6 +98,13 @@ 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="; +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="; constexpr std::string_view SELECTION_MINI_PATTERN = ";selection_mini="; @@ -141,6 +148,13 @@ 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(); +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(); constexpr size_t PROGRESS_PATTERN_LEN = PROGRESS_PATTERN.size(); @@ -185,6 +199,8 @@ static std::string lastCommandMode; static bool lastCommandIsHold; static bool lastFooterHighlight; static bool lastFooterHighlightDefined; +static bool lastToggleTargetState = false; +static bool lastToggleHasState = false; static std::unordered_map selectedFooterDict; @@ -359,8 +375,19 @@ 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 +// 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, @@ -404,6 +431,7 @@ bool processHold(uint64_t keysDown, uint64_t keysHeld, u64& holdStartTick, bool& lastCommandMode.clear(); lastCommandIsHold = false; lastKeyName.clear(); + warningOnConfirmCallback = nullptr; } if (onRelease) onRelease(); @@ -416,9 +444,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) / 3000)); + 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. @@ -449,6 +480,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; } @@ -548,7 +583,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(); @@ -593,21 +629,58 @@ 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(); + 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 +} + // 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. // 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); + 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); lastRunningInterpreter.store(true, std::memory_order_release); + if (warningOnConfirmCallback) { + auto cb = std::move(warningOnConfirmCallback); + warningOnConfirmCallback = nullptr; + cb(); + } + // 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; } @@ -653,6 +726,618 @@ 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 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. + 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; + tsl::Color m_accentColor = tsl::warningTextColor; + + 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(); + 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 = m_accentColor; + const u8 aFinal = static_cast((static_cast(c.a) * alphaScale) / 0xF); + const tsl::Color tint(c.r, c.g, c.b, aFinal); + + // 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()) - HALO_EXTENT_PX; + r->drawRect(ax, ay, ACCENT_WIDTH_PX, ah, tint); + } + }; + + 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 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) { + 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. + 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. + // 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() { + // 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; + } + + // 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); + } + + // 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; + g_sourceItem = nullptr; + g_pendingCollapse = false; + 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(); + + // Full collapse: cancels any in-flight hold targeting our Accept item, + // 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) { + 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; + if (animate) { + beginCollapseAnim(); + } else { + 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; + 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(); + + // 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 + // 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 + // 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. + 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, + const std::string& acceptText = std::string(), + std::function onConfirmExtra = nullptr, + u64 holdMsOverride = 0, + const std::string& accentHex = std::string(), + const std::string& iconName = std::string()) { + if (list == nullptr || sourceItem == nullptr || warningText.empty()) + return; + + // Collapse any other active warning first (single-active rule). + // Use immediate (non-animated) collapse so the new banner doesn't visually + // 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; + + // 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; + 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, 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. + const u64 nowTick = armGetSystemTick(); + 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 noticeably relative to source-item left edge + // so banner+Accept visually nest under the originating item. + const s32 accentX = x + BANNER_INDENT_PX; + // 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 + // bundled font containing U+26A0 etc. + const s32 glyphSize = 18; + const s32 glyphX = accentX + ACCENT_WIDTH_PX + 8; // left edge of glyph box + const s32 glyphY = y + 6; // top edge + 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)); + + 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 = (iconKind == IconKind::None) + ? (accentX + ACCENT_WIDTH_PX + 8) + : (glyphX + glyphSize + 8); + const u32 fontSize = 17; + s32 cursor = y + 4 + lineHeight; + + const tsl::Color textCol = fade(tsl::defaultTextColor); + size_t start = 0; + 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); + r->drawString(line, false, textX, cursor, fontSize, textCol); + cursor += lineHeight; + 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. Four 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 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(); + + 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), + overrideMs = holdMsOverride](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; + + // 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; + 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; + + // 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 + // Forward declaration of the MainMenu class. class MainMenu; @@ -1748,6 +2433,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 @@ -1873,6 +2580,7 @@ class UltrahandSettingsMenu : public tsl::Gui { } signalFeedback(); + WarningConfirm::consumeDeferredCollapse(); return true; } @@ -2876,6 +3584,7 @@ class ScriptOverlay : public tsl::Gui { reloadSoundCacheNow.store(true, std::memory_order_release); } signalFeedback(); + WarningConfirm::consumeDeferredCollapse(); return true; } @@ -3021,6 +3730,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 +3835,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 +4459,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 +4556,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); @@ -3925,6 +4698,7 @@ class SelectionOverlay : public tsl::Gui { } signalFeedback(); + WarningConfirm::consumeDeferredCollapse(); return true; } @@ -4208,6 +4982,13 @@ 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; + 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; @@ -4323,6 +5104,13 @@ bool drawCommandsMenu( commandFooterHighlight = false; commandFooterHighlightDefined = false; isHold = false; + holdMsOverride = 0; + warningText.clear(); + warningOnText.clear(); + warningOffText.clear(); + acceptText.clear(); + warningColorHex.clear(); + warningIconName.clear(); commandSystem = DEFAULT_STR; commandState = DEFAULT_STR; commandHOSFirmware = ""; @@ -4336,6 +5124,18 @@ bool drawCommandsMenu( sourceTypeOn = DEFAULT_STR; sourceTypeOff = DEFAULT_STR; + std::string toggleStateMode = ""; + std::string toggleStatePath = ""; + std::string toggleStateArg = ""; + std::string toggleStateArg2 = ""; + std::string toggleStateArg3 = ""; + + std::string toggleVisibilityMode = ""; + std::string toggleVisibilityPath = ""; + std::string toggleVisibilityArg = ""; + std::string toggleVisibilityArg2 = ""; + std::string toggleVisibilityArg3 = ""; + bool isSlot = false; @@ -4592,6 +5392,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') && @@ -4712,6 +5533,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; @@ -4758,6 +5585,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; @@ -4769,6 +5604,43 @@ bool drawCommandsMenu( break; case 'w': + // 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); + 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; @@ -4791,6 +5663,48 @@ 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; @@ -4798,6 +5712,7 @@ bool drawCommandsMenu( currentSection = OFF_STR; if (currentSection == GLOBAL_STR) { + commandsOn.push_back(cmd); commandsOff.push_back(cmd); } else if (currentSection == ON_STR) { @@ -4836,6 +5751,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); @@ -4887,6 +5804,8 @@ bool drawCommandsMenu( } packageConfigData.clear(); } + } // end toggleStateMode.empty() + // Get Option name and footer @@ -4926,7 +5845,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; @@ -5355,7 +6293,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, acceptText, holdMsOverride, warningColorHex, warningIconName, lastPackageHeader, commandMode, footer, isHold, showWidget, commandFooterHighlight, commandFooterHighlightDefined](uint64_t keys) { if (runningInterpreter.load(acquire)) { return false; @@ -5366,6 +6304,16 @@ 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; + } isDownloadCommand.store(false, release); runningInterpreter.store(true, release); @@ -5419,12 +6367,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 +6398,103 @@ 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) { + 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, warningColorHex, warningIconName](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()) { + // 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); + + 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, acceptText, + [capturedToggle, targetState, noConfigPath, + capturedConfigPath, capturedKeyName]() { + capturedToggle->setState(targetState); + if (!noConfigPath) { + setIniFileValue(capturedConfigPath, capturedKeyName, FOOTER_STR, + targetState ? CAPITAL_ON_STR : CAPITAL_OFF_STR); + } + }, + holdMsOverride, + warningColorHex, + warningIconName); + return; + } + + auto modifiedCmds = state ? getSourceReplacement(commandsOn, pathPatternOn, i, packagePath) : + getSourceReplacement(commandsOff, pathPatternOff, i, packagePath); + + if (isHold && !lastCommandIsHold) { + lastToggleTargetState = state; + lastToggleHasState = hasToggleState; + toggleListItem->setState(!state); + + lastSelectedListItemFooter = toggleListItem->getValue(); + 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; + lastToggleHasState = hasToggleState; + if (!hasToggleState) + 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) @@ -5686,8 +6720,32 @@ 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(); + // 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. 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; - + + // 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); @@ -5698,6 +6756,7 @@ class PackageMenu : public tsl::Gui { if (lastRunningInterpreter.exchange(false, std::memory_order_acq_rel)) { handleInterpreterCompletion(packageConfigIniPath); + WarningConfirm::consumeDeferredCollapse(); return true; } @@ -6871,7 +7930,25 @@ 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; + // 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(); + // 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. 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; if (ult::launchingOverlay.load(acquire)) return true; @@ -6883,6 +7960,7 @@ class MainMenu : public tsl::Gui { if (lastRunningInterpreter.exchange(false, std::memory_order_acq_rel)) { handleInterpreterCompletion(packageConfigIniPath); + WarningConfirm::consumeDeferredCollapse(); return true; } @@ -7209,6 +8287,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 +8326,14 @@ void initializeSettingsAndDirectories() { if (needsUpdate) saveIniFileData(ULTRAHAND_CONFIG_INI_PATH, iniData); + 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); + } + // 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) {