Files
Horizon-OC/Source/hoc-clk/overlay/lib/libultrahand/libtesla/include/tesla.hpp
2026-04-01 16:00:54 -04:00

12860 lines
592 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/********************************************************************************
* Custom Fork Information
*
* File: tesla.hpp
* Author: ppkantorski
* Description:
* This file serves as the core logic for the Ultrahand Overlay project's custom fork
* of libtesla, an overlay executor. Within this file, you will find a collection of
* functions, menu structures, and interaction logic designed to facilitate the
* smooth execution and flexible customization of overlays within the project.
*
* For the latest updates and contributions, visit the project's GitHub repository.
* (GitHub Repository: https://github.com/ppkantorski/Ultrahand-Overlay)
*
* Note: Please be aware that this notice cannot be altered or removed. It is a part
* of the project's documentation and must remain intact.
*
* Copyright (c) 2023-2026 ppkantorski
********************************************************************************/
/**
* Copyright (C) 2020 werwolv
*
* This file is part of libtesla.
*
* libtesla is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* libtesla is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with libtesla. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <ultra.hpp>
#include <switch.h>
#include <arm_neon.h>
#include <strings.h>
#include <math.h>
#if !IS_LAUNCHER_DIRECTIVE
#include <filesystem> // unused, but preserved for projects that might need it
#endif
#include <algorithm>
#include <cstring>
#include <cwctype>
#include <string>
#include <functional>
#include <type_traits>
#include <mutex>
#include <shared_mutex>
#include <memory>
#include <list>
#include <stack>
#include <map>
// Define this makro before including tesla.hpp in your main file. If you intend
// to use the tesla.hpp header in more than one source file, only define it once!
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-function"
#ifdef TESLA_INIT_IMPL
#define STB_TRUETYPE_IMPLEMENTATION
#endif
#include "stb_truetype.h"
#pragma GCC diagnostic pop
#define ELEMENT_BOUNDS(elem) elem->getX(), elem->getY(), elem->getWidth(), elem->getHeight()
#define ASSERT_EXIT(x) if (R_FAILED(x)) std::exit(1)
#define ASSERT_FATAL(x) if (Result res = x; R_FAILED(res)) fatalThrow(res)
#define PACKED __attribute__((packed))
#define ALWAYS_INLINE inline __attribute__((always_inline))
/// Evaluates an expression that returns a result, and returns the result if it would fail.
#define TSL_R_TRY(resultExpr) \
({ \
const auto result = resultExpr; \
if (R_FAILED(result)) { \
return result; \
} \
})
using namespace std::literals::string_literals;
using namespace std::literals::chrono_literals; // potentially unused, restored for softare compatibility
#if IS_STATUS_MONITOR_DIRECTIVE
struct KeyPairHash {
std::size_t operator()(const std::pair<int, float>& key) const {
// Combine hashes of both components
union returnValue {
char c[8];
std::size_t s;
} value;
memcpy(&value.c[0], &key.first, 4);
memcpy(&value.c[4], &key.second, 4);
return value.s;
}
};
// Custom equality comparison for int-float pairs
struct KeyPairEqual {
bool operator()(const std::pair<int, float>& lhs, const std::pair<int, float>& rhs) const {
return lhs.first == rhs.first &&
std::abs(lhs.second - rhs.second) < 0.00001f;
}
};
inline u8 TeslaFPS = 60;
inline std::atomic<bool> triggerExitNow{false};
inline std::atomic<bool> isRendering{false};
inline std::atomic<bool> delayUpdate{false};
inline std::atomic<bool> pendingExit{false};
inline std::atomic<bool> wasRendering{false};
inline LEvent renderingStopEvent;
inline bool FullMode = true;
inline bool deactivateOriginalFooter = false;
inline bool disableJumpTo = false;
inline std::string lastMode;
inline std::set<std::string> overlayModes = {"full", "mini", "micro", "fps_graph", "fps_counter", "game_resolutions"};
inline bool isValidOverlayMode() {
return overlayModes.count(lastMode) > 0;
}
#endif
#if USING_FPS_INDICATOR_DIRECTIVE
inline float fps = 0.0;
inline int frameCount = 0;
inline double elapsedTime = 0.0;
#endif
// Custom variables
inline std::atomic<bool> jumpToTop{false};
inline std::atomic<bool> jumpToBottom{false};
inline std::atomic<bool> skipUp{false};
inline std::atomic<bool> skipDown{false};
inline u32 offsetWidthVar = 112;
inline std::string lastOverlayFilename;
inline std::string lastOverlayMode;
inline std::mutex jumpItemMutex;
inline std::string jumpItemName;
inline std::string jumpItemValue;
inline std::atomic<bool> jumpItemExactMatch{true};
inline std::atomic<bool> s_onLeftPage{false};
inline std::atomic<bool> s_onRightPage{false};
inline std::atomic<bool> screenshotsAreDisabled{false};
inline std::atomic<bool> screenshotsAreForceDisabled{false};
inline bool hideHidden = false;
inline bool usingUnfocusedColor = true;
inline bool bypassUnfocused = false;
inline std::atomic<bool> mainComboHasTriggered{false};
inline std::atomic<bool> launchComboHasTriggered{false};
inline std::atomic<bool> feedbackPollerStop{false};
inline std::atomic<bool> hidReinitInProgress{false};
// Sound triggering variables
inline std::atomic<bool> triggerNavigationSound{false};
inline std::atomic<bool> triggerEnterSound{false};
inline std::atomic<bool> triggerExitSound{false};
inline std::atomic<bool> triggerWallSound{false};
inline std::atomic<bool> triggerOnSound{false};
inline std::atomic<bool> triggerOffSound{false};
inline std::atomic<bool> triggerSettingsSound{false};
inline std::atomic<bool> triggerMoveSound{false};
inline std::atomic<bool> triggerNotificationSound{false};
inline std::atomic<bool> disableSound{false};
inline std::atomic<bool> disableHaptics{false};
inline std::atomic<bool> reloadIfDockedChangedNow{false};
inline std::atomic<bool> reloadSoundCacheNow{false};
// Haptic triggering variables
inline std::atomic<bool> triggerInitHaptics{false};
inline std::atomic<bool> triggerRumbleClick{false};
inline std::atomic<bool> triggerRumbleDoubleClick{false};
__attribute__((noinline)) static void triggerFeedbackImpl(
std::atomic<bool>& rumble, std::atomic<bool>& sound) {
rumble.store(true, std::memory_order_release);
sound.store(true, std::memory_order_release);
}
inline void triggerNavigationFeedback() { triggerFeedbackImpl(triggerRumbleClick, triggerNavigationSound); }
inline void triggerWallFeedback() { triggerFeedbackImpl(triggerRumbleClick, triggerWallSound); }
inline void triggerEnterFeedback() { triggerFeedbackImpl(triggerRumbleClick, triggerEnterSound); }
inline void triggerExitFeedback() { triggerFeedbackImpl(triggerRumbleDoubleClick, triggerExitSound); }
/**
* @brief Checks if an NRO file uses new libnx (has LNY2 tag).
*
* @param filePath The path to the NRO file.
* @return true if the file uses new libnx (LNY2 present), false otherwise.
* Defined in tesla.cpp — file I/O + malloc body is too large to inline.
*/
bool usingLNY2(const std::string& filePath);
/**
* @brief Checks if the current AMS version is at least the specified version.
*
* @param major Minimum major version required
* @param minor Minimum minor version required
* @param patch Minimum patch version required
* @return true if current AMS version >= specified version, false otherwise
*/
inline bool amsVersionAtLeast(uint8_t major, uint8_t minor, uint8_t patch) {
u64 packed_version;
if (R_FAILED(splGetConfig((SplConfigItem)65000, &packed_version))) {
return false;
}
return ((packed_version >> 40) & 0xFFFFFF) >= static_cast<u32>((major << 16) | (minor << 8) | patch);
}
inline bool requiresLNY2 = false;
namespace tsl {
// Shared static specialChars vectors — avoids duplicate static init at each call site
inline const std::vector<std::string> s_dividerSpecialChars = {ult::DIVIDER_SYMBOL};
inline const std::vector<std::string> s_footerSpecialChars = {"\uE0E1","\uE0E0","\uE0ED","\uE0EE","\uE0E5"};
// Booleans
inline std::atomic<bool> clearGlyphCacheNow(false);
// Constants
namespace cfg {
constexpr u32 ScreenWidth = 1920; ///< Width of the Screen
constexpr u32 ScreenHeight = 1080; ///< Height of the Screen
constexpr u32 LayerMaxWidth = 1280;
constexpr u32 LayerMaxHeight = 720;
extern u16 LayerWidth; ///< Width of the Tesla layer
extern u16 LayerHeight; ///< Height of the Tesla layer
extern u16 LayerPosX; ///< X position of the Tesla layer
extern u16 LayerPosY; ///< Y position of the Tesla layer
extern u16 FramebufferWidth; ///< Width of the framebuffer
extern u16 FramebufferHeight; ///< Height of the framebuffer
extern u64 launchCombo; ///< Overlay activation key combo
extern u64 launchCombo2; ///< Overlay activation key combo
}
/**
* @brief RGBA4444 Color structure
*/
struct Color {
union {
struct {
u16 r: 4, g: 4, b: 4, a: 4;
} PACKED;
u16 rgba;
};
constexpr inline Color() : rgba(0) {}
constexpr inline Color(u16 raw) : rgba(raw) {}
constexpr inline Color(u8 r, u8 g, u8 b, u8 a) : r(r), g(g), b(b), a(a) {}
};
// Ultra-fast version - zero variables, optimized calculations
inline constexpr Color GradientColor(float temperature) {
if (temperature <= 35.0f) return Color(7, 7, 15, 0xF);
if (temperature >= 65.0f) return Color(15, 0, 0, 0xF);
if (temperature < 45.0f) {
// Single calculation, avoid repetition
const float factor = (temperature - 35.0f) * 0.1f;
return Color(7 - 7 * factor, 7 + 8 * factor, 15 - 15 * factor, 0xF);
}
if (temperature < 55.0f) {
return Color(15 * (temperature - 45.0f) * 0.1f, 15, 0, 0xF);
}
return Color(15, 15 - 15 * (temperature - 55.0f) * 0.1f, 0, 0xF);
}
// Ultra-fast version - single variable, minimal branching
inline Color RGB888(const std::string& hexColor, size_t alpha = 15, const std::string& defaultHexColor = ult::whiteColor) {
const char* h = hexColor.size() == 6 ? hexColor.data() :
hexColor.size() == 7 && hexColor[0] == '#' ? hexColor.data() + 1 :
defaultHexColor.data();
return Color(
(ult::hexMap[h[0]] << 4 | ult::hexMap[h[1]]) >> 4,
(ult::hexMap[h[2]] << 4 | ult::hexMap[h[3]]) >> 4,
(ult::hexMap[h[4]] << 4 | ult::hexMap[h[5]]) >> 4,
alpha
);
}
inline Color lerpColor(const Color& c1, const Color& c2, float t) {
return {
static_cast<u8>((c1.r - c2.r) * t + c2.r + 0.5f),
static_cast<u8>((c1.g - c2.g) * t + c2.g + 0.5f),
static_cast<u8>((c1.b - c2.b) * t + c2.b + 0.5f),
0xF
};
}
namespace style {
constexpr u32 ListItemDefaultHeight = 70; ///< Standard list item height
constexpr u32 MiniListItemDefaultHeight = 40; ///< Mini list item height
constexpr u32 TrackBarDefaultHeight = 83; ///< Standard track bar height
constexpr u8 ListItemHighlightSaturation = 7; ///< Maximum saturation of Listitem highlights
constexpr u8 ListItemHighlightLength = 22; ///< Maximum length of Listitem highlights
namespace color {
constexpr Color ColorFrameBackground = { 0x0, 0x0, 0x0, 0xD }; ///< Overlay frame background color
constexpr Color ColorTransparent = { 0x0, 0x0, 0x0, 0x0 }; ///< Transparent color
constexpr Color ColorHighlight = { 0x0, 0xF, 0xD, 0xF }; ///< Greenish highlight color
constexpr Color ColorFrame = { 0x7, 0x7, 0x7, 0x7 }; ///< Outer boarder color
constexpr Color ColorHandle = { 0x5, 0x5, 0x5, 0xF }; ///< Track bar handle color
constexpr Color ColorText = { 0xF, 0xF, 0xF, 0xF }; ///< Standard text color
constexpr Color ColorDescription = { 0xA, 0xA, 0xA, 0xF }; ///< Description text color
constexpr Color ColorHeaderBar = { 0xC, 0xC, 0xC, 0xF }; ///< Category header rectangle color
constexpr Color ColorClickAnimation = { 0x0, 0x2, 0x2, 0xF }; ///< Element click animation color
}
}
inline bool overrideBackButton = false; // for properly overriding the automatic "go back" functionality of KEY_B button presses
inline bool disableHiding = false; // for manually disabling the hide overlay functionality
// Theme color variable definitions — defined once in tesla.cpp
extern Color logoColor1;
extern Color logoColor2;
extern size_t defaultBackgroundAlpha;
extern Color defaultBackgroundColor;
extern Color defaultTextColor;
extern Color notificationTextColor;
extern Color notificationTitleColor;
extern Color notificationTimeColor;
extern Color headerTextColor;
extern Color headerSeparatorColor;
extern Color starColor;
extern Color selectionStarColor;
extern Color buttonColor;
extern Color bottomTextColor;
extern Color bottomSeparatorColor;
extern Color unfocusedColor;
extern Color topSeparatorColor;
extern Color defaultOverlayColor;
extern Color defaultPackageColor;
extern Color defaultScriptColor;
extern Color clockColor;
extern Color temperatureColor;
extern Color batteryColor;
extern Color batteryChargingColor;
extern Color batteryLowColor;
extern size_t widgetBackdropAlpha;
extern Color widgetBackdropColor;
extern Color overlayTextColor;
extern Color ultOverlayTextColor;
extern Color packageTextColor;
extern Color ultPackageTextColor;
extern Color bannerVersionTextColor;
extern Color overlayVersionTextColor;
extern Color ultOverlayVersionTextColor;
extern Color packageVersionTextColor;
extern Color ultPackageVersionTextColor;
extern Color onTextColor;
extern Color offTextColor;
#if IS_LAUNCHER_DIRECTIVE
extern Color dynamicLogoRGB1;
extern Color dynamicLogoRGB2;
#endif
extern bool invertBGClickColor;
extern size_t selectionBGAlpha;
extern Color selectionBGColor;
extern Color highlightColor1;
extern Color highlightColor2;
extern Color highlightColor3;
extern Color highlightColor4;
extern Color s_highlightColor;
extern size_t clickAlpha;
extern Color clickColor;
extern size_t progressAlpha;
extern Color progressColor;
extern Color scrollBarColor;
extern Color scrollBarWallColor;
extern size_t separatorAlpha;
extern Color separatorColor;
extern const Color edgeSeparatorColor;
extern Color textSeparatorColor;
extern Color selectedTextColor;
extern Color selectedValueTextColor;
extern Color inprogressTextColor;
extern Color invalidTextColor;
extern Color clickTextColor;
extern size_t tableBGAlpha;
extern Color tableBGColor;
extern Color sectionTextColor;
extern Color infoTextColor;
extern Color warningTextColor;
extern Color healthyRamTextColor;
extern Color neutralRamTextColor;
extern Color badRamTextColor;
extern Color trackBarSliderColor;
extern Color trackBarSliderBorderColor;
extern Color trackBarSliderMalleableColor;
extern Color trackBarFullColor;
extern Color trackBarEmptyColor;
// Prepare a map of default settings
struct ThemeDefault { const char* key; const char* value; };
extern const ThemeDefault defaultThemeSettings[];
extern const size_t defaultThemeSettingsCount;
const char* getThemeDefault(const char* key);
bool isValidHexColor(std::string_view hexColor);
// Defined in tesla.cpp — reads theme INI and populates all color vars above
void initializeThemeVars();
void initializeTheme(const std::string& themeIniPath = ult::THEME_CONFIG_INI_PATH);
extern std::vector<std::string> wrapText(
const std::string& text,
float maxWidth,
const std::string& wrappingMode,
bool useIndent,
const std::string& indent,
float indentWidth,
size_t fontSize
);
// Declarations
/**
* @brief Direction in which focus moved before landing on
* the currently focused element
*/
enum class FocusDirection {
None, ///< Focus was placed on the element programatically without user input
Up, ///< Focus moved upwards
Down, ///< Focus moved downwards
Left, ///< Focus moved from left to rigth
Right ///< Focus moved from right to left
};
/**
* @brief Current input controll mode
*
*/
enum class InputMode {
Controller, ///< Input from controller
Touch, ///< Touch input
TouchScroll ///< Moving/scrolling touch input
};
class Overlay;
namespace elm { class Element; }
void shiftItemFocus(elm::Element* element); // forward declare
namespace impl {
/**
* @brief Overlay launch parameters
*/
enum class LaunchFlags : u8 {
None = 0, ///< Do nothing special at launch
CloseOnExit = BIT(0) ///< Close the overlay the last Gui gets poped from the stack
};
static constexpr LaunchFlags operator|(LaunchFlags lhs, LaunchFlags rhs) {
return static_cast<LaunchFlags>(u8(lhs) | u8(rhs));
}
}
void goBack(u32 count = 1);
void pop(u32 count = 1);
void setNextOverlay(const std::string& ovlPath, std::string args = "");
template<typename TOverlay, impl::LaunchFlags launchFlags = impl::LaunchFlags::CloseOnExit>
int loop(int argc, char** argv);
// Helpers
namespace hlp {
/**
* @brief Wrapper for service initialization
*
* @param f wrapped function
*/
template<typename F>
static inline void doWithSmSession(F f) {
smInitialize();
f();
smExit();
}
/**
* @brief Wrapper for sd card access using stdio
* @note Consider using raw fs calls instead as they are faster and need less space
*
* @param f wrapped function
*/
template<typename F>
static inline void doWithSDCardHandle(F f) {
fsdevMountSdmc();
f();
fsdevUnmountDevice("sdmc");
}
/**
* @brief Guard that will execute a passed function at the end of the current scope
*
* @param f wrapped function
*/
template<typename F>
class ScopeGuard {
public:
ScopeGuard(const ScopeGuard&) = delete;
ScopeGuard& operator=(const ScopeGuard&) = delete;
ALWAYS_INLINE explicit ScopeGuard(F func) : f(std::move(func)) { }
ALWAYS_INLINE ~ScopeGuard() { if (!canceled) { f(); } }
void dismiss() { canceled = true; }
private:
F f;
bool canceled = false;
};
/**
* @brief libnx hid:sys shim that gives or takes away frocus to or from the process with the given aruid
*
* @param enable Give focus or take focus
* @param aruid Aruid of the process to focus/unfocus
* @return Result Result
*/
static Result hidsysEnableAppletToGetInput(bool enable, u64 aruid) {
const struct {
u8 permitInput;
u64 appletResourceUserId;
} in = { enable != 0, aruid };
return serviceDispatchIn(hidsysGetServiceSession(), 503, in);
}
static Result viAddToLayerStack(ViLayer *layer, ViLayerStack stack) {
const struct {
u32 stack;
u64 layerId;
} in = { stack, layer->layer_id };
return serviceDispatchIn(viGetSession_IManagerDisplayService(), 6000, in);
}
/**
* @brief Remove layer from layer stack
*/
static Result viRemoveFromLayerStack(ViLayer *layer, ViLayerStack stack) {
const struct {
u32 stack;
u64 layerId;
} in = { stack, layer->layer_id };
// Service command 6001 is commonly used for remove operations
// If this doesn't work, try 6002, 6010, or other nearby values
return serviceDispatchIn(viGetSession_IManagerDisplayService(), 6001, in);
}
/**
* @brief Toggles focus between the Tesla overlay and the rest of the system
*
* @param enabled Focus Tesla?
*/
void requestForeground(bool enabled, bool updateGlobalFlag = true);
// Deprecated code, no longer used but preserved for consistency
namespace ini {
/**
* @brief Ini file type
*/
using IniData = std::map<std::string, std::map<std::string, std::string>>;
/**
* @brief Parses a ini string
*
* @param str String to parse
* @return Parsed data
* // Modified to be "const std" instead of just "std"
*/
static IniData parseIni(const std::string &str) {
return ult::parseIni(str);
}
/**
* @brief Unparses ini data into a string
*
* @param iniData Ini data
* @return Ini string
*/
std::string unparseIni(const IniData &iniData);
/**
* @brief Read Tesla settings file
*
* @return Settings data
*/
static IniData readOverlaySettings(auto& CONFIG_FILE) {
/* Open Sd card filesystem. */
FsFileSystem fsSdmc;
if (R_FAILED(fsOpenSdCardFileSystem(&fsSdmc)))
return {};
hlp::ScopeGuard fsGuard([&] { fsFsClose(&fsSdmc); });
/* Open config file. */
FsFile fileConfig;
if (R_FAILED(fsFsOpenFile(&fsSdmc, CONFIG_FILE, FsOpenMode_Read, &fileConfig)))
return {};
hlp::ScopeGuard fileGuard([&] { fsFileClose(&fileConfig); });
/* Get config file size. */
s64 configFileSize;
if (R_FAILED(fsFileGetSize(&fileConfig, &configFileSize)))
return {};
/* Read and parse config file. */
std::string configFileData(configFileSize, '\0');
u64 readSize;
Result rc = fsFileRead(&fileConfig, 0, configFileData.data(), configFileSize, FsReadOption_None, &readSize);
if (R_FAILED(rc) || readSize != static_cast<u64>(configFileSize))
return {};
return ult::parseIni(configFileData);
}
/**
* @brief Replace Tesla settings file with new data
*
* @param iniData new data
*/
static void writeOverlaySettings(IniData const &iniData, auto& CONFIG_FILE) {
/* Open Sd card filesystem. */
FsFileSystem fsSdmc;
if (R_FAILED(fsOpenSdCardFileSystem(&fsSdmc)))
return;
hlp::ScopeGuard fsGuard([&] { fsFsClose(&fsSdmc); });
/* Open config file. */
FsFile fileConfig;
if (R_FAILED(fsFsOpenFile(&fsSdmc, CONFIG_FILE, FsOpenMode_Write, &fileConfig)))
return;
hlp::ScopeGuard fileGuard([&] { fsFileClose(&fileConfig); });
const std::string iniString = unparseIni(iniData);
fsFileWrite(&fileConfig, 0, iniString.c_str(), iniString.length(), FsWriteOption_Flush);
}
/**
* @brief Merge and save changes into Tesla settings file
*
* @param changes setting values to add or update
*/
static void updateOverlaySettings(IniData const &changes, auto& CONFIG_FILE) {
hlp::ini::IniData iniData = hlp::ini::readOverlaySettings(CONFIG_FILE);
for (auto &section : changes) {
for (auto &keyValue : section.second) {
iniData[section.first][keyValue.first] = keyValue.second;
}
}
writeOverlaySettings(iniData, CONFIG_FILE);
}
}
/**
* @brief Decodes a key string into it's key code
*
* @param value Key string
* @return Key code
*/
static u64 stringToKeyCode(const std::string& value) {
for (const auto& keyInfo : ult::KEYS_INFO) {
if (strcasecmp(value.c_str(), keyInfo.name) == 0)
return keyInfo.key;
}
return 0;
}
/**
* @brief Decodes a combo string into key codes
*
* @param value Combo string
* @return Key codes
*/
static u64 comboStringToKeys(const std::string &value) {
u64 keyCombo = 0x00;
for (const auto& key : ult::split(ult::removeWhiteSpaces(value), '+')) {
keyCombo |= hlp::stringToKeyCode(key);
}
return keyCombo;
}
/**
* @brief Encodes key codes into a combo string
*
* @param keys Key codes
* @return Combo string
*/
std::string keysToComboString(u64 keys);
inline static std::mutex comboMutex;
// Function to load key combo mappings from both overlays.ini and packages.ini
void loadEntryKeyCombos();
// Function to check if a key combination matches any overlay key combo
static OverlayCombo getEntryForKeyCombo(u64 keys) {
std::lock_guard<std::mutex> lock(comboMutex);
if (auto it = ult::g_entryCombos.find(keys); it != ult::g_entryCombos.end())
return it->second;
return { "", "" };
}
}
// Renderer
namespace gfx {
extern "C" u64 __nx_vi_layer_id;
struct ScissoringConfig {
u32 x, y, w, h, x_max, y_max;
};
// Forward declarations
class Renderer;
inline static std::shared_mutex s_translationCacheMutex;
class FontManager {
public:
struct Glyph {
stbtt_fontinfo *currFont;
float currFontSize;
int bounds[4];
int xAdvance;
u8 *glyphBmp;
int width, height;
~Glyph() {
if (glyphBmp) {
stbtt_FreeBitmap(glyphBmp, nullptr);
glyphBmp = nullptr;
}
}
Glyph(const Glyph&) = delete;
Glyph& operator=(const Glyph&) = delete;
Glyph(Glyph&& other) noexcept
: currFont(other.currFont), currFontSize(other.currFontSize)
, xAdvance(other.xAdvance), glyphBmp(other.glyphBmp)
, width(other.width), height(other.height) {
memcpy(bounds, other.bounds, sizeof(bounds));
other.glyphBmp = nullptr;
}
Glyph& operator=(Glyph&& other) noexcept {
if (this != &other) {
if (glyphBmp) stbtt_FreeBitmap(glyphBmp, nullptr);
currFont = other.currFont;
currFontSize = other.currFontSize;
xAdvance = other.xAdvance;
glyphBmp = other.glyphBmp;
width = other.width;
height = other.height;
memcpy(bounds, other.bounds, sizeof(bounds));
other.glyphBmp = nullptr;
}
return *this;
}
Glyph() : currFont(nullptr), currFontSize(0.0f), xAdvance(0),
glyphBmp(nullptr), width(0), height(0) {
std::memset(bounds, 0, sizeof(bounds));
}
};
struct FontMetrics {
int ascent, descent, lineGap;
int lineHeight;
stbtt_fontinfo* font;
float fontSize;
FontMetrics() : ascent(0), descent(0), lineGap(0), lineHeight(0), font(nullptr), fontSize(0.0f) {}
FontMetrics(stbtt_fontinfo* f, float size) : font(f), fontSize(size) {
if (font) {
stbtt_GetFontVMetrics(font, &ascent, &descent, &lineGap);
const float scale = stbtt_ScaleForPixelHeight(font, fontSize);
ascent = static_cast<int>(ascent * scale);
descent = static_cast<int>(descent * scale);
lineGap = static_cast<int>(lineGap * scale);
lineHeight = ascent - descent + lineGap;
} else {
ascent = descent = lineGap = lineHeight = 0;
}
}
};
enum class CacheType { Regular, Notification };
private:
inline static std::shared_mutex s_cacheMutex;
inline static std::mutex s_initMutex;
inline static std::unordered_map<u64, std::shared_ptr<Glyph>> s_sharedGlyphCache;
inline static std::unordered_map<u64, std::shared_ptr<Glyph>> s_notificationGlyphCache;
inline static std::unordered_map<u64, FontMetrics> s_fontMetricsCache;
static constexpr size_t MAX_CACHE_SIZE = 600;
static constexpr size_t CLEANUP_THRESHOLD = 500;
static constexpr size_t MAX_NOTIFICATION_CACHE_SIZE = 200;
inline static stbtt_fontinfo* s_stdFont = nullptr;
inline static stbtt_fontinfo* s_localFont = nullptr;
inline static stbtt_fontinfo* s_localFontCN = nullptr;
inline static stbtt_fontinfo* s_localFontTW = nullptr;
inline static stbtt_fontinfo* s_localFontKO = nullptr;
inline static stbtt_fontinfo* s_extFont = nullptr;
inline static bool s_hasLocalFont = false;
inline static bool s_initialized = false;
static u64 generateCacheKey(u32 character, bool monospace, u32 fontSize) {
u64 key = (static_cast<u64>(character) << 32) | static_cast<u64>(fontSize);
if (monospace) key |= (1ULL << 63);
return key;
}
static u64 generateFontMetricsCacheKey(stbtt_fontinfo* font, u32 fontSize) {
return (reinterpret_cast<uintptr_t>(font) << 32) | static_cast<u64>(fontSize);
}
// Consolidated trim — replaces cleanupOldEntries + cleanupNotificationCache
static void trimCache(std::unordered_map<u64, std::shared_ptr<Glyph>>& cache, size_t target) {
if (cache.size() <= target) return;
size_t toRemove = cache.size() - target;
for (auto it = cache.begin(); toRemove-- && it != cache.end();)
it = cache.erase(it);
}
// Single helper to clear + release bucket memory — used everywhere
template<typename V>
static void clearMap(std::unordered_map<u64, V>& m) {
m.clear();
m.rehash(0);
}
// Assumes lock already held by caller
static void clearAllUnsafe() {
clearMap(s_sharedGlyphCache);
clearMap(s_notificationGlyphCache);
clearMap(s_fontMetricsCache);
}
static std::shared_ptr<Glyph> getOrCreateGlyphInternal(u32 character, bool monospace, u32 fontSize, CacheType cacheType) {
const u64 key = generateCacheKey(character, monospace, fontSize);
auto& targetCache = (cacheType == CacheType::Notification)
? s_notificationGlyphCache : s_sharedGlyphCache;
{
std::shared_lock<std::shared_mutex> readLock(s_cacheMutex);
if (!s_initialized) return nullptr;
auto it = targetCache.find(key);
if (it != targetCache.end()) return it->second;
}
std::unique_lock<std::shared_mutex> writeLock(s_cacheMutex);
if (!s_initialized) return nullptr;
// Double-checked
auto it = targetCache.find(key);
if (it != targetCache.end()) return it->second;
if (cacheType == CacheType::Regular)
trimCache(s_sharedGlyphCache, CLEANUP_THRESHOLD);
else
trimCache(s_notificationGlyphCache, MAX_NOTIFICATION_CACHE_SIZE / 2);
auto glyph = std::make_shared<Glyph>();
glyph->currFont = selectFontForCharacterUnsafe(character);
if (!glyph->currFont) return nullptr;
glyph->currFontSize = stbtt_ScaleForPixelHeight(glyph->currFont, fontSize);
stbtt_GetCodepointBitmapBoxSubpixel(glyph->currFont, character,
glyph->currFontSize, glyph->currFontSize, 0, 0,
&glyph->bounds[0], &glyph->bounds[1], &glyph->bounds[2], &glyph->bounds[3]);
s32 yAdvance = 0;
stbtt_GetCodepointHMetrics(glyph->currFont, monospace ? 'W' : character,
&glyph->xAdvance, &yAdvance);
glyph->glyphBmp = stbtt_GetCodepointBitmap(glyph->currFont,
glyph->currFontSize, glyph->currFontSize, character,
&glyph->width, &glyph->height, nullptr, nullptr);
targetCache[key] = glyph;
return glyph;
}
static stbtt_fontinfo* selectFontForCharacterUnsafe(u32 character) {
if (!s_initialized) return nullptr;
if (stbtt_FindGlyphIndex(s_extFont, character)) return s_extFont;
if (character == 0x00B0) return s_stdFont;
if (s_hasLocalFont && stbtt_FindGlyphIndex(s_localFont, character)) return s_localFont;
if (stbtt_FindGlyphIndex(s_stdFont, character)) return s_stdFont;
if (stbtt_FindGlyphIndex(s_localFontCN, character)) return s_localFontCN;
if (stbtt_FindGlyphIndex(s_localFontTW, character)) return s_localFontTW;
if (stbtt_FindGlyphIndex(s_localFontKO, character)) return s_localFontKO;
return s_stdFont;
}
public:
static void initializeFonts(stbtt_fontinfo* stdFont,
stbtt_fontinfo* localFont,
stbtt_fontinfo* localFontCN,
stbtt_fontinfo* localFontTW,
stbtt_fontinfo* localFontKO,
stbtt_fontinfo* extFont,
bool hasLocalFont) {
std::lock_guard<std::mutex> initLock(s_initMutex);
std::unique_lock<std::shared_mutex> cacheLock(s_cacheMutex);
s_stdFont = stdFont;
s_localFont = localFont;
s_localFontCN = localFontCN;
s_localFontTW = localFontTW;
s_localFontKO = localFontKO;
s_extFont = extFont;
s_hasLocalFont = hasLocalFont;
s_initialized = true;
}
static stbtt_fontinfo* selectFontForCharacter(u32 character) {
std::shared_lock<std::shared_mutex> lock(s_cacheMutex);
return selectFontForCharacterUnsafe(character);
}
static FontMetrics getFontMetrics(stbtt_fontinfo* font, u32 fontSize) {
if (!font) return FontMetrics();
const u64 key = generateFontMetricsCacheKey(font, fontSize);
{
std::shared_lock<std::shared_mutex> readLock(s_cacheMutex);
auto it = s_fontMetricsCache.find(key);
if (it != s_fontMetricsCache.end()) return it->second;
}
std::unique_lock<std::shared_mutex> writeLock(s_cacheMutex);
auto it = s_fontMetricsCache.find(key);
if (it != s_fontMetricsCache.end()) return it->second;
FontMetrics metrics(font, static_cast<float>(fontSize));
s_fontMetricsCache[key] = metrics;
return metrics;
}
static FontMetrics getFontMetricsForCharacter(u32 character, u32 fontSize) {
return getFontMetrics(selectFontForCharacter(character), fontSize);
}
[[nodiscard]] static std::shared_ptr<Glyph> getOrCreateGlyph(u32 character, bool monospace, u32 fontSize) {
return getOrCreateGlyphInternal(character, monospace, fontSize, CacheType::Regular);
}
[[nodiscard]] static std::shared_ptr<Glyph> getOrCreateNotificationGlyph(u32 character, bool monospace, u32 fontSize) {
return getOrCreateGlyphInternal(character, monospace, fontSize, CacheType::Notification);
}
static void clearNotificationCache() {
std::unique_lock<std::shared_mutex> lock(s_cacheMutex);
clearMap(s_notificationGlyphCache);
}
static void clearCache() {
std::unique_lock<std::shared_mutex> lock(s_cacheMutex);
clearMap(s_sharedGlyphCache);
clearMap(s_fontMetricsCache);
}
static void clearAllCaches() {
std::unique_lock<std::shared_mutex> lock(s_cacheMutex);
clearAllUnsafe();
}
static void cleanup() {
std::lock_guard<std::mutex> initLock(s_initMutex);
std::unique_lock<std::shared_mutex> cacheLock(s_cacheMutex);
clearAllUnsafe();
s_initialized = false;
s_stdFont = nullptr;
s_localFont = nullptr;
s_localFontCN = nullptr;
s_localFontTW = nullptr;
s_localFontKO = nullptr;
s_extFont = nullptr;
s_hasLocalFont = false;
}
static bool isInitialized() {
std::shared_lock<std::shared_mutex> lock(s_cacheMutex);
return s_initialized;
}
#ifndef NDEBUG
static size_t getCacheSize() {
std::shared_lock<std::shared_mutex> lock(s_cacheMutex);
return s_sharedGlyphCache.size();
}
static size_t getNotificationCacheSize() {
std::shared_lock<std::shared_mutex> lock(s_cacheMutex);
return s_notificationGlyphCache.size();
}
static size_t getFontMetricsCacheSize() {
std::shared_lock<std::shared_mutex> lock(s_cacheMutex);
return s_fontMetricsCache.size();
}
static size_t getMemoryUsage() {
std::shared_lock<std::shared_mutex> lock(s_cacheMutex);
size_t total = 0;
auto countCache = [&](const std::unordered_map<u64, std::shared_ptr<Glyph>>& cache) {
for (const auto& [k, g] : cache)
if (g && g->glyphBmp) total += g->width * g->height;
};
countCache(s_sharedGlyphCache);
countCache(s_notificationGlyphCache);
return total;
}
#endif
};
// Updated thread-safe calculateStringWidth function
float calculateStringWidth(const std::string& originalString, const float fontSize, const bool monospace = false);
static std::pair<int, int> getUnderscanPixels();
/**
* @brief Manages the Tesla layer and draws raw data to the screen
*/
class Renderer final {
public:
using Glyph = FontManager::Glyph;
Renderer& operator=(Renderer&) = delete;
friend class tsl::Overlay;
/**
* @brief Gets the renderer instance
*
* @return Renderer
*/
inline static Renderer& get() {
static Renderer renderer;
return renderer;
}
stbtt_fontinfo m_stdFont, m_extFont;
stbtt_fontinfo m_localFont; // Primary font based on system language
stbtt_fontinfo m_localFontCN; // Chinese Simplified - always loaded
stbtt_fontinfo m_localFontTW; // Chinese Traditional - always loaded
stbtt_fontinfo m_localFontKO; // Korean - always loaded
bool m_hasLocalFont = false; // Whether primary local font is valid
static inline float s_opacity = 1.0F;
/**
* @brief Handles opacity of drawn colors for fadeout. Pass all colors through this function in order to apply opacity properly
*
* @param c Original color
* @return Color with applied opacity
*/
static inline Color a(const Color& c) {
const u8 opacity_limit = static_cast<u8>(0xF * Renderer::s_opacity);
return (c.rgba & 0x0FFF) | (static_cast<u16>(
ult::disableTransparency
? (ult::useOpaqueScreenshots
? 0xF // fully opaque when both flags on
: (c.a > 0xE ? c.a : 0xE)) // clamp to 14, keep lower values
: (c.a < opacity_limit ? c.a : opacity_limit) // normal fade logic
) << 12);
}
static inline Color aWithOpacity(const Color& c) {
const u8 opacity_limit = static_cast<u8>(0xF * Renderer::s_opacity);
return (c.rgba & 0x0FFF) | (static_cast<u16>(
ult::disableTransparency
? 0xF // fully opaque when both flags on
: (c.a < opacity_limit ? c.a : opacity_limit) // normal fade logic
) << 12);
}
static inline Color a2(const Color& c) {
if (!ult::disableTransparency)
return c;
const u8 a = ult::useOpaqueScreenshots ? 0xF : (c.a > 0xE ? c.a : 0xE);
return (c.rgba & 0x0FFF) | (static_cast<u16>(a) << 12);
}
/**
* @brief Enables scissoring, discarding of any draw outside the given boundaries
*
* @param x x pos
* @param y y pos
* @param w Width
* @param h Height
*/
inline void enableScissoring(const u32 x, const u32 y, const u32 w, const u32 h) {
this->m_scissoringStack.emplace(x, y, w, h, x+w, y+h);
}
/**
* @brief Disables scissoring
*/
inline void disableScissoring() {
this->m_scissoringStack.pop();
}
// Drawing functions
/**
* @brief Draw a single pixel onto the screen
*
* @param x X pos
* @param y Y pos
* @param color Color
*/
inline void setPixel(const u32 x, const u32 y, const Color& color) {
const u32 offset = this->getPixelOffset(x, y);
if (offset != UINT32_MAX) [[likely]] {
Color* framebuffer = static_cast<Color*>(this->getCurrentFramebuffer());
framebuffer[offset] = color;
}
}
inline void setPixelAtOffset(const u32 offset, const Color& color) {
Color* framebuffer = static_cast<Color*>(this->getCurrentFramebuffer());
framebuffer[offset] = color;
}
/**
* @brief Blends two colors
*
* @param src Source color
* @param dst Destination color
* @param alpha Opacity
* @return Blended color
*/
static constexpr u8 inv_alpha_table[16] = {15,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0};
inline u8 __attribute__((always_inline)) blendColor(const u8 src, const u8 dst, const u8 alpha) {
return ((src * inv_alpha_table[alpha]) + (dst * alpha)) >> 4;
}
/**
* @brief Draws a single source blended pixel onto the screen
*
* @param x X pos
* @param y Y pos
* @param color Color
*/
inline void setPixelBlendSrc(const u32 x, const u32 y, const Color& color) {
const u32 offset = this->getPixelOffset(x, y);
if (offset == UINT32_MAX) [[unlikely]]
return;
Color* framebuffer = static_cast<Color*>(this->getCurrentFramebuffer());
const Color src = framebuffer[offset];
// Direct write instead of calling setPixel
framebuffer[offset] = Color(
blendColor(src.r, color.r, color.a),
blendColor(src.g, color.g, color.a),
blendColor(src.b, color.b, color.a),
src.a
);
}
// Compromise version - keep framebuffer lookup but inline the rest
inline void setPixelBlendDst(const u32 x, const u32 y, const Color& color) {
const u32 offset = this->getPixelOffset(x, y);
if (offset == UINT32_MAX) [[unlikely]]
return;
Color* framebuffer = static_cast<Color*>(this->getCurrentFramebuffer());
const Color src = framebuffer[offset];
// Direct write instead of calling setPixel
framebuffer[offset] = Color(
blendColor(src.r, color.r, color.a),
blendColor(src.g, color.g, color.a),
blendColor(src.b, color.b, color.a),
(color.a + (src.a * (0xF - color.a) >> 4))
);
}
// Batch version for setPixelBlendDst
inline void setPixelBlendDstBatch(const u32 baseX, const u32 baseY,
const u8 red[16], const u8 green[16],
const u8 blue[16], const u8 alpha[16],
const s32 count) {
Color* framebuffer = static_cast<Color*>(this->getCurrentFramebuffer());
for (s32 i = 0; i < count; ++i) {
// Early exit for transparent pixels
const u8 currentAlpha = alpha[i];
if (currentAlpha == 0) [[unlikely]]
continue;
const u32 offset = this->getPixelOffset(baseX + i, baseY);
if (offset == UINT32_MAX) [[unlikely]]
continue;
// Direct framebuffer read
const Color src = framebuffer[offset];
const u8 invAlpha = 0xF - currentAlpha;
// Direct framebuffer write - skip setPixelAtOffset call
framebuffer[offset] = Color(
blendColor(src.r, red[i], currentAlpha),
blendColor(src.g, green[i], currentAlpha),
blendColor(src.b, blue[i], currentAlpha),
currentAlpha + ((src.a * invAlpha) >> 4)
);
}
}
/**
* @brief Draws a rectangle of given sizes
*
* @param x X pos
* @param y Y pos
* @param w Width
* @param h Height
* @param color Color
*/
inline void drawRect(const s32 x, const s32 y, const s32 w, const s32 h, const Color& color) {
// Early exit for invalid dimensions
//if (w <= 0 || h <= 0) return;
// Calculate clipped bounds
const s32 x_start = x < 0 ? 0 : x;
const s32 y_start = y < 0 ? 0 : y;
const s32 x_end = (x + w > cfg::FramebufferWidth) ? cfg::FramebufferWidth : x + w;
const s32 y_end = (y + h > cfg::FramebufferHeight) ? cfg::FramebufferHeight : y + h;
// Early exit if completely outside bounds
if (x_start >= x_end || y_start >= y_end) [[unlikely]] return;
this->processRectChunk(x_start, x_end, y_start, y_end, color);
}
/**
* @brief Worker function for multithreaded rectangle drawing
* @param x_start Start X coordinate
* @param x_end End X coordinate
* @param y_start Start Y coordinate for this thread
* @param y_end End Y coordinate for this thread
* @param color Color to draw
*/
inline void processRectChunk(const s32 x_start, const s32 x_end, const s32 y_start, const s32 y_end, const Color& color) {
for (s32 yi = y_start; yi < y_end; ++yi) {
for (s32 xi = x_start; xi < x_end; ++xi) {
this->setPixelBlendDst(xi, yi, color);
}
}
}
/**
* @brief Draws a rectangle of given sizes (Multi-threaded)
*
* @param x X pos
* @param y Y pos
* @param w Width
* @param h Height
* @param color Color
*/
inline void drawRectMultiThreaded(const s32 x, const s32 y, const s32 w, const s32 h, const Color& color) {
// Early exit for invalid dimensions
if (w <= 0 || h <= 0) return;
// Calculate clipped bounds
const s32 x_start = x < 0 ? 0 : x;
const s32 y_start = y < 0 ? 0 : y;
const s32 x_end = (x + w > cfg::FramebufferWidth) ? cfg::FramebufferWidth : x + w;
const s32 y_end = (y + h > cfg::FramebufferHeight) ? cfg::FramebufferHeight : y + h;
// Early exit if completely outside bounds
if (x_start >= x_end || y_start >= y_end) return;
// Calculate visible dimensions
const s32 visibleHeight = y_end - y_start;
// Calculate chunk size - divide rows among threads
const s32 chunkSize = std::max(1, visibleHeight / static_cast<s32>(ult::numThreads));
// Launch threads using ult::renderThreads array
for (unsigned i = 0; i < static_cast<unsigned>(ult::numThreads); ++i) {
const s32 startRow = y_start + (i * chunkSize);
const s32 endRow = (i == static_cast<unsigned>(ult::numThreads) - 1) ?
y_end :
std::min(startRow + chunkSize, y_end);
// Skip threads that have no work
if (startRow >= endRow) {
ult::renderThreads[i] = std::thread([](){}); // Empty thread (still needed for joining)
continue;
}
// Use member function instead of lambda - much faster
ult::renderThreads[i] = std::thread(&Renderer::processRectChunk, this,
x_start, x_end, startRow, endRow, color);
}
// Join all ult::renderThreads
for (auto& t : ult::renderThreads) {
t.join();
}
}
inline void drawRectAdaptive(s32 x, s32 y, s32 w, s32 h, const Color& color) {
if (ult::expandedMemory)
drawRectMultiThreaded(x, y, w, h, color);
else
drawRect(x, y, w, h, color);
}
/**
* @brief Draws a rectangle of given sizes with empty filling
*
* @param x X pos
* @param y Y pos
* @param w Width
* @param h Height
* @param color Color
*/
inline void drawEmptyRect(s32 x, s32 y, s32 w, s32 h, Color color) {
// Only precompute values that are actually reused
const s32 x_end = x + w - 1;
const s32 y_end = y + h - 1;
// Early exit for completely out-of-bounds rectangles
if (x_end < 0 || y_end < 0 || x >= cfg::FramebufferWidth || y >= cfg::FramebufferHeight) [[unlikely]] {
return;
}
// These are reused for both horizontal lines
const s32 line_x_start = x < 0 ? 0 : x;
const s32 line_x_end = x_end >= cfg::FramebufferWidth ? cfg::FramebufferWidth - 1 : x_end;
// Draw top horizontal line
if (y >= 0 && y < cfg::FramebufferHeight) {
for (s32 xi = line_x_start; xi <= line_x_end; ++xi) {
this->setPixelBlendDst(xi, y, color);
}
}
// Draw bottom horizontal line (only if different from top)
if (h > 1 && y_end >= 0 && y_end < cfg::FramebufferHeight) {
for (s32 xi = line_x_start; xi <= line_x_end; ++xi) {
this->setPixelBlendDst(xi, y_end, color);
}
}
// Draw vertical lines only if there's space between horizontal lines
if (h > 2) {
// These are reused for both vertical lines
const s32 line_y_start = (y + 1) < 0 ? 0 : (y + 1);
const s32 line_y_end = (y_end - 1) >= cfg::FramebufferHeight ? cfg::FramebufferHeight - 1 : (y_end - 1);
// Only proceed if there are actually vertical pixels to draw
if (line_y_start <= line_y_end) {
// Left vertical line
if (x >= 0 && x < cfg::FramebufferWidth) {
for (s32 yi = line_y_start; yi <= line_y_end; ++yi) {
this->setPixelBlendDst(x, yi, color);
}
}
// Right vertical line (only if different from left)
if (w > 1 && x_end >= 0 && x_end < cfg::FramebufferWidth) {
for (s32 yi = line_y_start; yi <= line_y_end; ++yi) {
this->setPixelBlendDst(x_end, yi, color);
}
}
}
}
}
/**
* @brief Draws a line
*
* @param x0 Start X pos
* @param y0 Start Y pos
* @param x1 End X pos
* @param y1 End Y pos
* @param color Color
*/
inline void drawLine(s32 x0, s32 y0, s32 x1, s32 y1, Color color) {
// Early exit for single point
if (x0 == x1 && y0 == y1) {
if (x0 >= 0 && y0 >= 0 && x0 < cfg::FramebufferWidth && y0 < cfg::FramebufferHeight) {
this->setPixelBlendDst(x0, y0, color);
}
return;
}
// Calculate deltas
const s32 dx = x1 - x0;
const s32 dy = y1 - y0;
// Calculate absolute deltas and steps
const s32 abs_dx = dx < 0 ? -dx : dx;
const s32 abs_dy = dy < 0 ? -dy : dy;
const s32 step_x = dx < 0 ? -1 : 1;
const s32 step_y = dy < 0 ? -1 : 1;
// Bresenham's algorithm
s32 x = x0, y = y0;
s32 error = abs_dx - abs_dy;
s32 error2;
while (true) {
// Bounds check and draw pixel
if (x >= 0 && y >= 0 && x < cfg::FramebufferWidth && y < cfg::FramebufferHeight) {
this->setPixelBlendDst(x, y, color);
}
// Check if we've reached the end point
if (x == x1 && y == y1) break;
// Calculate error and step
error2 = error << 1; // error * 2
if (error2 > -abs_dy) {
error -= abs_dy;
x += step_x;
}
if (error2 < abs_dx) {
error += abs_dx;
y += step_y;
}
}
}
/**
* @brief Draws a dashed line
*
* @param x0 Start X pos
* @param y0 Start Y pos
* @param x1 End X pos
* @param y1 End Y pos
* @param line_width How long one line can be
* @param color Color
*/
inline void drawDashedLine(s32 x0, s32 y0, s32 x1, s32 y1, s32 line_width, Color color) {
// Source of formula: https://www.cc.gatech.edu/grads/m/Aaron.E.McClennen/Bresenham/code.html
const s32 x_min = std::min(x0, x1);
const s32 x_max = std::max(x0, x1);
const s32 y_min = std::min(y0, y1);
const s32 y_max = std::max(y0, y1);
if (x_min < 0 || y_min < 0 || x_min >= cfg::FramebufferWidth || y_min >= cfg::FramebufferHeight)
return;
const s32 dx = x_max - x_min;
const s32 dy = y_max - y_min;
s32 d = 2 * dy - dx;
const s32 incrE = 2*dy;
const s32 incrNE = 2*(dy - dx);
this->setPixelBlendDst(x_min, y_min, color);
s32 x = x_min;
s32 y = y_min;
s32 rendered = 0;
while(x < x1) {
if (d <= 0) {
d += incrE;
x++;
}
else {
d += incrNE;
x++;
y++;
}
rendered++;
if (x < 0 || y < 0 || x >= cfg::FramebufferWidth || y >= cfg::FramebufferHeight)
continue;
if (x <= x_max && y <= y_max) {
if (rendered > 0 && rendered < line_width) {
this->setPixelBlendDst(x, y, color);
}
else if (rendered > 0 && rendered >= line_width) {
rendered *= -1;
}
}
}
}
inline void drawCircle(const s32 centerX, const s32 centerY, const u16 radius, const bool filled, const Color& color) {
// Use Bresenham-style algorithm for small radii
if (radius <= 3) {
s32 x = radius;
s32 y = 0;
s32 radiusError = 0;
s32 xChange = 1 - (radius << 1);
s32 yChange = 0;
while (x >= y) {
if (filled) {
for (s32 i = centerX - x; i <= centerX + x; i++) {
this->setPixelBlendDst(i, centerY + y, color);
this->setPixelBlendDst(i, centerY - y, color);
}
for (s32 i = centerX - y; i <= centerX + y; i++) {
this->setPixelBlendDst(i, centerY + x, color);
this->setPixelBlendDst(i, centerY - x, color);
}
y++;
radiusError += yChange;
yChange += 2;
if (((radiusError << 1) + xChange) > 0) {
x--;
radiusError += xChange;
xChange += 2;
}
} else {
this->setPixelBlendDst(centerX + x, centerY + y, color);
this->setPixelBlendDst(centerX + y, centerY + x, color);
this->setPixelBlendDst(centerX - y, centerY + x, color);
this->setPixelBlendDst(centerX - x, centerY + y, color);
this->setPixelBlendDst(centerX - x, centerY - y, color);
this->setPixelBlendDst(centerX - y, centerY - x, color);
this->setPixelBlendDst(centerX + y, centerY - x, color);
this->setPixelBlendDst(centerX + x, centerY - y, color);
if (radiusError <= 0) {
y++;
radiusError += 2 * y + 1;
} else {
x--;
radiusError -= 2 * x + 1;
}
}
}
return;
}
// Original supersampling algorithm for larger radii
const float r_f = static_cast<float>(radius);
const float r2 = r_f * r_f;
const u8 base_a = color.a;
const bool full_opacity = (base_a == 0xFF);
const s32 bound = radius + 2;
const s32 clip_left = std::max(0, centerX - bound);
const s32 clip_right = std::min(static_cast<s32>(cfg::FramebufferWidth), centerX + bound);
const s32 clip_top = std::max(0, centerY - bound);
const s32 clip_bottom = std::min(static_cast<s32>(cfg::FramebufferHeight), centerY + bound);
const float offset = 0.353553f; // sqrt(2)/4
const float samples[8][2] = {
{-offset, -offset}, {offset, -offset},
{-offset, offset}, {offset, offset},
{-0.5f, 0.0f}, {0.5f, 0.0f},
{0.0f, -0.5f}, {0.0f, 0.5f}
};
for (s32 yc = clip_top; yc < clip_bottom; ++yc) {
const float py = static_cast<float>(yc - centerY) + 0.5f;
const float py_sq = py * py;
for (s32 xc = clip_left; xc < clip_right; ++xc) {
const float px = static_cast<float>(xc - centerX) + 0.5f;
const float px_sq = px * px;
const float center_d2 = px_sq + py_sq;
if (filled) {
if (center_d2 <= r2 - r_f) {
const u32 off = this->getPixelOffset(xc, yc);
if (off != UINT32_MAX) {
if (full_opacity) this->setPixelAtOffset(off, color);
else this->setPixelBlendDst(xc, yc, color);
}
continue;
} else if (center_d2 > r2 + r_f) {
continue;
}
u32 inside_count = 0;
for (u32 s = 0; s < 8; ++s) {
const float sx = px + samples[s][0];
const float sy = py + samples[s][1];
if (sx*sx + sy*sy <= r2) {
inside_count++;
}
}
if (inside_count > 0) {
const u32 off = this->getPixelOffset(xc, yc);
if (off != UINT32_MAX) {
Color c = color;
c.a = static_cast<u8>((base_a * inside_count + 4) / 8);
this->setPixelBlendDst(xc, yc, c);
}
}
} else {
const float inner_r2 = (r_f - 1.0f) * (r_f - 1.0f);
if (center_d2 >= inner_r2 + r_f && center_d2 <= r2 - r_f) {
const u32 off = this->getPixelOffset(xc, yc);
if (off != UINT32_MAX) {
if (full_opacity) this->setPixelAtOffset(off, color);
else this->setPixelBlendDst(xc, yc, color);
}
continue;
} else if (center_d2 < inner_r2 - r_f || center_d2 > r2 + r_f) {
continue;
}
u32 inside_count = 0;
for (u32 s = 0; s < 8; ++s) {
const float sx = px + samples[s][0];
const float sy = py + samples[s][1];
const float sd2 = sx*sx + sy*sy;
if (sd2 >= inner_r2 && sd2 <= r2) {
inside_count++;
}
}
if (inside_count > 0) {
const u32 off = this->getPixelOffset(xc, yc);
if (off != UINT32_MAX) {
Color c = color;
c.a = static_cast<u8>((base_a * inside_count + 4) / 8);
this->setPixelBlendDst(xc, yc, c);
}
}
}
}
}
}
inline void drawBorderedRoundedRect(const s32 x, const s32 y, const s32 width, const s32 height, const s32 thickness, const s32 radius, const Color& highlightColor) {
const s32 startX = x + 4;
const s32 startY = y;
const s32 adjustedWidth = width - 12;
const s32 adjustedHeight = height + 1;
// Pre-calculate corner positions
const s32 leftCornerX = startX;
const s32 rightCornerX = x + width - 9;
const s32 topCornerY = startY;
const s32 bottomCornerY = startY + height;
// Draw borders
this->drawRect(startX, startY - thickness, adjustedWidth, thickness, highlightColor);
this->drawRect(startX, startY + adjustedHeight, adjustedWidth, thickness, highlightColor);
this->drawRect(startX - thickness, startY, thickness, adjustedHeight, highlightColor);
this->drawRect(startX + adjustedWidth, startY, thickness, adjustedHeight, highlightColor);
// Pre-calculate AA colors once
const Color aaColor1 = {highlightColor.r, highlightColor.g, highlightColor.b, static_cast<u8>(highlightColor.a >> 1)}; // 50%
const Color aaColor2 = {highlightColor.r, highlightColor.g, highlightColor.b, static_cast<u8>(highlightColor.a >> 2)}; // 25%
// Circle drawing with AA - optimized Bresenham
s32 cx = radius;
s32 cy = 0;
s32 radiusError = 0;
const s32 diameter = radius << 1;
s32 xChange = 1 - diameter;
s32 yChange = 0;
s32 lastCx = cx;
while (cx >= cy) {
// Pre-calculate Y coordinates (hoist invariants)
const s32 topY1 = topCornerY - cy;
const s32 topY2 = topCornerY - cx;
const s32 bottomY1 = bottomCornerY + cy;
const s32 bottomY2 = bottomCornerY + cx;
// Pre-calculate X bounds
const s32 leftX1Start = leftCornerX - cx;
const s32 leftX2Start = leftCornerX - cy;
const s32 rightX1Start = rightCornerX + 1;
const s32 rightX1End = rightCornerX + cx;
const s32 rightX2End = rightCornerX + cy;
// Draw filled spans - NOW PERFECTLY MIRRORED
// Upper-left corner (exclusive)
for (s32 i = leftX1Start; i < leftCornerX; i++) {
this->setPixelBlendDst(i, topY1, highlightColor);
}
for (s32 i = leftX2Start; i < leftCornerX; i++) {
this->setPixelBlendDst(i, topY2, highlightColor);
}
// Lower-left corner (NOW exclusive like top)
for (s32 i = leftX1Start; i < leftCornerX; i++) {
this->setPixelBlendDst(i, bottomY1, highlightColor);
}
for (s32 i = leftX2Start; i < leftCornerX; i++) {
this->setPixelBlendDst(i, bottomY2, highlightColor);
}
// Upper-right corner (starts at +1)
for (s32 i = rightX1Start; i <= rightX1End; i++) {
this->setPixelBlendDst(i, topY1, highlightColor);
}
for (s32 i = rightX1Start; i <= rightX2End; i++) {
this->setPixelBlendDst(i, topY2, highlightColor);
}
// Lower-right corner (NOW starts at +1 like top)
for (s32 i = rightX1Start; i <= rightX1End; i++) {
this->setPixelBlendDst(i, bottomY1, highlightColor);
}
for (s32 i = rightX1Start; i <= rightX2End; i++) {
this->setPixelBlendDst(i, bottomY2, highlightColor);
}
// Add AA at step transitions
if (__builtin_expect(cx != lastCx && cy > 0, 0)) {
// Pre-calculate AA pixel positions
const s32 cxAA = cx + 1;
// Upper-left AA
this->setPixelBlendDst(leftCornerX - cxAA, topY1, aaColor1);
this->setPixelBlendDst(leftCornerX - cxAA, topY1 + 1, aaColor2);
this->setPixelBlendDst(leftX2Start, topY2 - 1, aaColor1);
this->setPixelBlendDst(leftX2Start + 1, topY2 - 1, aaColor2);
// Upper-right AA
this->setPixelBlendDst(rightCornerX + cxAA, topY1, aaColor1);
this->setPixelBlendDst(rightCornerX + cxAA, topY1 + 1, aaColor2);
this->setPixelBlendDst(rightX2End, topY2 - 1, aaColor1);
this->setPixelBlendDst(rightX2End - 1, topY2 - 1, aaColor2);
// Lower-left AA
this->setPixelBlendDst(leftCornerX - cxAA, bottomY1, aaColor1);
this->setPixelBlendDst(leftCornerX - cxAA, bottomY1 - 1, aaColor2);
this->setPixelBlendDst(leftX2Start, bottomY2 + 1, aaColor1);
this->setPixelBlendDst(leftX2Start + 1, bottomY2 + 1, aaColor2);
// Lower-right AA
this->setPixelBlendDst(rightCornerX + cxAA, bottomY1, aaColor1);
this->setPixelBlendDst(rightCornerX + cxAA, bottomY1 - 1, aaColor2);
this->setPixelBlendDst(rightX2End, bottomY2 + 1, aaColor1);
this->setPixelBlendDst(rightX2End - 1, bottomY2 + 1, aaColor2);
}
lastCx = cx;
// Bresenham iteration - optimized
cy++;
radiusError += yChange;
yChange += 2;
if (__builtin_expect(((radiusError << 1) + xChange) > 0, 0)) {
cx--;
radiusError += xChange;
xChange += 2;
}
}
}
// Pre-compute all horizontal spans for the entire shape
struct HorizontalSpan {
s32 start_x, end_x;
};
// Helper function - defined outside, compiler will inline
static inline void sampleAndBlendArcPixel(Renderer* self, s32 xp, s32 yc,
int px2, int cx2, int sx, int py2, int cy2, int sy,
long long r2_scaled, const Color& color, u8 base_a) {
int hits = 0;
const long long dx1 = px2 + sx - cx2;
const long long dx2 = px2 - sx - cx2;
const long long dy1 = py2 + sy - cy2;
const long long dy2 = py2 - sy - cy2;
if (dx1*dx1 + dy1*dy1 <= r2_scaled) ++hits;
if (dx1*dx1 + dy2*dy2 <= r2_scaled) ++hits;
if (dx2*dx2 + dy1*dy1 <= r2_scaled) ++hits;
if (dx2*dx2 + dy2*dy2 <= r2_scaled) ++hits;
if (hits == 4) {
self->setPixelBlendDst(xp, yc, color);
} else if (hits > 0) {
u8 a = (base_a * hits + 2) >> 2;
if (a) {
Color c = color;
c.a = a;
self->setPixelBlendDst(xp, yc, c);
}
}
}
static void processRoundedRectChunk(Renderer* self, const s32 x, const s32 y, const s32 w, const s32 h,
const s32 radius, const Color& color,
const s32 startRow, const s32 endRow) {
if (radius <= 0) return;
const s32 x_end = x + w;
const s32 y_end = y + h;
const s32 clip_x = std::max(0, x);
const s32 clip_x_end = std::min<s32>(cfg::FramebufferWidth, x_end);
const s32 left_arc_end = x + radius - 1;
const s32 right_arc_start = x_end - radius;
const s32 top_arc_end = y + radius - 1;
const s32 bottom_arc_start = y_end - radius;
const int cx2_left = 2 * (x + radius);
const int cx2_right = 2 * (x_end - radius);
const int cy2_top = 2 * (y + radius);
const int cy2_bottom = 2 * (y_end - radius);
const long long r2_scaled = 4LL * radius * radius;
const long long reject_threshold = (2LL*radius + 2)*(2LL*radius + 2);
const u8 base_a = color.a;
// Pre-compute sample offsets (constant per corner)
const int sx_left = ((x + radius) & 1) ? -1 : 1;
const int sx_right = ((x_end - radius) & 1) ? -1 : 1;
const int sy_top = ((y + radius) & 1) ? -1 : 1;
const int sy_bottom = ((y_end - radius) & 1) ? -1 : 1;
alignas(64) u8 redArray[512], greenArray[512], blueArray[512], alphaArray[512];
const uint8x16_t rv = vdupq_n_u8(color.r);
const uint8x16_t gv = vdupq_n_u8(color.g);
const uint8x16_t bv = vdupq_n_u8(color.b);
const uint8x16_t av = vdupq_n_u8(color.a);
for (int i = 0; i < 512; i += 16) {
vst1q_u8(redArray + i, rv);
vst1q_u8(greenArray + i, gv);
vst1q_u8(blueArray + i, bv);
vst1q_u8(alphaArray + i, av);
}
for (s32 yc = startRow; yc < endRow; ++yc) {
if (yc < y || yc >= y_end) continue;
const bool is_top = (yc <= top_arc_end);
const bool in_arc_rows = is_top || (yc >= bottom_arc_start);
if (!in_arc_rows) {
s32 xs = std::max(clip_x, x);
s32 xe = std::min(clip_x_end, x_end);
for (s32 xp = xs; xp < xe; xp += 512)
self->setPixelBlendDstBatch(xp, yc, redArray, greenArray, blueArray, alphaArray,
std::min(512, xe - xp));
continue;
}
const int cy2 = is_top ? cy2_top : cy2_bottom;
const int py2 = 2 * yc + 1;
const int sy = is_top ? sy_top : sy_bottom;
// Quick row reject
const long long dy = py2 - cy2;
if (dy * dy > reject_threshold) continue;
const s32 xe = std::min(clip_x_end, x_end);
s32 xp = std::max(clip_x, x);
// Left arc
for (; xp <= left_arc_end && xp < xe; ++xp) {
sampleAndBlendArcPixel(self, xp, yc, 2*xp + 1, cx2_left, sx_left,
py2, cy2, sy, r2_scaled, color, base_a);
}
// Middle flat
s32 mid_start = std::max(xp, left_arc_end + 1);
s32 mid_end = std::min(xe, right_arc_start);
if (mid_start < mid_end) {
for (s32 bx = mid_start; bx < mid_end; bx += 512)
self->setPixelBlendDstBatch(bx, yc, redArray, greenArray, blueArray, alphaArray,
std::min(512, mid_end - bx));
}
// Right arc
xp = std::max(xp, right_arc_start);
for (; xp < xe; ++xp) {
sampleAndBlendArcPixel(self, xp, yc, 2*xp + 1, cx2_right, sx_right,
py2, cy2, sy, r2_scaled, color, base_a);
}
}
}
/**
* @brief Draws a rounded rectangle of given sizes and corner radius (Multi-threaded)
*
* @param x X pos
* @param y Y pos
* @param w Width
* @param h Height
* @param radius Corner radius
* @param color Color
*/
inline void drawRoundedRectMultiThreaded(const s32 x, const s32 y, const s32 w, const s32 h, const s32 radius, const Color& color) {
if (w <= 0 || h <= 0) return;
// Calculate clipped bounds for early exit check
const s32 clampedX = std::max(0, x);
const s32 clampedY = std::max(0, y);
const s32 clampedXEnd = std::min(static_cast<s32>(cfg::FramebufferWidth), x + w);
const s32 clampedYEnd = std::min(static_cast<s32>(cfg::FramebufferHeight), y + h);
// Early exit if nothing to draw after clamping
if (clampedX >= clampedXEnd || clampedY >= clampedYEnd) return;
// Calculate visible dimensions
const s32 visibleHeight = clampedYEnd - clampedY;
// Dynamic chunk size based on visible rectangle height
const s32 chunkSize = std::max(1, visibleHeight / (static_cast<s32>(ult::numThreads) * 2));
std::atomic<s32> currentRow(clampedY);
auto threadTask = [&]() {
s32 startRow, endRow;
while ((startRow = currentRow.fetch_add(chunkSize)) < clampedYEnd) {
endRow = std::min(startRow + chunkSize, clampedYEnd);
processRoundedRectChunk(this, x, y, w, h, radius, color, startRow, endRow);
}
};
// Launch threads using ult::renderThreads array
for (unsigned i = 0; i < static_cast<unsigned>(ult::numThreads); ++i) {
ult::renderThreads[i] = std::thread(threadTask);
}
// Join all ult::renderThreads
for (auto& t : ult::renderThreads) {
t.join();
}
}
/**
* @brief Draws a rounded rectangle of given sizes and corner radius (Single-threaded)
*
* @param x X pos
* @param y Y pos
* @param w Width
* @param h Height
* @param radius Corner radius
* @param color Color
*/
inline void drawRoundedRectSingleThreaded(s32 x, s32 y, s32 w, s32 h, s32 radius, const Color& color) {
if (w <= 0 || h <= 0) return;
const s32 clampedY = std::max(0, y);
const s32 clampedYEnd = std::min(static_cast<s32>(cfg::FramebufferHeight), y + h);
// Early exit if nothing to draw after clamping
if (x + w <= 0 || x >= static_cast<s32>(cfg::FramebufferWidth) || clampedY >= clampedYEnd)
return;
processRoundedRectChunk(this, x, y, w, h, radius, color, clampedY, clampedYEnd);
}
inline void drawRoundedRect(s32 x, s32 y, s32 w, s32 h, s32 radius, Color color) {
if (ult::expandedMemory)
drawRoundedRectMultiThreaded(x, y, w, h, radius, color);
else
drawRoundedRectSingleThreaded(x, y, w, h, radius, color);
}
inline void drawUniformRoundedRect(const s32 x, const s32 y, const s32 w, const s32 h, const Color& color) {
const s32 radius = h >> 1;
const s32 clip_left = std::max(0, x);
const s32 clip_top = std::max(0, y);
const s32 clip_right = std::min(static_cast<s32>(cfg::FramebufferWidth), x + w);
const s32 clip_bottom = std::min(static_cast<s32>(cfg::FramebufferHeight), y + h);
if (clip_left >= clip_right || clip_top >= clip_bottom) return;
const s32 x_end = x + w;
const s32 y_end = y + h;
const s32 corner_x_left = x + radius;
const s32 corner_x_right = x_end - radius - 1;
const s32 corner_y_top = y + radius;
const s32 corner_y_bottom = y_end - radius - 1;
const float r_f = static_cast<float>(radius);
const float r2 = r_f * r_f;
const float aa_thresh = r2 + 2.0f * r_f + 1.0f;
const u8 base_a = color.a;
const bool full_opacity = (base_a == 0xF);
for (s32 yc = clip_top; yc < clip_bottom; ++yc) {
if (yc < y || yc >= y_end) continue;
const bool in_corners = yc < corner_y_top || yc > corner_y_bottom;
if (!in_corners) {
const s32 span_start = std::max(x, clip_left);
const s32 span_end = std::min(x_end, clip_right);
for (s32 xc = span_start; xc < span_end; ++xc) {
const u32 off = this->getPixelOffset(xc, yc);
if (off != UINT32_MAX) {
if (full_opacity) {
this->setPixelAtOffset(off, color);
} else {
this->setPixelBlendDst(xc, yc, color);
}
}
}
} else {
const float dy = (yc < corner_y_top) ? static_cast<float>(corner_y_top - yc) :
static_cast<float>(yc - corner_y_bottom);
const float dy_sq = dy * dy;
if (dy_sq > aa_thresh) continue;
const float dy_half = dy - 0.5f;
const float dy_half_sq = dy_half * dy_half;
const s32 span_start = std::max(x, clip_left);
const s32 span_end = std::min(x_end, clip_right);
s32 xc = span_start;
// Left corner/edge
const s32 left_end = std::min(corner_x_left + 1, span_end);
for (; xc < left_end; ++xc) {
const float dx = static_cast<float>(corner_x_left - xc);
const float dx_sq = dx * dx;
const float d2 = dx_sq + dy_sq;
if (d2 <= r2) {
const u32 off = this->getPixelOffset(xc, yc);
if (off != UINT32_MAX) {
if (full_opacity) this->setPixelAtOffset(off, color);
else this->setPixelBlendDst(xc, yc, color);
}
} else if (d2 <= aa_thresh) {
const float dx_half = dx - 0.5f;
float cov = 0.0f;
if (dx_sq + dy_sq <= r2) cov += 0.25f;
if (dx_half*dx_half + dy_sq <= r2) cov += 0.25f;
if (dx_sq + dy_half_sq <= r2) cov += 0.25f;
if (dx_half*dx_half + dy_half_sq <= r2) cov += 0.25f;
if (cov > 0.0f) {
const u32 off = this->getPixelOffset(xc, yc);
if (off != UINT32_MAX) {
Color c = color;
c.a = static_cast<u8>((base_a * static_cast<u8>(cov * 15.0f + 0.5f)) / 15);
this->setPixelBlendDst(xc, yc, c);
}
}
}
}
// Middle section
const s32 mid_end = std::min(corner_x_right, span_end);
for (; xc < mid_end; ++xc) {
const u32 off = this->getPixelOffset(xc, yc);
if (off != UINT32_MAX) {
if (full_opacity) this->setPixelAtOffset(off, color);
else this->setPixelBlendDst(xc, yc, color);
}
}
// Right corner/edge
for (; xc < span_end; ++xc) {
const float dx = static_cast<float>(xc - corner_x_right);
const float dx_sq = dx * dx;
const float d2 = dx_sq + dy_sq;
if (d2 <= r2) {
const u32 off = this->getPixelOffset(xc, yc);
if (off != UINT32_MAX) {
if (full_opacity) this->setPixelAtOffset(off, color);
else this->setPixelBlendDst(xc, yc, color);
}
} else if (d2 <= aa_thresh) {
const float dx_half = dx - 0.5f;
float cov = 0.0f;
if (dx_sq + dy_sq <= r2) cov += 0.25f;
if (dx_half*dx_half + dy_sq <= r2) cov += 0.25f;
if (dx_sq + dy_half_sq <= r2) cov += 0.25f;
if (dx_half*dx_half + dy_half_sq <= r2) cov += 0.25f;
if (cov > 0.0f) {
const u32 off = this->getPixelOffset(xc, yc);
if (off != UINT32_MAX) {
Color c = color;
c.a = static_cast<u8>((base_a * static_cast<u8>(cov * 15.0f + 0.5f)) / 15);
this->setPixelBlendDst(xc, yc, c);
}
}
}
}
}
}
}
// RGBA4444 processing - no expansion needed
const uint8x16_t mask_low = vdupq_n_u8(0x0F);
inline void processBMPChunk(const u32 x, const u32 y, const s32 imageW, const u8 *preprocessedData,
const s32 startRow, const s32 endRow, const u8 globalAlphaLimit,
const bool useBarrier = true, const bool preserveAlpha = false) {
const s32 bytesPerRow = imageW * 2;
const s32 endX16 = imageW & ~15;
const uint8x16_t alpha_limit_vec = vdupq_n_u8(globalAlphaLimit);
Color* const framebuffer = static_cast<Color*>(this->getCurrentFramebuffer());
const bool hasScissor = !this->m_scissoringStack.empty();
const auto scissor = hasScissor ? this->m_scissoringStack.top() : ScissoringConfig{};
for (s32 y1 = startRow; y1 < endRow; ++y1) {
const u32 baseY = y + y1;
if (hasScissor && (baseY < scissor.y || baseY >= scissor.y_max)) [[unlikely]]
continue;
const u32 yPart = ((((baseY & 127) >> 4) + ((baseY >> 7) * offsetWidthVar)) << 9)
+ ((baseY & 8) << 5) + ((baseY & 6) << 4) + ((baseY & 1) << 3);
const u8 *rowPtr = preprocessedData + (y1 * bytesPerRow);
s32 x1 = 0;
for (; x1 < endX16; x1 += 16) {
const u8* ptr = rowPtr + (x1 << 1);
uint8x16x2_t packed = vld2q_u8(ptr);
uint8x16_t high1 = vshrq_n_u8(packed.val[0], 4);
uint8x16_t low1 = vandq_u8(packed.val[0], mask_low);
uint8x16_t high2 = vshrq_n_u8(packed.val[1], 4);
uint8x16_t low2 = vminq_u8(vandq_u8(packed.val[1], mask_low), alpha_limit_vec);
alignas(16) u8 red_vals[16], green_vals[16], blue_vals[16], alpha_vals[16];
vst1q_u8(red_vals, high1);
vst1q_u8(green_vals, low1);
vst1q_u8(blue_vals, high2);
vst1q_u8(alpha_vals, low2);
const u32 baseX = x + x1;
for (int i = 0; i < 16; ++i) {
const u8 a = alpha_vals[i];
if (a == 0) [[unlikely]] continue;
const u32 px = baseX + i;
if (hasScissor && (px < scissor.x || px >= scissor.x_max)) [[unlikely]] continue;
const u32 offset = yPart + ((px >> 5) << 12)
+ ((px & 16) << 3) + ((px & 8) << 1) + (px & 7);
const Color src = framebuffer[offset];
framebuffer[offset] = {
blendColor(src.r, red_vals[i], a),
blendColor(src.g, green_vals[i], a),
blendColor(src.b, blue_vals[i], a),
static_cast<u8>(preserveAlpha ? src.a : (a + ((src.a * (0xF - a)) >> 4)))
};
}
}
for (; x1 < imageW; ++x1) {
const u8 p1 = rowPtr[x1 << 1];
const u8 p2 = rowPtr[(x1 << 1) + 1];
const u8 alpha = std::min(static_cast<u8>(p2 & 0x0F), globalAlphaLimit);
if (alpha == 0) [[unlikely]] continue;
const u32 px = x + x1;
if (hasScissor && (px < scissor.x || px >= scissor.x_max)) [[unlikely]] continue;
const u32 offset = yPart + ((px >> 5) << 12)
+ ((px & 16) << 3) + ((px & 8) << 1) + (px & 7);
const Color bg = framebuffer[offset];
framebuffer[offset] = {
blendColor(bg.r, static_cast<u8>(p1 >> 4), alpha),
blendColor(bg.g, static_cast<u8>(p1 & 0x0F), alpha),
blendColor(bg.b, static_cast<u8>(p2 >> 4), alpha),
static_cast<u8>(preserveAlpha ? bg.a : (alpha + ((bg.a * (0xF - alpha)) >> 4)))
};
}
}
if (useBarrier)
ult::inPlotBarrier.arrive_and_wait();
}
inline void drawBitmapRGBA4444(const u32 x, const u32 y, const u32 imageW, const u32 imageH,
const u8 *preprocessedData, float opacity = 1.0f, bool preserveAlpha = false) {
const u8 globalAlphaLimit = static_cast<u8>(0xF * opacity);
if (imageW < 448) {
processBMPChunk(x, y, imageW, preprocessedData, 0, imageH, globalAlphaLimit, false, preserveAlpha);
return;
}
for (unsigned i = 0; i < ult::numThreads; ++i) {
const u32 startRow = i * ult::bmpChunkSize;
const u32 endRow = std::min(startRow + ult::bmpChunkSize, imageH);
ult::renderThreads[i] = std::thread([this, x, y, imageW, preprocessedData, startRow, endRow, globalAlphaLimit, preserveAlpha](){
processBMPChunk(x, y, imageW, preprocessedData, startRow, endRow, globalAlphaLimit, true, preserveAlpha);
});
}
for (auto& t : ult::renderThreads) t.join();
}
inline void drawWallpaper() {
if (!ult::expandedMemory || ult::refreshWallpaper.load(std::memory_order_acquire)) {
return;
}
ult::inPlot.store(true, std::memory_order_release);
if (!ult::wallpaperData.empty() &&
!ult::refreshWallpaper.load(std::memory_order_acquire) &&
ult::correctFrameSize) {
drawBitmapRGBA4444(0, 0, cfg::FramebufferWidth, cfg::FramebufferHeight,
ult::wallpaperData.data(), Renderer::s_opacity, true);
}
ult::inPlot.store(false, std::memory_order_release);
}
/**
* @brief Draws a RGBA8888 bitmap from memory
*
* @param x X start position
* @param y Y start position
* @param w Bitmap width
* @param h Bitmap height
* @param bmp Pointer to bitmap data
*/
inline void drawBitmap(s32 x, s32 y, s32 w, s32 h, const u8 *bmp) {
if (w <= 0 || h <= 0) [[unlikely]] return;
const u8* __restrict__ src = bmp;
// Pre-compute alpha limit once using global opacity
const u8 alphaLimit = static_cast<u8>(0xF * Renderer::s_opacity);
// Completely unroll small bitmaps for maximum speed
if (w <= 8 && h <= 8) [[likely]] {
s32 px;
// Specialized path for small bitmaps (icons, etc.)
for (s32 py = 0; py < h; ++py) {
const s32 rowY = y + py;
px = x;
// Unroll inner loop completely for small widths
switch(w) {
case 8: goto pixel8;
case 7: goto pixel7;
case 6: goto pixel6;
case 5: goto pixel5;
case 4: goto pixel4;
case 3: goto pixel3;
case 2: goto pixel2;
case 1: goto pixel1;
default: break;
}
pixel8: {
u8 alpha = src[3] >> 4;
if (alpha > 0) {
alpha = (alpha < alphaLimit) ? alpha : alphaLimit;
const Color c = {static_cast<u8>(src[0] >> 4), static_cast<u8>(src[1] >> 4),
static_cast<u8>(src[2] >> 4), alpha};
setPixelBlendSrc(px, rowY, a(c));
}
px++; src += 4;
}
pixel7: {
u8 alpha = src[3] >> 4;
if (alpha > 0) {
alpha = (alpha < alphaLimit) ? alpha : alphaLimit;
const Color c = {static_cast<u8>(src[0] >> 4), static_cast<u8>(src[1] >> 4),
static_cast<u8>(src[2] >> 4), alpha};
setPixelBlendSrc(px, rowY, a(c));
}
px++; src += 4;
}
pixel6: {
u8 alpha = src[3] >> 4;
if (alpha > 0) {
alpha = (alpha < alphaLimit) ? alpha : alphaLimit;
const Color c = {static_cast<u8>(src[0] >> 4), static_cast<u8>(src[1] >> 4),
static_cast<u8>(src[2] >> 4), alpha};
setPixelBlendSrc(px, rowY, a(c));
}
px++; src += 4;
}
pixel5: {
u8 alpha = src[3] >> 4;
if (alpha > 0) {
alpha = (alpha < alphaLimit) ? alpha : alphaLimit;
const Color c = {static_cast<u8>(src[0] >> 4), static_cast<u8>(src[1] >> 4),
static_cast<u8>(src[2] >> 4), alpha};
setPixelBlendSrc(px, rowY, a(c));
}
px++; src += 4;
}
pixel4: {
u8 alpha = src[3] >> 4;
if (alpha > 0) {
alpha = (alpha < alphaLimit) ? alpha : alphaLimit;
const Color c = {static_cast<u8>(src[0] >> 4), static_cast<u8>(src[1] >> 4),
static_cast<u8>(src[2] >> 4), alpha};
setPixelBlendSrc(px, rowY, a(c));
}
px++; src += 4;
}
pixel3: {
u8 alpha = src[3] >> 4;
if (alpha > 0) {
alpha = (alpha < alphaLimit) ? alpha : alphaLimit;
const Color c = {static_cast<u8>(src[0] >> 4), static_cast<u8>(src[1] >> 4),
static_cast<u8>(src[2] >> 4), alpha};
setPixelBlendSrc(px, rowY, a(c));
}
px++; src += 4;
}
pixel2: {
u8 alpha = src[3] >> 4;
if (alpha > 0) {
alpha = (alpha < alphaLimit) ? alpha : alphaLimit;
const Color c = {static_cast<u8>(src[0] >> 4), static_cast<u8>(src[1] >> 4),
static_cast<u8>(src[2] >> 4), alpha};
setPixelBlendSrc(px, rowY, a(c));
}
px++; src += 4;
}
pixel1: {
u8 alpha = src[3] >> 4;
if (alpha > 0) {
alpha = (alpha < alphaLimit) ? alpha : alphaLimit;
const Color c = {static_cast<u8>(src[0] >> 4), static_cast<u8>(src[1] >> 4),
static_cast<u8>(src[2] >> 4), alpha};
setPixelBlendSrc(px, rowY, a(c));
}
src += 4;
}
}
return;
}
// Optimized scalar path for larger bitmaps
for (s32 py = 0; py < h; ++py) {
const s32 rowY = y + py;
s32 px = x;
const u8* rowEnd = src + (w * 4);
// Prefetch first cache line
__builtin_prefetch(src, 0, 3);
// Process all pixels in the row
while (src < rowEnd) {
// Prefetch ahead every 16 pixels (64 bytes)
if (((uintptr_t)src & 63) == 0) [[unlikely]] {
__builtin_prefetch(src + 64, 0, 3);
}
u8 alpha = src[3] >> 4;
if (alpha > 0) {
alpha = (alpha < alphaLimit) ? alpha : alphaLimit;
const Color c = {static_cast<u8>(src[0] >> 4), static_cast<u8>(src[1] >> 4),
static_cast<u8>(src[2] >> 4), alpha};
setPixelBlendSrc(px, rowY, c);
}
px++;
src += 4;
}
}
}
/**
* @brief Fills the entire layer with a given color
*
* @param color Color
*/
inline void fillScreen(const Color& color) {
std::fill_n(static_cast<Color*>(this->getCurrentFramebuffer()), this->getFramebufferSize() / sizeof(Color), color);
}
/**
* @brief Clears the layer (With transparency)
*
*/
inline void clearScreen() {
this->fillScreen(Color(0x0, 0x0, 0x0, 0x0)); // Fully transparent
}
const stbtt_fontinfo& getStandardFont() const {
return m_stdFont;
}
// Optimized unified drawString method with thread safety
inline std::pair<s32, s32> drawString(const std::string& originalString, bool monospace,
const s32 x, const s32 y, const u32 fontSize,
const Color& defaultColor, const ssize_t maxWidth = 0,
bool draw = true,
const Color* highlightColor = nullptr,
const std::vector<std::string>* specialSymbols = nullptr,
const u32 highlightStartChar = 0,
const u32 highlightEndChar = 0,
const bool useNotificationCache = false) {
// Thread-safe translation cache access
const std::string* text = &originalString;
std::string translatedText;
{
std::shared_lock<std::shared_mutex> readLock(s_translationCacheMutex);
auto translatedIt = ult::translationCache.find(originalString);
if (translatedIt != ult::translationCache.end()) {
translatedText = translatedIt->second;
text = &translatedText;
}
}
if (text->empty() || fontSize == 0) return {0, 0};
const float maxWidthLimit = maxWidth > 0 ? x + maxWidth : std::numeric_limits<float>::max();
// Check if highlighting is enabled
const bool highlightingEnabled = highlightColor && highlightStartChar != 0 && highlightEndChar != 0;
// Get font metrics once
const auto fontMetrics = FontManager::getFontMetricsForCharacter('A', fontSize);
const s32 lineHeight = static_cast<s32>(fontMetrics.lineHeight);
// Fast ASCII check with early exit
bool isAsciiOnly = true;
const char* textPtr = text->data();
const char* textEnd = textPtr + text->size();
for (const char* p = textPtr; p < textEnd; ++p) {
if (static_cast<unsigned char>(*p) > 127) {
isAsciiOnly = false;
break;
}
}
s32 maxX = x, currX = x, currY = y;
s32 maxY = y + lineHeight;
bool inHighlight = false;
const Color* currentColor = &defaultColor;
// Main processing loop
if (isAsciiOnly && !specialSymbols) {
// Fast ASCII-only path
for (const char* p = textPtr; p < textEnd && currX < maxWidthLimit; ++p) {
u32 currCharacter = static_cast<u32>(*p);
// Handle highlighting
if (highlightingEnabled) {
if (currCharacter == highlightStartChar) {
inHighlight = true;
currentColor = &defaultColor;
} else if (currCharacter == highlightEndChar) {
inHighlight = false;
currentColor = &defaultColor;
} else {
currentColor = inHighlight ? highlightColor : &defaultColor;
}
}
// Handle newline
if (currCharacter == '\n') {
maxX = std::max(currX, maxX);
currX = x;
currY += lineHeight;
maxY = std::max(maxY, currY + lineHeight);
continue;
}
// Get glyph
std::shared_ptr<FontManager::Glyph> glyph = useNotificationCache ?
FontManager::getOrCreateNotificationGlyph(currCharacter, monospace, fontSize) :
FontManager::getOrCreateGlyph(currCharacter, monospace, fontSize);
if (!glyph) continue;
maxY = std::max(maxY, currY + lineHeight);
// Render if needed
if (draw && glyph->glyphBmp && currCharacter > 32) {
renderGlyph(glyph, currX, currY, *currentColor, useNotificationCache);
}
currX += static_cast<s32>(glyph->xAdvance * glyph->currFontSize);
}
} else {
// UTF-8 path with special symbols support
auto itStr = text->cbegin();
const auto itStrEnd = text->cend();
while (itStr != itStrEnd && currX < maxWidthLimit) {
// Check for special symbols first
bool symbolProcessed = false;
if (specialSymbols) {
const size_t remainingLength = itStrEnd - itStr;
for (const auto& symbol : *specialSymbols) {
if (remainingLength >= symbol.length() &&
std::equal(symbol.begin(), symbol.end(), itStr)) {
// Process special symbol
for (size_t i = 0; i < symbol.length(); ) {
u32 symChar;
const ssize_t symWidth = decode_utf8(&symChar,
reinterpret_cast<const u8*>(&symbol[i]));
if (symWidth <= 0) break;
if (symChar == '\n') {
maxX = std::max(currX, maxX);
currX = x;
currY += lineHeight;
maxY = std::max(maxY, currY + lineHeight);
} else {
auto glyph = FontManager::getOrCreateGlyph(symChar, monospace, fontSize);
if (glyph) {
maxY = std::max(maxY, currY + lineHeight);
if (draw && glyph->glyphBmp && symChar > 32) {
renderGlyph(glyph, currX, currY, *highlightColor, useNotificationCache);
}
currX += static_cast<s32>(glyph->xAdvance * glyph->currFontSize);
}
}
i += symWidth;
}
itStr += symbol.length();
symbolProcessed = true;
break;
}
}
}
if (symbolProcessed) continue;
// Decode character
u32 currCharacter;
ssize_t codepointWidth;
if (isAsciiOnly) {
currCharacter = static_cast<u32>(*itStr);
codepointWidth = 1;
} else {
codepointWidth = decode_utf8(&currCharacter, reinterpret_cast<const u8*>(&(*itStr)));
if (codepointWidth <= 0) break;
}
itStr += codepointWidth;
// Handle highlighting
if (highlightingEnabled) {
if (currCharacter == highlightStartChar) {
inHighlight = true;
currentColor = &defaultColor;
} else if (currCharacter == highlightEndChar) {
inHighlight = false;
currentColor = &defaultColor;
} else {
currentColor = inHighlight ? highlightColor : &defaultColor;
}
}
// Handle newline
if (currCharacter == '\n') {
maxX = std::max(currX, maxX);
currX = x;
currY += lineHeight;
maxY = std::max(maxY, currY + lineHeight);
continue;
}
// Get glyph
auto glyph = FontManager::getOrCreateGlyph(currCharacter, monospace, fontSize);
if (!glyph) continue;
maxY = std::max(maxY, currY + lineHeight);
// Render if needed
if (draw && glyph->glyphBmp && currCharacter > 32) {
renderGlyph(glyph, currX, currY, *currentColor, useNotificationCache);
}
currX += static_cast<s32>(glyph->xAdvance * glyph->currFontSize);
}
}
maxX = std::max(currX, maxX);
return {maxX - x, maxY - y};
}
inline std::pair<s32, s32> drawNotificationString(const std::string& text, bool monospace,
const s32 x, const s32 y, const u32 fontSize,
const Color& defaultColor, const ssize_t maxWidth = 0,
bool draw = true,
const Color* highlightColor = nullptr,
const std::vector<std::string>* specialSymbols = nullptr,
const u32 highlightStartChar = 0,
const u32 highlightEndChar = 0) {
return drawString(text, monospace, x, y, fontSize, defaultColor, maxWidth, draw,
highlightColor, specialSymbols, highlightStartChar, highlightEndChar, true);
}
// Convenience wrappers for backward compatibility
inline std::pair<s32, s32> drawStringWithHighlight(const std::string& text, bool monospace,
s32 x, s32 y, const u32 fontSize,
const Color& defaultColor,
const Color& specialColor,
const ssize_t maxWidth = 0,
const u32 startChar = '(',
const u32 endChar = ')') {
return drawString(text, monospace, x, y, fontSize, defaultColor, maxWidth, true, &specialColor, nullptr, startChar, endChar);
}
inline std::pair<s32, s32> drawStringWithColoredSections(const std::string& text, bool monospace,
const std::vector<std::string>& specialSymbols,
s32 x, const s32 y, const u32 fontSize,
const Color& defaultColor,
const Color& specialColor) {
return drawString(text, monospace, x, y, fontSize, defaultColor, 0, true, &specialColor, &specialSymbols);
}
// Calculate string dimensions without drawing
inline std::pair<s32, s32> getTextDimensions(const std::string& text, bool monospace,
const u32 fontSize, const ssize_t maxWidth = 0) {
return drawString(text, monospace, 0, 0, fontSize, Color{0,0,0,0}, maxWidth, false);
}
inline std::pair<s32, s32> getNotificationTextDimensions(const std::string& text, bool monospace,
const u32 fontSize, const ssize_t maxWidth = 0) {
return drawString(text, monospace, 0, 0, fontSize, Color{0,0,0,0}, maxWidth, false,
nullptr, nullptr, 0, 0, true);
}
// Thread-safe limitStringLength using the unified cache
inline std::string limitStringLength(const std::string& originalString, const bool monospace,
const u32 fontSize, const s32 maxLength) { // Changed fontSize to u32
// Thread-safe translation cache access
std::string text;
{
std::shared_lock<std::shared_mutex> readLock(s_translationCacheMutex);
auto translatedIt = ult::translationCache.find(originalString);
if (translatedIt != ult::translationCache.end()) {
text = translatedIt->second;
} else {
// Don't insert anything, just fallback to original string
text = originalString;
}
}
if (text.size() < 2) return text;
// Get ellipsis width using shared cache (now thread-safe)
static constexpr u32 ellipsisChar = 0x2026;
std::shared_ptr<FontManager::Glyph> ellipsisGlyph = FontManager::getOrCreateGlyph(ellipsisChar, monospace, fontSize);
if (!ellipsisGlyph) return text;
// Fixed: Use consistent s32 calculation like other functions
const s32 ellipsisWidth = static_cast<s32>(ellipsisGlyph->xAdvance * ellipsisGlyph->currFontSize);
const s32 maxWidthWithoutEllipsis = maxLength - ellipsisWidth;
if (maxWidthWithoutEllipsis <= 0) {
return ""; // If there's no room for text, just return ellipsis
}
// Calculate width incrementally
s32 currX = 0;
auto itStr = text.cbegin();
const auto itStrEnd = text.cend();
auto lastValidPos = itStr;
// Fast ASCII check
bool isAsciiOnly = true;
for (unsigned char c : text) {
if (c > 127) {
isAsciiOnly = false;
break;
}
}
// Move variable declarations outside the loop
u32 currCharacter;
ssize_t codepointWidth;
s32 charWidth;
size_t bytePos;
while (itStr != itStrEnd) {
// Decode UTF-8 codepoint
if (isAsciiOnly) {
currCharacter = static_cast<u32>(*itStr);
codepointWidth = 1;
} else {
codepointWidth = decode_utf8(&currCharacter, reinterpret_cast<const u8*>(&(*itStr)));
if (codepointWidth <= 0) break;
}
// FontManager::getOrCreateGlyph is now thread-safe
std::shared_ptr<FontManager::Glyph> glyph = FontManager::getOrCreateGlyph(currCharacter, monospace, fontSize);
if (!glyph) {
itStr += codepointWidth;
continue;
}
// Fixed: Use consistent s32 calculation
charWidth = static_cast<s32>(glyph->xAdvance * glyph->currFontSize);
if (currX + charWidth > maxWidthWithoutEllipsis) {
// Calculate the byte position for substring
bytePos = std::distance(text.cbegin(), lastValidPos);
return text.substr(0, bytePos) + "";
}
currX += charWidth;
itStr += codepointWidth;
lastValidPos = itStr;
}
return text;
}
inline void setLayerPos(u32 x, u32 y) {
if (x > cfg::ScreenWidth - (int)(1.5 * cfg::FramebufferWidth) || y > cfg::ScreenHeight - (int)(1.5 * cfg::FramebufferHeight)) {
return;
}
setLayerPosImpl(x, y);
}
void updateLayerSize() {
const auto [horizontalUnderscanPixels, verticalUnderscanPixels] = getUnderscanPixels();
// Recalculate layer dimensions with new underscan values
cfg::LayerWidth = cfg::ScreenWidth * (float(cfg::FramebufferWidth) / float(cfg::LayerMaxWidth));
cfg::LayerHeight = cfg::ScreenHeight * (float(cfg::FramebufferHeight) / float(cfg::LayerMaxHeight));
// Apply underscan adjustments
if (ult::DefaultFramebufferWidth == 1280 && ult::DefaultFramebufferHeight == 28) {
cfg::LayerHeight += cfg::ScreenHeight/720. * verticalUnderscanPixels;
} else if (ult::correctFrameSize) {
cfg::LayerWidth += horizontalUnderscanPixels;
}
// Update position if using right alignment
if (ult::useRightAlignment && ult::correctFrameSize) {
ult::layerEdge = (1280 - 448);
}
// Update the existing layer with new dimensions
viSetLayerSize(&this->m_layer, cfg::LayerWidth, cfg::LayerHeight);
// Update position if using right alignment
if (ult::useRightAlignment && ult::correctFrameSize) {
viSetLayerPosition(&this->m_layer, 1280-32 - horizontalUnderscanPixels, 0);
viSetLayerSize(&this->m_layer, cfg::LayerWidth, cfg::LayerHeight);
viSetLayerPosition(&this->m_layer, 1280-32 - horizontalUnderscanPixels, 0);
}
// ADD THIS: Update position for micro mode bottom positioning
else if (ult::DefaultFramebufferWidth == 1280 && ult::DefaultFramebufferHeight == 28 && cfg::LayerPosY > 500) {
// Only adjust if already positioned at bottom (LayerPosY > 500 indicates bottom positioning)
const u32 targetY = !verticalUnderscanPixels ? 1038 : 1038- (cfg::ScreenHeight/720. * verticalUnderscanPixels) +0.5;
viSetLayerPosition(&this->m_layer, 0, targetY);
viSetLayerSize(&this->m_layer, cfg::LayerWidth, cfg::LayerHeight);
viSetLayerPosition(&this->m_layer, 0, targetY);
}
}
inline void setLayerPosImpl(u32 x, u32 y) {
// Simply set the position to what was requested - no automatic right alignment
cfg::LayerPosX = x;
cfg::LayerPosY = y;
ASSERT_FATAL(viSetLayerPosition(&this->m_layer, cfg::LayerPosX, cfg::LayerPosY));
}
#if USING_WIDGET_DIRECTIVE
// Method to draw clock, temperatures, and battery percentage
inline bool drawWidget() {
static time_t lastTimeUpdate = 0;
static char timeStr[20];
static char PCB_temperatureStr[10];
static char SOC_temperatureStr[10];
static char chargeString[6];
static time_t lastSensorUpdate = 0;
const bool showAnyWidget = !(ult::hideBattery && ult::hidePCBTemp && ult::hideSOCTemp && ult::hideClock);
// Draw separator and backdrop if showing any widget
if (showAnyWidget) {
drawRect(239, 15 + 2 - 2, 1, 64 + 2, aWithOpacity(topSeparatorColor));
if (!ult::hideWidgetBackdrop) {
drawUniformRoundedRect(
247, 15 + 2 - 2,
(ult::extendedWidgetBackdrop
? tsl::cfg::FramebufferWidth - 255
: tsl::cfg::FramebufferWidth - 215),
64 + 2, a(widgetBackdropColor)
);
}
}
// Calculate base Y offset
size_t y_offset = ((ult::hideBattery && ult::hidePCBTemp && ult::hideSOCTemp) || ult::hideClock)
? (55 + 2 - 1)
: (44 + 2 - 1);
// Constants for centering calculations
const int backdropCenterX = 247 + ((tsl::cfg::FramebufferWidth - 255) >> 1);
time_t currentTime = time(nullptr);
// Draw clock
if (!ult::hideClock) {
if (currentTime != lastTimeUpdate || ult::languageWasChanged.load(std::memory_order_acquire)) {
strftime(timeStr, sizeof(timeStr), ult::datetimeFormat.c_str(), localtime(&currentTime));
ult::localizeTimeStr(timeStr);
lastTimeUpdate = currentTime;
}
const int timeWidth = getTextDimensions(timeStr, false, 20).first;
if (ult::centerWidgetAlignment) {
// Centered alignment
drawString(timeStr, false, backdropCenterX - (timeWidth >> 1), y_offset, 20, clockColor);
} else {
// Right alignment
drawString(timeStr, false, tsl::cfg::FramebufferWidth - timeWidth - 25, y_offset, 20, clockColor);
}
y_offset += 22;
}
// Update sensor data every second
if ((currentTime - lastSensorUpdate) >= 1) {
if (!ult::hideSOCTemp) {
float socTemp = 0.0f;
ult::ReadSocTemperature(&socTemp);
ult::SOC_temperature.store(socTemp, std::memory_order_release);
snprintf(
SOC_temperatureStr, sizeof(SOC_temperatureStr),
"%d°C",
static_cast<int>(round(ult::SOC_temperature.load(std::memory_order_acquire)))
);
}
if (!ult::hidePCBTemp) {
float pcbTemp = 0.0f;
ult::ReadPcbTemperature(&pcbTemp);
ult::PCB_temperature.store(pcbTemp, std::memory_order_release);
snprintf(
PCB_temperatureStr, sizeof(PCB_temperatureStr),
"%d°C",
static_cast<int>(round(ult::PCB_temperature.load(std::memory_order_acquire)))
);
}
if (!ult::hideBattery) {
uint32_t bc = 0;
bool charging = false;
ult::powerGetDetails(&bc, &charging);
bc = std::min(bc, 100U);
ult::batteryCharge.store(bc, std::memory_order_release);
ult::isCharging.store(charging, std::memory_order_release);
snprintf(chargeString, sizeof(chargeString), "%u%%", bc);
}
lastSensorUpdate = currentTime;
}
if (ult::centerWidgetAlignment) {
// CENTERED ALIGNMENT
int totalWidth = 0;
int socWidth = 0, pcbWidth = 0, chargeWidth = 0;
bool hasMultiple = false;
const float socTemp = ult::SOC_temperature.load(std::memory_order_acquire);
const float pcbTemp = ult::PCB_temperature.load(std::memory_order_acquire);
const uint32_t batteryCharge = ult::batteryCharge.load(std::memory_order_acquire);
const bool charging = ult::isCharging.load(std::memory_order_acquire);
if (!ult::hideSOCTemp && socTemp > 0.0f) {
socWidth = getTextDimensions(SOC_temperatureStr, false, 20).first;
totalWidth += socWidth;
hasMultiple = true;
}
if (!ult::hidePCBTemp && pcbTemp > 0.0f) {
pcbWidth = getTextDimensions(PCB_temperatureStr, false, 20).first;
if (hasMultiple) totalWidth += 5;
totalWidth += pcbWidth;
hasMultiple = true;
}
if (!ult::hideBattery && batteryCharge > 0) {
chargeWidth = getTextDimensions(chargeString, false, 20).first;
if (hasMultiple) totalWidth += 5;
totalWidth += chargeWidth;
}
int currentX = backdropCenterX - (totalWidth >> 1);
if (socWidth > 0) {
drawString(
SOC_temperatureStr, false, currentX, y_offset, 20,
ult::dynamicWidgetColors
? tsl::GradientColor(socTemp)
: temperatureColor
);
currentX += socWidth + 5;
}
if (pcbWidth > 0) {
drawString(
PCB_temperatureStr, false, currentX, y_offset, 20,
ult::dynamicWidgetColors
? tsl::GradientColor(pcbTemp)
: temperatureColor
);
currentX += pcbWidth + 5;
}
if (chargeWidth > 0) {
const Color batteryColorToUse = charging
? batteryChargingColor
: (batteryCharge < 20 ? batteryLowColor : batteryColor);
drawString(chargeString, false, currentX, y_offset, 20, batteryColorToUse);
}
} else {
// RIGHT ALIGNMENT
int chargeWidth = 0, pcbWidth = 0, socWidth = 0;
const float pcbTemp = ult::PCB_temperature.load(std::memory_order_acquire);
const float socTemp = ult::SOC_temperature.load(std::memory_order_acquire);
const uint32_t batteryCharge = ult::batteryCharge.load(std::memory_order_acquire);
const bool charging = ult::isCharging.load(std::memory_order_acquire);
if (!ult::hideBattery && batteryCharge > 0) {
const Color batteryColorToUse = charging
? batteryChargingColor
: (batteryCharge < 20 ? batteryLowColor : batteryColor);
chargeWidth = getTextDimensions(chargeString, false, 20).first;
drawString(
chargeString, false,
tsl::cfg::FramebufferWidth - chargeWidth - 25,
y_offset, 20, batteryColorToUse
);
}
int offset = 0;
if (!ult::hidePCBTemp && pcbTemp > 0.0f) {
if (!ult::hideBattery) offset -= 5;
pcbWidth = getTextDimensions(PCB_temperatureStr, false, 20).first;
drawString(
PCB_temperatureStr, false,
tsl::cfg::FramebufferWidth + offset - pcbWidth - chargeWidth - 25,
y_offset, 20,
ult::dynamicWidgetColors
? tsl::GradientColor(pcbTemp)
: defaultTextColor
);
}
if (!ult::hideSOCTemp && socTemp > 0.0f) {
if (!ult::hidePCBTemp || !ult::hideBattery) offset -= 5;
socWidth = getTextDimensions(SOC_temperatureStr, false, 20).first;
drawString(
SOC_temperatureStr, false,
tsl::cfg::FramebufferWidth + offset - socWidth - pcbWidth - chargeWidth - 25,
y_offset, 20,
ult::dynamicWidgetColors
? tsl::GradientColor(socTemp)
: defaultTextColor
);
}
}
return showAnyWidget;
}
#endif
// Optimized glyph rendering
inline void renderGlyph(std::shared_ptr<FontManager::Glyph> glyph, float x, float y, const Color& color, bool skipAlphaLimit = false) {
if (!glyph->glyphBmp || color.a == 0) [[unlikely]] return;
const s32 xPos = static_cast<s32>(x + glyph->bounds[0]);
const s32 yPos = static_cast<s32>(y + glyph->bounds[1]);
if (xPos >= cfg::FramebufferWidth || yPos >= cfg::FramebufferHeight ||
xPos + glyph->width <= 0 || yPos + glyph->height <= 0) [[unlikely]] return;
const s32 startX = std::max(0, -xPos);
const s32 startY = std::max(0, -yPos);
const s32 endX = std::min(glyph->width, static_cast<s32>(cfg::FramebufferWidth) - xPos);
const s32 endY = std::min(glyph->height, static_cast<s32>(cfg::FramebufferHeight) - yPos);
const u8 alphaLimit = skipAlphaLimit ? color.a : static_cast<u8>(0xF * Renderer::s_opacity);
const uint8_t* bmpPtr = glyph->glyphBmp + startY * glyph->width;
for (s32 bmpY = startY; bmpY < endY; ++bmpY, bmpPtr += glyph->width) {
const s32 pixelY = yPos + bmpY;
for (s32 bmpX = startX; bmpX < endX; ++bmpX) {
u8 alpha = bmpPtr[bmpX] >> 4;
if (alpha == 0) [[unlikely]] continue;
alpha = (alpha < alphaLimit) ? alpha : alphaLimit;
const s32 pixelX = xPos + bmpX;
if (alpha == 0xF) [[likely]] {
this->setPixel(pixelX, pixelY, color);
} else {
this->setPixelBlendDst(pixelX, pixelY, Color(color.r, color.g, color.b, alpha));
}
}
}
}
/**
* @brief Adds the layer from screenshot and recording stacks
*/
inline void addScreenshotStacks(bool forceDisable = true) {
tsl::hlp::viAddToLayerStack(&this->m_layer, ViLayerStack_Screenshot);
tsl::hlp::viAddToLayerStack(&this->m_layer, ViLayerStack_Recording);
screenshotsAreDisabled.store(false, std::memory_order_release);
if (forceDisable)
screenshotsAreForceDisabled.store(false, std::memory_order_release);
}
/**
* @brief Removes the layer from screenshot and recording stacks
*/
inline void removeScreenshotStacks(bool forceDisable = true) {
tsl::hlp::viRemoveFromLayerStack(&this->m_layer, ViLayerStack_Screenshot);
tsl::hlp::viRemoveFromLayerStack(&this->m_layer, ViLayerStack_Recording);
screenshotsAreDisabled.store(true, std::memory_order_release);
if (forceDisable)
screenshotsAreForceDisabled.store(true, std::memory_order_release);
}
private:
Renderer() {}
/**
* @brief Sets the opacity of the layer
*
* @param opacity Opacity
*/
inline static void setOpacity(float opacity) {
opacity = std::clamp(opacity, 0.0F, 1.0F);
Renderer::s_opacity = opacity;
}
bool m_initialized = false;
ViDisplay m_display;
ViLayer m_layer;
Event m_vsyncEvent;
NWindow m_window;
Framebuffer m_framebuffer;
void *m_currentFramebuffer = nullptr;
std::stack<ScissoringConfig> m_scissoringStack;
/**
* @brief Get the current framebuffer address
*
* @return Framebuffer address
*/
inline void* getCurrentFramebuffer() {
return this->m_currentFramebuffer;
}
/**
* @brief Get the next framebuffer address
*
* @return Next framebuffer address
*/
inline void* getNextFramebuffer() {
return static_cast<u8*>(this->m_framebuffer.buf) + this->getNextFramebufferSlot() * this->getFramebufferSize();
}
/**
* @brief Get the framebuffer size
*
* @return Framebuffer size
*/
inline size_t getFramebufferSize() {
return this->m_framebuffer.fb_size;
}
/**
* @brief Get the number of framebuffers in use
*
* @return Number of framebuffers
*/
inline size_t getFramebufferCount() {
return this->m_framebuffer.num_fbs;
}
/**
* @brief Get the currently used framebuffer's slot
*
* @return Slot
*/
inline u8 getCurrentFramebufferSlot() {
return this->m_window.cur_slot;
}
/**
* @brief Get the next framebuffer's slot
*
* @return Next slot
*/
inline u8 getNextFramebufferSlot() {
return (this->getCurrentFramebufferSlot() + 1) % this->getFramebufferCount();
}
/**
* @brief Waits for the vsync event
*
*/
inline void waitForVSync() {
eventWait(&this->m_vsyncEvent, UINT64_MAX);
}
/**
* @brief Decodes a x and y coordinate into a offset into the swizzled framebuffer
*
* @param x X pos
* @param y Y Pos
* @return Offset
*/
inline u32 __attribute__((always_inline)) getPixelOffset(const u32 x, const u32 y) {
// Check for scissoring boundaries
if (!this->m_scissoringStack.empty()) [[unlikely]] {
const auto& currScissorConfig = this->m_scissoringStack.top();
if (x < currScissorConfig.x || y < currScissorConfig.y ||
x >= currScissorConfig.x_max ||
y >= currScissorConfig.y_max) {
return UINT32_MAX;
}
}
return ((((y & 127) >> 4) + ((x >> 5) << 3) + ((y >> 7) * offsetWidthVar)) << 9) +
((y & 8) << 5) + ((x & 16) << 3) + ((y & 6) << 4) +
((x & 8) << 1) + ((y & 1) << 3) + (x & 7);
}
/**
* @brief Initializes the renderer and layers
*
*/
void init() {
// Get the underscan pixel values for both horizontal and vertical borders
const auto [horizontalUnderscanPixels, verticalUnderscanPixels] = getUnderscanPixels();
ult::useRightAlignment = (ult::parseValueFromIniSection(ult::ULTRAHAND_CONFIG_INI_PATH, ult::ULTRAHAND_PROJECT_NAME, "right_alignment") == ult::TRUE_STR);
cfg::LayerPosX = 0;
cfg::LayerPosY = 0;
cfg::FramebufferWidth = ult::DefaultFramebufferWidth;
cfg::FramebufferHeight = ult::DefaultFramebufferHeight;
offsetWidthVar = (((cfg::FramebufferWidth / 2) >> 4) << 3);
ult::correctFrameSize = (cfg::FramebufferWidth == 448 && cfg::FramebufferHeight == 720); // for detecting the correct Overlay display size
if (ult::useRightAlignment && ult::correctFrameSize) {
cfg::LayerPosX = 1280-32 - horizontalUnderscanPixels;
ult::layerEdge = (1280-448);
}
cfg::LayerWidth = cfg::ScreenWidth * (float(cfg::FramebufferWidth) / float(cfg::LayerMaxWidth));
cfg::LayerHeight = cfg::ScreenHeight * (float(cfg::FramebufferHeight) / float(cfg::LayerMaxHeight));
// Apply underscanning offset
if (ult::DefaultFramebufferWidth == 1280 && ult::DefaultFramebufferHeight == 28) // for status monitor micro mode
cfg::LayerHeight += cfg::ScreenHeight/720. *verticalUnderscanPixels;
else if (ult::correctFrameSize)
cfg::LayerWidth += horizontalUnderscanPixels;
if (this->m_initialized)
return;
tsl::hlp::doWithSmSession([this, horizontalUnderscanPixels]{
ASSERT_FATAL(viInitialize(ViServiceType_Manager));
ASSERT_FATAL(viOpenDefaultDisplay(&this->m_display));
ASSERT_FATAL(viGetDisplayVsyncEvent(&this->m_display, &this->m_vsyncEvent));
ASSERT_FATAL(viCreateManagedLayer(&this->m_display, static_cast<ViLayerFlags>(0), 0, &__nx_vi_layer_id));
ASSERT_FATAL(viCreateLayer(&this->m_display, &this->m_layer));
ASSERT_FATAL(viSetLayerScalingMode(&this->m_layer, ViScalingMode_FitToLayer));
if (horizontalUnderscanPixels == 0) {
s32 layerZ = 0;
if (R_SUCCEEDED(viGetZOrderCountMax(&this->m_display, &layerZ)) && layerZ > 0) {
ASSERT_FATAL(viSetLayerZ(&this->m_layer, layerZ));
}
else {
ASSERT_FATAL(viSetLayerZ(&this->m_layer, 255)); // max value 255 as fallback
}
} else {
ASSERT_FATAL(viSetLayerZ(&this->m_layer, 34)); // 34 is the edge for underscanning
}
ASSERT_FATAL(tsl::hlp::viAddToLayerStack(&this->m_layer, ViLayerStack_Default));
ASSERT_FATAL(tsl::hlp::viAddToLayerStack(&this->m_layer, ViLayerStack_Screenshot));
ASSERT_FATAL(tsl::hlp::viAddToLayerStack(&this->m_layer, ViLayerStack_Recording));
ASSERT_FATAL(tsl::hlp::viAddToLayerStack(&this->m_layer, ViLayerStack_Arbitrary));
ASSERT_FATAL(tsl::hlp::viAddToLayerStack(&this->m_layer, ViLayerStack_LastFrame));
ASSERT_FATAL(tsl::hlp::viAddToLayerStack(&this->m_layer, ViLayerStack_Null));
ASSERT_FATAL(tsl::hlp::viAddToLayerStack(&this->m_layer, ViLayerStack_ApplicationForDebug));
ASSERT_FATAL(tsl::hlp::viAddToLayerStack(&this->m_layer, ViLayerStack_Lcd));
ASSERT_FATAL(viSetLayerSize(&this->m_layer, cfg::LayerWidth, cfg::LayerHeight));
ASSERT_FATAL(viSetLayerPosition(&this->m_layer, cfg::LayerPosX, cfg::LayerPosY));
ASSERT_FATAL(nwindowCreateFromLayer(&this->m_window, &this->m_layer));
ASSERT_FATAL(framebufferCreate(&this->m_framebuffer, &this->m_window, cfg::FramebufferWidth, cfg::FramebufferHeight, PIXEL_FORMAT_RGBA_4444, 2));
ASSERT_FATAL(setInitialize());
ASSERT_FATAL(this->initFonts());
setExit();
});
this->m_initialized = true;
}
/**
* @brief Exits the renderer and layer
*
*/
void exit() {
if (!this->m_initialized)
return;
// Cleanup shared font manager
FontManager::cleanup();
framebufferClose(&this->m_framebuffer);
nwindowClose(&this->m_window);
viDestroyManagedLayer(&this->m_layer);
viCloseDisplay(&this->m_display);
eventClose(&this->m_vsyncEvent);
viExit();
}
/**
* @brief Initializes Nintendo's shared fonts. Default and Extended
*
* @return Result
*/
Result initFonts() {
PlFontData stdFontData, localFontData, extFontData;
// Nintendo's default font
TSL_R_TRY(plGetSharedFontByType(&stdFontData, PlSharedFontType_Standard));
u8 *fontBuffer = reinterpret_cast<u8*>(stdFontData.address);
stbtt_InitFont(&this->m_stdFont, fontBuffer, stbtt_GetFontOffsetForIndex(fontBuffer, 0));
u64 languageCode;
if (R_SUCCEEDED(setGetSystemLanguage(&languageCode))) {
// Check if need localization font
SetLanguage setLanguage;
TSL_R_TRY(setMakeLanguage(languageCode, &setLanguage));
this->m_hasLocalFont = true;
switch (setLanguage) {
case SetLanguage_ZHCN:
case SetLanguage_ZHHANS:
TSL_R_TRY(plGetSharedFontByType(&localFontData, PlSharedFontType_ChineseSimplified));
break;
case SetLanguage_ZHTW:
case SetLanguage_ZHHANT:
TSL_R_TRY(plGetSharedFontByType(&localFontData, PlSharedFontType_ChineseTraditional));
break;
case SetLanguage_KO:
TSL_R_TRY(plGetSharedFontByType(&localFontData, PlSharedFontType_KO));
break;
default:
this->m_hasLocalFont = false;
break;
}
if (this->m_hasLocalFont) {
fontBuffer = reinterpret_cast<u8*>(localFontData.address);
stbtt_InitFont(&this->m_localFont, fontBuffer, stbtt_GetFontOffsetForIndex(fontBuffer, 0));
}
}
// Nintendo's extended font containing a bunch of icons
TSL_R_TRY(plGetSharedFontByType(&extFontData, PlSharedFontType_NintendoExt));
fontBuffer = reinterpret_cast<u8*>(extFontData.address);
stbtt_InitFont(&this->m_extFont, fontBuffer, stbtt_GetFontOffsetForIndex(fontBuffer, 0));
// Load all three local fonts unconditionally for fallback support
PlFontData cnFontData, twFontData, koFontData;
TSL_R_TRY(plGetSharedFontByType(&cnFontData, PlSharedFontType_ChineseSimplified));
fontBuffer = reinterpret_cast<u8*>(cnFontData.address);
stbtt_InitFont(&this->m_localFontCN, fontBuffer, stbtt_GetFontOffsetForIndex(fontBuffer, 0));
TSL_R_TRY(plGetSharedFontByType(&twFontData, PlSharedFontType_ChineseTraditional));
fontBuffer = reinterpret_cast<u8*>(twFontData.address);
stbtt_InitFont(&this->m_localFontTW, fontBuffer, stbtt_GetFontOffsetForIndex(fontBuffer, 0));
TSL_R_TRY(plGetSharedFontByType(&koFontData, PlSharedFontType_KO));
fontBuffer = reinterpret_cast<u8*>(koFontData.address);
stbtt_InitFont(&this->m_localFontKO, fontBuffer, stbtt_GetFontOffsetForIndex(fontBuffer, 0));
// Initialize the shared font manager
FontManager::initializeFonts(&this->m_stdFont,
&this->m_localFont,
&this->m_localFontCN,
&this->m_localFontTW,
&this->m_localFontKO,
&this->m_extFont,
this->m_hasLocalFont);
return 0;
}
/**
* @brief Start a new frame
* @warning Don't call this more than once before calling \ref endFrame
*/
inline void startFrame() {
this->m_currentFramebuffer = framebufferBegin(&this->m_framebuffer, nullptr);
}
inline void endFrame() {
#if IS_STATUS_MONITOR_DIRECTIVE
if (isRendering) {
static u32 lastFPS = 0;
static u64 cachedIntervalNs = 1000000000ULL / 60;
u32 fps = TeslaFPS;
if (__builtin_expect(fps != lastFPS, 0)) {
cachedIntervalNs = (fps > 0) ? (1000000000ULL / fps) : cachedIntervalNs;
lastFPS = fps;
}
// Just wait - touch thread will signal if needed
leventWait(&renderingStopEvent, cachedIntervalNs);
}
#endif
this->waitForVSync();
framebufferEnd(&this->m_framebuffer);
this->m_currentFramebuffer = nullptr;
if (tsl::clearGlyphCacheNow.exchange(false, std::memory_order_acq_rel)) {
tsl::gfx::FontManager::clearCache();
}
}
};
static std::pair<int, int> getUnderscanPixels() {
if (!ult::consoleIsDocked()) {
return {0, 0};
}
// Retrieve the TV settings
SetSysTvSettings tvSettings;
Result res = setsysGetTvSettings(&tvSettings);
if (R_FAILED(res)) {
// Handle error: return default underscan or log error
return {0, 0};
}
// The underscan value might not be a percentage, we need to interpret it correctly
const u32 underscanValue = tvSettings.underscan;
// Convert the underscan value to a fraction. Assuming 0 means no underscan and larger values represent
// greater underscan. Adjust this formula based on actual observed behavior or documentation.
const float underscanPercentage = 1.0f - (underscanValue / 100.0f);
// Original dimensions of the full 720p image (1280x720)
const float originalWidth = 1280;
const float originalHeight = 720;
// Adjust the width and height based on the underscan percentage
const float adjustedWidth = (originalWidth * underscanPercentage);
const float adjustedHeight = (originalHeight * underscanPercentage);
// Calculate the underscan in pixels (left/right and top/bottom)
const int horizontalUnderscanPixels = (originalWidth - adjustedWidth);
const int verticalUnderscanPixels = (originalHeight - adjustedHeight);
return {horizontalUnderscanPixels, verticalUnderscanPixels};
}
}
// Elements
namespace elm {
enum class TouchEvent {
Touch,
Hold,
Scroll,
Release,
None
};
/**
* @brief The top level Element of the libtesla UI library
* @note When creating your own elements, extend from this or one of it's sub classes
*/
class Element {
public:
Element() {}
virtual ~Element() {
m_clickListener = {}; // frees captures immediately
}
bool m_isTable = false; // Default to false for non-table elements
bool m_isItem = true;
u64 t_ns; // Changed from chrono::duration to nanoseconds
u8 saturation;
float progress;
s32 x, y;
s32 amplitude;
u64 m_animationStartTime; // Changed from chrono::time_point to nanoseconds
virtual bool isTable() const {
return m_isTable;
}
virtual bool isItem() const {
return m_isItem;
}
/**
* @brief Handles focus requesting
* @note This function should return the element to focus.
* When this element should be focused, return `this`.
* When one of it's child should be focused, return `this->child->requestFocus(oldFocus, direction)`
* When this element is not focusable, return `nullptr`
*
* @param oldFocus Previously focused element
* @param direction Direction in which focus moved. \ref FocusDirection::None is passed for the initial load
* @return Element to focus
*/
virtual Element* requestFocus(Element *oldFocus, FocusDirection direction) {
return nullptr;
}
/**
* @brief Function called when a joycon button got pressed
*
* @param keys Keys pressed in the last frame
* @return true when button press has been consumed
* @return false when button press should be passed on to the parent
*/
virtual bool onClick(u64 keys) {
return m_clickListener(keys);
}
/**
* @brief Called once per frame with the latest HID inputs
*
* @param keysDown Buttons pressed in the last frame
* @param keysHeld Buttons held down longer than one frame
* @param touchInput Last touch position
* @param leftJoyStick Left joystick position
* @param rightJoyStick Right joystick position
* @return Weather or not the input has been consumed
*/
virtual bool handleInput(u64 keysDown, u64 keysHeld, const HidTouchState &touchPos, HidAnalogStickState joyStickPosLeft, HidAnalogStickState joyStickPosRight) {
return false;
}
/**
* @brief Function called when the element got touched
* @todo Not yet implemented
*
* @param x X pos
* @param y Y pos
* @return true when touch input has been consumed
* @return false when touch input should be passed on to the parent
*/
virtual bool onTouch(TouchEvent event, s32 currX, s32 currY, s32 prevX, s32 prevY, s32 initialX, s32 initialY) {
return false;
}
/**
* @brief Called once per frame to draw the element
* @warning Do not call this yourself. Use \ref Element::frame(gfx::Renderer *renderer)
*
* @param renderer Renderer
*/
virtual void draw(gfx::Renderer *renderer) = 0;
/**
* @brief Called when the underlying Gui gets created and after calling \ref Gui::invalidate() to calculate positions and boundaries of the element
* @warning Do not call this yourself. Use \ref Element::invalidate()
*
* @param parentX Parent X pos
* @param parentY Parent Y pos
* @param parentWidth Parent Width
* @param parentHeight Parent Height
*/
virtual void layout(u16 parentX, u16 parentY, u16 parentWidth, u16 parentHeight) = 0;
/**
* @brief Draws highlighting and the element itself
* @note When drawing children of a element in \ref Element::draw(gfx::Renderer *renderer), use `this->child->frame(renderer)` instead of calling draw directly
*
* @param renderer
*/
void inline frame(gfx::Renderer *renderer) {
if (this->m_focused) {
renderer->enableScissoring(0, ult::activeHeaderHeight, tsl::cfg::FramebufferWidth, tsl::cfg::FramebufferHeight-73-ult::activeHeaderHeight);
this->drawFocusBackground(renderer);
this->drawHighlight(renderer);
renderer->disableScissoring();
}
this->draw(renderer);
}
/**
* @brief Forces a layout recreation of a element
*
*/
void inline invalidate() {
const auto& parent = this->getParent();
if (parent == nullptr)
this->layout(0, 0, cfg::FramebufferWidth, cfg::FramebufferHeight);
else
this->layout(ELEMENT_BOUNDS(parent));
}
/**
* @brief Shake the highlight in the given direction to signal that the focus cannot move there
*
* @param direction Direction to shake highlight in
*/
void inline shakeHighlight(FocusDirection direction) {
this->m_highlightShaking = true;
this->m_highlightShakingDirection = direction;
this->m_highlightShakingStartTime = ult::nowNs(); // Changed
if (direction != FocusDirection::None && m_isItem) {
triggerWallFeedback();
}
}
/**
* @brief Triggers the blue click animation to signal a element has been clicked on
*
*/
void inline triggerClickAnimation() {
this->m_clickAnimationProgress = tsl::style::ListItemHighlightLength;
this->m_animationStartTime = ult::nowNs(); // Changed
}
/**
* @brief Resets the click animation progress, canceling the animation
*/
void inline resetClickAnimation() {
this->m_clickAnimationProgress = 0;
}
/**
* @brief Draws the blue highlight animation when clicking on a button
* @note Override this if you have a element that e.g requires a non-rectangular animation or a different color
*
* @param renderer Renderer
*/
virtual void drawClickAnimation(gfx::Renderer *renderer) {
if (!m_isItem)
return;
if (ult::useSelectionBG) {
renderer->drawRectAdaptive(this->getX() + x + 4, this->getY() + y, this->getWidth() - 8, this->getHeight(), aWithOpacity(selectionBGColor));
}
saturation = tsl::style::ListItemHighlightSaturation * (float(this->m_clickAnimationProgress) / float(tsl::style::ListItemHighlightLength));
Color animColor = {0xF,0xF,0xF,0xF};
if (invertBGClickColor) {
const u8 inverted = 15-saturation;
animColor = {inverted, inverted, inverted, selectionBGColor.a};
} else {
animColor = {saturation, saturation, saturation, selectionBGColor.a};
}
renderer->drawRectAdaptive(ELEMENT_BOUNDS(this), aWithOpacity(animColor));
// Cache time calculation - only compute once
static u64 lastTimeUpdate = 0;
static double cachedProgress = 0.0;
const u64 currentTime_ns = ult::nowNs();
// Only recalculate progress if enough time has passed (reduce computation frequency)
if (currentTime_ns - lastTimeUpdate > 16666666) { // ~60 FPS update rate
cachedProgress = (ult::cos(2.0 * ult::_M_PI * std::fmod(currentTime_ns / 1000000000.0 - 0.25, 1.0)) + 1.0) / 2.0;
lastTimeUpdate = currentTime_ns;
}
progress = cachedProgress;
Color clickColor1 = highlightColor1;
Color clickColor2 = clickColor;
if (progress >= 0.5) {
clickColor1 = clickColor;
clickColor2 = highlightColor2;
}
// Combine color interpolation into single calculation
s_highlightColor = lerpColor(clickColor1, clickColor2, progress);
x = 0;
y = 0;
if (this->m_highlightShaking) {
t_ns = currentTime_ns - this->m_highlightShakingStartTime;
const double t_ms = t_ns / 1000000.0;
static constexpr double SHAKE_DURATION_MS = 200.0;
if (t_ms >= SHAKE_DURATION_MS)
this->m_highlightShaking = false;
else {
// Generate random amplitude only once per shake using the start time as seed
const double amplitude = 6.0 + ((this->m_highlightShakingStartTime / 1000000) % 5);
const double progress = t_ms / SHAKE_DURATION_MS; // 0 to 1
// Lighter damping so both bounces are visible
const double damping = 1.0 / (1.0 + 2.5 * progress * (1.0 + 1.3 * progress));
// 2 full oscillations = 2 clear bounces
const double oscillation = ult::cos(ult::_M_PI * 4.0 * progress);
const double displacement = amplitude * oscillation * damping;
const int offset = static_cast<int>(displacement);
switch (this->m_highlightShakingDirection) {
case FocusDirection::Up: y = -offset; break;
case FocusDirection::Down: y = offset; break;
case FocusDirection::Left: x = -offset; break;
case FocusDirection::Right: x = offset; break;
default: break;
}
}
}
renderer->drawBorderedRoundedRect(this->getX() + x, this->getY() + y, this->getWidth() +4, this->getHeight(), 5, 5, a(s_highlightColor));
}
/**
* @brief Draws the back background when a element is highlighted
* @note Override this if you have a element that e.g requires a non-rectangular focus
*
* @param renderer Renderer
*/
virtual void drawFocusBackground(gfx::Renderer *renderer) {
if (this->m_clickAnimationProgress > 0) {
this->drawClickAnimation(renderer);
// Direct calculation without intermediate multiplication
this->m_clickAnimationProgress = tsl::style::ListItemHighlightLength * (1.0f - ((ult::nowNs() - this->m_animationStartTime) * 0.000001) * 0.002f); // 0.002f = 1/500
// Clamp to 0 in one operation
if (this->m_clickAnimationProgress < 0) {
this->m_clickAnimationProgress = 0;
}
}
}
/**
* @brief Draws the blue boarder when a element is highlighted
* @note Override this if you have a element that e.g requires a non-rectangular focus
*
* @param renderer Renderer
*/
virtual void drawHighlight(gfx::Renderer *renderer) {
if (!m_isItem)
return;
// Use cached time calculation from drawClickAnimation if possible
static u64 lastHighlightUpdate = 0;
static double cachedHighlightProgress = 0.0;
const u64 currentTime_ns = ult::nowNs();
// Update progress at 60 FPS rate with high-precision calculation
if (currentTime_ns - lastHighlightUpdate > 16666666) {
// Match original calculation exactly but with higher precision
cachedHighlightProgress = (ult::cos(2.0 * ult::_M_PI * std::fmod(currentTime_ns * 0.000000001 - 0.25, 1.0)) + 1.0) * 0.5;
lastHighlightUpdate = currentTime_ns;
}
progress = cachedHighlightProgress;
// Cache the interpreter state check result to avoid atomic load overhead
static bool lastInterpreterState = false;
static u64 lastInterpreterCheck = 0;
if (currentTime_ns - lastInterpreterCheck > 50000000) { // Check every 50ms
lastInterpreterState = ult::runningInterpreter.load(std::memory_order_acquire);
lastInterpreterCheck = currentTime_ns;
}
if (lastInterpreterState) {
// High precision floating point color interpolation for interpreter colors
s_highlightColor = {
static_cast<u8>(highlightColor4.r + (highlightColor3.r - highlightColor4.r) * progress + 0.5),
static_cast<u8>(highlightColor4.g + (highlightColor3.g - highlightColor4.g) * progress + 0.5),
static_cast<u8>(highlightColor4.b + (highlightColor3.b - highlightColor4.b) * progress + 0.5),
0xF
};
} else {
// High precision floating point color interpolation for normal colors
s_highlightColor = {
static_cast<u8>(highlightColor2.r + (highlightColor1.r - highlightColor2.r) * progress + 0.5),
static_cast<u8>(highlightColor2.g + (highlightColor1.g - highlightColor2.g) * progress + 0.5),
static_cast<u8>(highlightColor2.b + (highlightColor1.b - highlightColor2.b) * progress + 0.5),
0xF
};
}
x = 0;
y = 0;
if (this->m_highlightShaking) {
t_ns = currentTime_ns - this->m_highlightShakingStartTime;
const double t_ms = t_ns / 1000000.0;
static constexpr double SHAKE_DURATION_MS = 200.0;
if (t_ms >= SHAKE_DURATION_MS)
this->m_highlightShaking = false;
else {
// Generate random amplitude only once per shake using the start time as seed
const double amplitude = 6.0 + ((this->m_highlightShakingStartTime / 1000000) % 5);
const double progress = t_ms / SHAKE_DURATION_MS; // 0 to 1
// Lighter damping so both bounces are visible
const double damping = 1.0 / (1.0 + 2.5 * progress * (1.0 + 1.3 * progress));
// 2 full oscillations = 2 clear bounces
const double oscillation = ult::cos(ult::_M_PI * 4.0 * progress);
const double displacement = amplitude * oscillation * damping;
const int offset = static_cast<int>(displacement);
switch (this->m_highlightShakingDirection) {
case FocusDirection::Up: y = -offset; break;
case FocusDirection::Down: y = offset; break;
case FocusDirection::Left: x = -offset; break;
case FocusDirection::Right: x = offset; break;
default: break;
}
}
}
if (this->m_clickAnimationProgress == 0) {
if (ult::useSelectionBG) {
renderer->drawRectAdaptive(this->getX() + x + 4, this->getY() + y, this->getWidth() - 12 +4, this->getHeight(), aWithOpacity(selectionBGColor));
}
#if IS_LAUNCHER_DIRECTIVE
// Determine the active percentage to use
const float activePercentage = ult::displayPercentage.load(std::memory_order_acquire);
if (activePercentage > 0){
renderer->drawRectAdaptive(this->getX() + x + 4, this->getY() + y, (this->getWidth()- 12 +4)*(activePercentage * 0.01f), this->getHeight(), aWithOpacity(progressColor));
}
#endif
renderer->drawBorderedRoundedRect(this->getX() + x, this->getY() + y, this->getWidth() +4, this->getHeight(), 5, 5, a(s_highlightColor));
}
ult::onTrackBar.store(false, std::memory_order_release);
}
/**
* @brief Sets the boundaries of this view
*
* @param x Start X pos
* @param y Start Y pos
* @param width Width
* @param height Height
*/
inline void setBoundaries(s32 x, s32 y, s32 width, s32 height) {
this->m_x = x;
this->m_y = y;
this->m_width = width;
this->m_height = height;
}
/**
* @brief Adds a click listener to the element
*
* @param clickListener Click listener called with keys that were pressed last frame. Callback should return true if keys got consumed
*/
virtual void setClickListener(std::function<bool(u64 keys)> clickListener) {
this->m_clickListener = clickListener;
}
/**
* @brief Gets the element's X position
*
* @return X position
*/
inline s32 getX() { return this->m_x; }
/**
* @brief Gets the element's Y position
*
* @return Y position
*/
inline s32 getY() { return this->m_y; }
/**
* @brief Gets the element's Width
*
* @return Width
*/
inline s32 getWidth() { return this->m_width; }
/**
* @brief Gets the element's Height
*
* @return Height
*/
inline s32 getHeight() { return this->m_height; }
inline s32 getTopBound() { return this->getY(); }
inline s32 getLeftBound() { return this->getX(); }
inline s32 getRightBound() { return this->getX() + this->getWidth(); }
inline s32 getBottomBound() { return this->getY() + this->getHeight(); }
/**
* @brief Check if the coordinates are in the elements bounds
*
* @return true if coordinates are in bounds, false otherwise
*/
bool inBounds(s32 touchX, s32 touchY) {
return touchX >= this->getLeftBound() + int(ult::layerEdge) && touchX <= this->getRightBound() + int(ult::layerEdge) && touchY >= this->getTopBound() && touchY <= this->getBottomBound();
}
/**
* @brief Sets the element's parent
* @note This is required to handle focus and button downpassing properly
*
* @param parent Parent
*/
inline void setParent(Element *parent) { this->m_parent = parent; }
/**
* @brief Get the element's parent
*
* @return Parent
*/
inline Element* getParent() { return this->m_parent; }
virtual std::vector<Element*> getChildren() const {
return {}; // Return empty vector for simplicity
}
/**
* @brief Marks this element as focused or unfocused to draw the highlight
*
* @param focused Focused
*/
virtual void setFocused(bool focused) {
this->m_focused = focused;
this->m_clickAnimationProgress = 0;
}
inline bool hasFocus() {
return this->m_focused;
}
virtual bool matchesJumpCriteria(const std::string& jumpText, const std::string& jumpValue, bool contains) const {
return false; // Default implementation for non-ListItem elements
}
static InputMode getInputMode() { return Element::s_inputMode; }
static void setInputMode(InputMode mode) { Element::s_inputMode = mode; }
protected:
constexpr static inline auto a = &gfx::Renderer::a;
constexpr static inline auto aWithOpacity = &gfx::Renderer::aWithOpacity;
bool m_focused = false;
u8 m_clickAnimationProgress = 0;
// Highlight shake animation
bool m_highlightShaking = false;
u64 m_highlightShakingStartTime; // Changed from chrono::time_point to nanoseconds
FocusDirection m_highlightShakingDirection;
static inline InputMode s_inputMode;
private:
friend class Gui;
s32 m_x = 0, m_y = 0, m_width = 0, m_height = 0;
Element *m_parent = nullptr;
std::vector<Element*> m_children;
std::function<bool(u64 keys)> m_clickListener = [](u64) { return false; };
};
/**
* @brief A Element that exposes the renderer directly to draw custom views easily
*/
class CustomDrawer : public Element {
public:
/**
* @brief Constructor
* @note This element should only be used to draw static things the user cannot interact with e.g info text, images, etc.
*
* @param renderFunc Callback that will be called once every frame to draw this view
*/
CustomDrawer(std::function<void(gfx::Renderer* r, s32 x, s32 y, s32 w, s32 h)> renderFunc) : Element(), m_renderFunc(renderFunc) {
m_isItem = false;
m_isTable = true;
}
virtual ~CustomDrawer() {}
virtual void draw(gfx::Renderer* renderer) override {
this->m_renderFunc(renderer, ELEMENT_BOUNDS(this));
}
virtual void layout(u16 parentX, u16 parentY, u16 parentWidth, u16 parentHeight) override {
}
private:
std::function<void(gfx::Renderer*, s32 x, s32 y, s32 w, s32 h)> m_renderFunc;
};
/**
* @brief A Element that exposes the renderer directly to draw custom views easily
*/
class TableDrawer : public Element {
public:
TableDrawer(std::function<void(gfx::Renderer* r, s32 x, s32 y, s32 w, s32 h)> renderFunc, bool _hideTableBackground, size_t _endGap, bool _isScrollable = false)
: Element(), m_renderFunc(renderFunc), hideTableBackground(_hideTableBackground), endGap(_endGap), isScrollable(_isScrollable) {
m_isTable = isScrollable; // Mark this element as a table
m_isItem = false;
}
virtual ~TableDrawer() {}
virtual void draw(gfx::Renderer* renderer) override {
renderer->enableScissoring(0, 88, tsl::cfg::FramebufferWidth, tsl::cfg::FramebufferHeight - 73 - 97 +2+5);
if (!hideTableBackground)
renderer->drawRoundedRect(this->getX() + 4+2, this->getY()-4-1, this->getWidth() +2 + 1, this->getHeight() + 20 - endGap+2, 12.0, aWithOpacity(tableBGColor));
m_renderFunc(renderer, this->getX() + 4, this->getY(), this->getWidth() + 4, this->getHeight());
renderer->disableScissoring();
}
virtual void layout(u16 parentX, u16 parentY, u16 parentWidth, u16 parentHeight) override {}
virtual bool onClick(u64 keys) {
return false;
}
virtual Element* requestFocus(Element *oldFocus, FocusDirection direction) override {
return nullptr;
}
private:
std::function<void(gfx::Renderer*, s32 x, s32 y, s32 w, s32 h)> m_renderFunc;
bool hideTableBackground = false;
size_t endGap = 3;
bool isScrollable = false;
};
#if IS_LAUNCHER_DIRECTIVE
// Simple utility function to draw the dynamic "Ultra" part of the logo
static s32 drawDynamicUltraText(gfx::Renderer* renderer, s32 startX, s32 y, u32 fontSize,
const tsl::Color& staticColor, bool useNotificationMethod = false) {
static constexpr double cycleDuration = 1.6;
s32 currentX = startX;
if (ult::useDynamicLogo) {
const u64 currentTime_ns = ult::nowNs();
const double currentTimeCount = static_cast<double>(currentTime_ns) / 1000000000.0;
const double timeBase = std::fmod(currentTimeCount, cycleDuration);
const double waveScale = 2.0 * ult::_M_PI / cycleDuration;
static constexpr double phaseShift = ult::_M_PI / 2.0;
float countOffset = 0;
for (const char letter : ult::SPLIT_PROJECT_NAME_1) {
const double wavePhase = waveScale * (timeBase + static_cast<double>(countOffset));
const double rawProgress = ult::cos(wavePhase - phaseShift);
const double normalizedProgress = (rawProgress + 1.0) * 0.5;
const double smoothedProgress = normalizedProgress * normalizedProgress * (3.0 - 2.0 * normalizedProgress);
const double ultraSmoothProgress = smoothedProgress * smoothedProgress * (3.0 - 2.0 * smoothedProgress);
const double blend = std::max(0.0, std::min(1.0, ultraSmoothProgress));
const tsl::Color _highlightColor = lerpColor(dynamicLogoRGB2, dynamicLogoRGB1, blend);
const std::string letterStr(1, letter);
if (useNotificationMethod) {
currentX += renderer->drawNotificationString(letterStr, false, currentX, y, fontSize, _highlightColor).first;
} else {
currentX += renderer->drawString(letterStr, false, currentX, y, fontSize, _highlightColor).first;
}
countOffset -= static_cast<float>(cycleDuration / 8.0);
}
} else {
// Static rendering
for (const char letter : ult::SPLIT_PROJECT_NAME_1) {
const std::string letterStr(1, letter);
if (useNotificationMethod) {
currentX += renderer->drawNotificationString(letterStr, false, currentX, y, fontSize, staticColor).first;
} else {
currentX += renderer->drawString(letterStr, false, currentX, y, fontSize, staticColor).first;
}
}
}
return currentX;
}
// Utility function to calculate width of the Ultra text (for notification centering)
static s32 calculateUltraTextWidth(gfx::Renderer* renderer, u32 fontSize, bool useNotificationMethod = false) {
s32 totalWidth = 0;
if (ult::useDynamicLogo) {
// Calculate width by measuring each character for dynamic rendering
for (const char letter : ult::SPLIT_PROJECT_NAME_1) {
const std::string letterStr(1, letter);
if (useNotificationMethod) {
totalWidth += renderer->getNotificationTextDimensions(letterStr, false, fontSize).first;
} else {
totalWidth += renderer->getTextDimensions(letterStr, false, fontSize).first;
}
}
} else {
// Static rendering - measure the whole string at once
if (useNotificationMethod) {
totalWidth = renderer->getNotificationTextDimensions(ult::SPLIT_PROJECT_NAME_1, false, fontSize).first;
} else {
totalWidth = renderer->getTextDimensions(ult::SPLIT_PROJECT_NAME_1, false, fontSize).first;
}
}
return totalWidth;
}
#endif
/**
* @brief The base frame which can contain another view
*
*/
class OverlayFrame : public Element {
public:
/**
* @brief Constructor
*
* @param title Name of the Overlay drawn bolt at the top
* @param subtitle Subtitle drawn bellow the title e.g version number
*/
std::string m_title;
std::string m_subtitle;
bool m_noClickableItems;
#if IS_LAUNCHER_DIRECTIVE
std::string m_menuMode;
std::string m_colorSelection;
tsl::Color titleColor = {0xF,0xF,0xF,0xF};
float letterWidth;
#endif
std::string m_pageLeftName;
std::string m_pageRightName;
#if USING_WIDGET_DIRECTIVE
bool m_showWidget = false;
#endif
float x, y;
int offset, y_offset;
int fontSize;
#if IS_LAUNCHER_DIRECTIVE
OverlayFrame(const std::string& title, const std::string& subtitle, const bool& _noClickableItems=false, const std::string& menuMode = "", const std::string& colorSelection = "", const std::string& pageLeftName = "", const std::string& pageRightName = "")
: Element(), m_title(title), m_subtitle(subtitle), m_noClickableItems(_noClickableItems), m_menuMode(menuMode), m_colorSelection(colorSelection), m_pageLeftName(pageLeftName), m_pageRightName(pageRightName) {
#else
OverlayFrame(const std::string& title, const std::string& subtitle, const bool& _noClickableItems=false, const std::string& pageLeftName = "", const std::string& pageRightName = "")
: Element(), m_title(title), m_subtitle(subtitle), m_noClickableItems(_noClickableItems), m_pageLeftName(pageLeftName), m_pageRightName(pageRightName) {
#endif
ult::activeHeaderHeight = 97;
ult::loadWallpaperFileWhenSafe();
m_isItem = false;
disableSound.store(false, std::memory_order_release);
}
~OverlayFrame() {
delete m_contentElement;
}
#if USING_FPS_INDICATOR_DIRECTIVE
// Function to calculate FPS
inline float updateFPS(double currentTimeCount) {
static double lastUpdateTime = currentTimeCount;
static int frameCount = 0;
static float fps = 0.0f;
++frameCount;
const double elapsedTime = currentTimeCount - lastUpdateTime;
if (elapsedTime >= 1.0) { // Update FPS every second
fps = frameCount / static_cast<float>(elapsedTime);
lastUpdateTime = currentTimeCount;
frameCount = 0;
}
return fps;
}
#endif
void draw(gfx::Renderer *renderer) override {
renderer->fillScreen(a(defaultBackgroundColor));
renderer->drawWallpaper();
y = 50;
offset = 0;
#if IS_LAUNCHER_DIRECTIVE
const bool interpreterIsRunningNow = ult::runningInterpreter.load(std::memory_order_acquire) &&
(ult::downloadPercentage.load(std::memory_order_acquire) != -1 ||
ult::unzipPercentage.load(std::memory_order_acquire) != -1 ||
ult::copyPercentage.load(std::memory_order_acquire) != -1);
if (m_noClickableItems != ult::noClickableItems.load(std::memory_order_acquire))
ult::noClickableItems.store(m_noClickableItems, std::memory_order_release);
const bool renderIsUltrahandMenu = (m_title == ult::CAPITAL_ULTRAHAND_PROJECT_NAME &&
m_subtitle.find("Ultrahand Package") == std::string::npos &&
m_subtitle.find("Ultrahand Script") == std::string::npos);
bool widgetDrawn = false;
if (renderIsUltrahandMenu) {
#if USING_WIDGET_DIRECTIVE
widgetDrawn = renderer->drawWidget();
#endif
if (ult::touchingMenu.load(std::memory_order_acquire) &&
(ult::inMainMenu.load(std::memory_order_acquire) ||
(ult::inHiddenMode.load(std::memory_order_acquire) &&
!ult::inSettingsMenu.load(std::memory_order_acquire) &&
!ult::inSubSettingsMenu.load(std::memory_order_acquire)))) {
renderer->drawRoundedRect(7.0f, 12.0f, 232.0f, 73.0f, 12.0f, a(clickColor));
}
x = 20; fontSize = 42; offset = 6;
if (ult::useDynamicLogo) {
x = drawDynamicUltraText(renderer, x, y + offset, fontSize, logoColor1, false);
} else {
for (const char letter : ult::SPLIT_PROJECT_NAME_1) {
const std::string letterStr(1, letter);
x += renderer->drawString(letterStr, false, x, y + offset, fontSize, logoColor1).first;
}
}
renderer->drawString(ult::SPLIT_PROJECT_NAME_2, false, x, y + offset, fontSize, logoColor2);
} else {
#if USING_WIDGET_DIRECTIVE
widgetDrawn = m_showWidget && renderer->drawWidget();
#endif
x = 20; y = 50; fontSize = 32;
calcScrollWidth(renderer, titleScroll, m_title, 32, widgetDrawn);
const bool isScript = m_subtitle.find("Ultrahand Script") != std::string::npos;
drawScrollableText(renderer, titleScroll, isScript ? defaultScriptColor : getPackageColor(), x, y, 32, 27, 35);
}
{
std::string subtitle = m_subtitle;
const size_t pos = subtitle.find("?Ultrahand Script");
if (pos != std::string::npos) subtitle.erase(pos, 17);
calcScrollWidth(renderer, subScroll, subtitle, 15, widgetDrawn);
const int subtitleX = 20, subtitleY = y + 25;
if (m_title == ult::CAPITAL_ULTRAHAND_PROJECT_NAME) {
renderer->drawStringWithColoredSections(ult::versionLabel, false, tsl::s_dividerSpecialChars,
subtitleX, subtitleY, 15, bannerVersionTextColor, textSeparatorColor);
} else if (subScroll.trunc) {
if (!subScroll.active) { subScroll.active = true; subScroll.timeIn = ult::nowNs(); }
renderer->enableScissoring(subtitleX, subtitleY - 16, subScroll.maxW, 24);
renderer->drawStringWithColoredSections(subScroll.scrollText, false, tsl::s_dividerSpecialChars,
subtitleX - static_cast<s32>(subScroll.offset), subtitleY, 15, bannerVersionTextColor, textSeparatorColor);
renderer->disableScissoring();
updateScroll(subScroll);
} else {
renderer->drawStringWithColoredSections(subtitle, false, tsl::s_dividerSpecialChars,
subtitleX, subtitleY, 15, bannerVersionTextColor, textSeparatorColor);
}
}
#else
if (m_noClickableItems != ult::noClickableItems.load(std::memory_order_acquire))
ult::noClickableItems.store(m_noClickableItems, std::memory_order_release);
bool widgetDrawn = false;
#if USING_WIDGET_DIRECTIVE
widgetDrawn = m_showWidget && renderer->drawWidget();
#endif
calcScrollWidth(renderer, titleScroll, m_title, 32, widgetDrawn);
drawScrollableText(renderer, titleScroll, defaultOverlayColor, 20, 50, 32, 27, 35);
calcScrollWidth(renderer, subScroll, m_subtitle, 15, widgetDrawn);
{
const int subtitleX = 20, subtitleY = y + 25;
if (subScroll.trunc) {
if (!subScroll.active) { subScroll.active = true; subScroll.timeIn = ult::nowNs(); }
renderer->enableScissoring(subtitleX, subtitleY - 16, subScroll.maxW, 24);
renderer->drawString(subScroll.scrollText, false,
subtitleX - static_cast<s32>(subScroll.offset), subtitleY, 15, bannerVersionTextColor);
renderer->disableScissoring();
updateScroll(subScroll);
} else {
renderer->drawString(m_subtitle, false, subtitleX, subtitleY, 15, bannerVersionTextColor);
}
}
#endif
renderer->drawRect(15, tsl::cfg::FramebufferHeight - 73, tsl::cfg::FramebufferWidth - 30, 1, a(bottomSeparatorColor));
// Atomic update helper
const auto updateAtomic = [](std::atomic<float>& atom, float val) {
if (val != atom.load(std::memory_order_acquire))
atom.store(val, std::memory_order_release);
};
const float gapWidth = renderer->getTextDimensions(ult::GAP_1, false, 23).first;
#if IS_LAUNCHER_DIRECTIVE
const float backTextWidth = renderer->getTextDimensions("\uE0E1" + ult::GAP_2 + (!interpreterIsRunningNow ? ult::BACK : ult::HIDE), false, 23).first;
const float selectTextWidth = renderer->getTextDimensions("\uE0E0" + ult::GAP_2 + (!interpreterIsRunningNow ? ult::OK : ult::CANCEL), false, 23).first;
#else
const float backTextWidth = renderer->getTextDimensions("\uE0E1" + ult::GAP_2 + ult::BACK, false, 23).first;
const float selectTextWidth = renderer->getTextDimensions("\uE0E0" + ult::GAP_2 + ult::OK, false, 23).first;
#endif
const float _halfGap = gapWidth * 0.5f;
const float _backWidth = backTextWidth + gapWidth;
const float _selectWidth = selectTextWidth + gapWidth;
updateAtomic(ult::halfGap, _halfGap);
updateAtomic(ult::backWidth, _backWidth);
updateAtomic(ult::selectWidth, _selectWidth);
static constexpr float buttonStartX = 30;
const float buttonY = static_cast<float>(cfg::FramebufferHeight - 73 + 1);
if (ult::touchingBack)
renderer->drawRoundedRect(buttonStartX+2 - _halfGap, buttonY, _backWidth-1, 73.0f, 12.0f, a(clickColor));
if (ult::touchingSelect.load(std::memory_order_acquire) && !m_noClickableItems)
renderer->drawRoundedRect(buttonStartX+2 - _halfGap + _backWidth+1, buttonY, _selectWidth-2, 73.0f, 12.0f, a(clickColor));
#if IS_LAUNCHER_DIRECTIVE
const bool hasNextPage = !interpreterIsRunningNow &&
((ult::inMainMenu.load(std::memory_order_acquire) &&
((m_menuMode == ult::OVERLAYS_STR) || (m_menuMode == ult::PACKAGES_STR))) ||
!m_pageLeftName.empty() || !m_pageRightName.empty());
if (hasNextPage != ult::hasNextPageButton.load(std::memory_order_acquire))
ult::hasNextPageButton.store(hasNextPage, std::memory_order_release);
if (hasNextPage) {
const float _nextPageWidth = renderer->getTextDimensions(
!m_pageLeftName.empty() ? ("\uE0ED" + ult::GAP_2 + m_pageLeftName) :
!m_pageRightName.empty() ? ("\uE0EE" + ult::GAP_2 + m_pageRightName) :
(ult::inMainMenu.load(std::memory_order_acquire) ?
(((m_menuMode == "packages") ? (ult::usePageSwap ? "\uE0EE" : "\uE0ED") :
(ult::usePageSwap ? "\uE0ED" : "\uE0EE")) +
ult::GAP_2 + (ult::inOverlaysPage.load(std::memory_order_acquire) ? ult::PACKAGES : ult::OVERLAYS_ABBR)) : ""),
false, 23).first + gapWidth;
updateAtomic(ult::nextPageWidth, _nextPageWidth);
if (ult::touchingNextPage.load(std::memory_order_acquire)) {
float nextX = buttonStartX+2 - _halfGap + _backWidth + 1;
if (!m_noClickableItems) nextX += _selectWidth;
renderer->drawRoundedRect(nextX, buttonY, _nextPageWidth-2, 73.0f, 12.0f, a(clickColor));
}
}
#else
const bool hasNextPage = !m_pageLeftName.empty() || !m_pageRightName.empty();
if (hasNextPage != ult::hasNextPageButton.load(std::memory_order_acquire))
ult::hasNextPageButton.store(hasNextPage, std::memory_order_release);
if (hasNextPage) {
const float _nextPageWidth = renderer->getTextDimensions(
!m_pageLeftName.empty() ? ("\uE0ED" + ult::GAP_2 + m_pageLeftName)
: ("\uE0EE" + ult::GAP_2 + m_pageRightName),
false, 23).first + gapWidth;
updateAtomic(ult::nextPageWidth, _nextPageWidth);
if (ult::touchingNextPage.load(std::memory_order_acquire)) {
float nextX = buttonStartX+2 - _halfGap + _backWidth + 1;
if (!m_noClickableItems) nextX += _selectWidth;
renderer->drawRoundedRect(nextX, buttonY, _nextPageWidth-2, 73.0f, 12.0f, a(clickColor));
}
} else {
ult::nextPageWidth.store(0.0f, std::memory_order_release);
}
#endif
#if IS_LAUNCHER_DIRECTIVE
const std::string currentBottomLine =
"\uE0E1" + ult::GAP_2 +
(interpreterIsRunningNow ? ult::HIDE : ult::BACK) + ult::GAP_1 +
(!m_noClickableItems && !interpreterIsRunningNow ? "\uE0E0" + ult::GAP_2 + ult::OK + ult::GAP_1 : "") +
(interpreterIsRunningNow ? "\uE0E5" + ult::GAP_2 + ult::CANCEL + ult::GAP_1 : "") +
(!interpreterIsRunningNow
? (!ult::usePageSwap
? ((m_menuMode == "packages") ? "\uE0ED" + ult::GAP_2 + ult::OVERLAYS_ABBR
: (m_menuMode == "overlays") ? "\uE0EE" + ult::GAP_2 + ult::PACKAGES : "")
: ((m_menuMode == "packages") ? "\uE0EE" + ult::GAP_2 + ult::OVERLAYS_ABBR
: (m_menuMode == "overlays") ? "\uE0ED" + ult::GAP_2 + ult::PACKAGES : ""))
: "") +
(!interpreterIsRunningNow && !m_pageLeftName.empty() ? "\uE0ED" + ult::GAP_2 + m_pageLeftName :
!interpreterIsRunningNow && !m_pageRightName.empty() ? "\uE0EE" + ult::GAP_2 + m_pageRightName : "");
const bool _hasOkBtn = !m_noClickableItems && !interpreterIsRunningNow;
#else
const std::string currentBottomLine =
"\uE0E1" + ult::GAP_2 + ult::BACK + ult::GAP_1 +
(!m_noClickableItems ? "\uE0E0" + ult::GAP_2 + ult::OK + ult::GAP_1 : "") +
(!m_pageLeftName.empty() ? "\uE0ED" + ult::GAP_2 + m_pageLeftName :
!m_pageRightName.empty() ? "\uE0EE" + ult::GAP_2 + m_pageRightName : "");
const bool _hasOkBtn = !m_noClickableItems;
#endif
renderer->drawStringWithColoredSections(currentBottomLine, false, tsl::s_footerSpecialChars,
buttonStartX, 693, 23, bottomTextColor, buttonColor);
if (_hasOkBtn && !usingUnfocusedColor) {
static const std::string okOverdraw = "\uE0E0" + ult::GAP_2 + ult::OK + ult::GAP_1;
renderer->drawStringWithColoredSections(okOverdraw, false, tsl::s_footerSpecialChars,
buttonStartX + _backWidth, 693, 23, unfocusedColor, unfocusedColor);
}
#if USING_FPS_INDICATOR_DIRECTIVE
{
const u64 currentTime_ns = ult::nowNs();
const float currentFps = updateFPS(currentTime_ns / 1e9);
static char fpsBuffer[32];
static float lastFps = -1.0f;
if (std::abs(currentFps - lastFps) > 0.1f) {
snprintf(fpsBuffer, sizeof(fpsBuffer), "FPS: %.2f", currentFps);
lastFps = currentFps;
}
static constexpr tsl::Color whiteColor = {0xF,0xF,0xF,0xF};
renderer->drawString(fpsBuffer, false, 20, tsl::cfg::FramebufferHeight - 60, 20, whiteColor);
}
#endif
if (m_contentElement != nullptr)
m_contentElement->frame(renderer);
if (!ult::useRightAlignment)
renderer->drawRect(447, 0, 448, 720, a(edgeSeparatorColor));
else
renderer->drawRect(0, 0, 1, 720, a(edgeSeparatorColor));
}
inline void layout(u16 parentX, u16 parentY, u16 parentWidth, u16 parentHeight) override {
setBoundaries(parentX, parentY, parentWidth, parentHeight);
if (m_contentElement != nullptr) {
m_contentElement->setBoundaries(parentX + 35, parentY + 97, parentWidth - 85, parentHeight - 73 - 105);
m_contentElement->invalidate();
}
}
inline Element* requestFocus(Element *oldFocus, FocusDirection direction) override {
return m_contentElement ? m_contentElement->requestFocus(oldFocus, direction) : nullptr;
}
inline bool onTouch(TouchEvent event, s32 currX, s32 currY, s32 prevX, s32 prevY, s32 initialX, s32 initialY) {
// Discard touches outside bounds
if (!m_contentElement || !m_contentElement->inBounds(currX, currY))
return false;
return m_contentElement->onTouch(event, currX, currY, prevX, prevY, initialX, initialY);
}
/**
* @brief Sets the content of the frame
*
* @param content Element
*/
inline void setContent(Element *content) {
delete m_contentElement;
m_contentElement = content;
if (content != nullptr) {
m_contentElement->setParent(this);
invalidate();
}
}
/**
* @brief Changes the title of the menu
*
* @param title Title to change to
*/
inline void setTitle(const std::string& t) { resetScroll(titleScroll, m_title, t); }
/**
* @brief Changes the subtitle of the menu
*
* @param title Subtitle to change to
*/
inline void setSubtitle(const std::string& s) { resetScroll(subScroll, m_subtitle, s); }
protected:
Element *m_contentElement = nullptr;
private:
// Unified scroll state structure
struct ScrollState {
u64 timeIn, lastUpd;
float offset;
u32 maxW, textW;
bool active, trunc;
std::string scrollText;
};
ScrollState subScroll = {0, 0, 0.0f, 0, 0, false, false, ""};
ScrollState titleScroll = {0, 0, 0.0f, 0, 0, false, false, ""};
// Unified width calculation
void calcScrollWidth(gfx::Renderer* renderer, ScrollState& s, const std::string& text, u32 fontSize, bool widgetDrawn) {
if (s.maxW) return;
s.maxW = widgetDrawn ? 217 : (tsl::cfg::FramebufferWidth - 40);
const u32 w = renderer->getTextDimensions(text, false, fontSize).first;
s.trunc = w > s.maxW;
if (s.trunc) {
s.scrollText = text + " ";
s.textW = renderer->getTextDimensions(s.scrollText, false, fontSize).first;
s.scrollText += text;
} else {
s.textW = w;
}
}
static void resetScroll(ScrollState& s, std::string& dest, const std::string& src) {
if (dest == src) return;
dest = src;
s.maxW = 0;
s.active = s.trunc = false;
}
#if IS_LAUNCHER_DIRECTIVE
// Get package color based on m_colorSelection
tsl::Color getPackageColor() const {
if (m_colorSelection.empty()) return defaultPackageColor;
const char c = m_colorSelection[0];
const size_t len = m_colorSelection.length();
switch (c) {
case 'g': return (len == 5) ? tsl::Color{0x0,0xF,0x0,0xF} : defaultPackageColor;
case 'r': return (len == 3) ? tsl::Color{0xF,0x2,0x4,0xF} : defaultPackageColor;
case 'b': return (len == 4) ? tsl::Color{0x7,0x7,0xF,0xF} : defaultPackageColor;
case 'y': return (len == 6) ? tsl::Color{0xF,0xF,0x0,0xF} : defaultPackageColor;
case 'o': return (len == 6) ? tsl::Color{0xF,0xA,0x0,0xF} : defaultPackageColor;
case 'p':
if (len == 4) return tsl::Color{0xF,0x6,0xB,0xF};
if (len == 6) return tsl::Color{0x8,0x0,0x8,0xF};
return defaultPackageColor;
case 'w': return (len == 5) ? tsl::Color{0xF,0xF,0xF,0xF} : defaultPackageColor;
case '#':
return (len == 7 && isValidHexColor(m_colorSelection.substr(1)))
? RGB888(m_colorSelection.substr(1)) : defaultPackageColor;
default: return defaultPackageColor;
}
}
#endif
// Draw scrollable text with common parameters
void drawScrollableText(gfx::Renderer* renderer, ScrollState& s, const tsl::Color& clr,
int xPos, int yPos, u32 fontSize, int scissorYOffset, int scissorHeight) {
if (s.trunc) {
if (!s.active) {
s.active = true;
s.timeIn = ult::nowNs();
}
renderer->enableScissoring(xPos, yPos - scissorYOffset, s.maxW, scissorHeight);
renderer->drawString(s.scrollText, false, xPos - static_cast<s32>(s.offset), yPos, fontSize, clr);
renderer->disableScissoring();
updateScroll(s);
} else {
renderer->drawString(m_title, false, xPos, yPos, fontSize, clr);
}
}
// Unified scroll update
void updateScroll(ScrollState& s) {
const u64 now = ult::nowNs();
// Only update at ~120Hz
if (now - s.lastUpd < 8333333ULL) return;
static constexpr double delay = 3.0, pause = 2.0, vel = 100.0, accel = 0.5, decel = 0.5;
static constexpr double invBil = 1e-9, invAccel = 2.0, invDecel = 2.0;
const double minDist = s.textW;
const double accelDist = 0.5 * vel * accel;
const double decelDist = 0.5 * vel * decel;
const double constDist = std::max(0.0, minDist - accelDist - decelDist);
const double constTime = constDist / vel;
const double totalDur = delay + accel + constTime + decel + pause;
const double t = (now - s.timeIn) * invBil;
const double cycle = std::fmod(t, totalDur);
if (cycle < delay) {
s.offset = 0.0f;
} else if (cycle < delay + accel + constTime + decel) {
const double st = cycle - delay;
double d;
if (st <= accel) {
const double r = st * invAccel;
d = r * r * accelDist;
} else if (st <= accel + constTime) {
d = accelDist + (st - accel) * vel;
} else {
const double r = (st - accel - constTime) * invDecel;
const double omr = 1.0 - r;
d = accelDist + constDist + (1.0 - omr * omr) * (minDist - accelDist - constDist);
}
s.offset = static_cast<float>(std::min(d, minDist));
} else {
s.offset = static_cast<float>(s.textW);
}
s.lastUpd = now;
if (t >= totalDur) s.timeIn = now;
}
};
#if IS_STATUS_MONITOR_DIRECTIVE
/**
* @brief The base frame which can contain another view
*
*/
class HeaderOverlayFrame : public Element {
public:
/**
* @brief Constructor
*
* @param title Name of the Overlay drawn bolt at the top
* @param subtitle Subtitle drawn bellow the title e.g version number
*/
std::string m_title;
std::string m_subtitle;
bool m_noClickableItems;
float x, y;
int offset, y_offset;
int fontSize;
HeaderOverlayFrame(const std::string& title, const std::string& subtitle, const bool& _noClickableItems=false)
: Element(), m_title(title), m_subtitle(subtitle), m_noClickableItems(_noClickableItems) {
ult::activeHeaderHeight = 97;
if (FullMode)
ult::loadWallpaperFileWhenSafe();
else
svcSleepThread(250'000); // sleep thread for initial values to auto-load
m_isItem = false;
}
virtual ~HeaderOverlayFrame() {
if (this->m_contentElement != nullptr)
delete this->m_contentElement;
}
virtual void draw(gfx::Renderer *renderer) override {
if (m_noClickableItems != ult::noClickableItems.load(std::memory_order_acquire)) {
ult::noClickableItems.store(m_noClickableItems, std::memory_order_release);
}
if (FullMode == true) {
renderer->fillScreen(a(defaultBackgroundColor));
if (lastMode.empty() || (lastMode.compare("returning") == 0))
renderer->drawWallpaper();
} else {
renderer->fillScreen({ 0x0, 0x0, 0x0, 0x0});
}
y = 50;
offset = 0;
// Use cached or current data for rendering
const std::string& renderTitle = m_title;
const std::string& renderSubtitle = m_subtitle;
renderer->drawString(renderTitle, false, 20, 50, 32, defaultOverlayColor);
renderer->drawString(renderSubtitle, false, 20, y+2+23, 15, bannerVersionTextColor);
if (FullMode == true)
renderer->drawRect(15, tsl::cfg::FramebufferHeight - 73, tsl::cfg::FramebufferWidth - 30, 1, a(bottomSeparatorColor));
// Set initial button position
static constexpr float buttonStartX = 30;
if (FullMode && !deactivateOriginalFooter) {
// Get the exact gap width from ult::GAP_1
const auto gapWidth = renderer->getTextDimensions(ult::GAP_1, false, 23).first;
const float _halfGap = gapWidth / 2.0f;
if (_halfGap != ult::halfGap.load(std::memory_order_acquire))
ult::halfGap.store(_halfGap, std::memory_order_release);
// Calculate text dimensions for buttons without gaps
const auto backTextWidth = renderer->getTextDimensions("\uE0E1" + ult::GAP_2 + ult::BACK, false, 23).first;
const auto selectTextWidth = renderer->getTextDimensions("\uE0E0" + ult::GAP_2 + ult::OK, false, 23).first;
// Update widths to include the half-gap padding on each side
const float _backWidth = backTextWidth + gapWidth;
if (_backWidth != ult::backWidth.load(std::memory_order_acquire))
ult::backWidth.store(_backWidth, std::memory_order_release);
const float _selectWidth = selectTextWidth + gapWidth;
if (_selectWidth != ult::selectWidth.load(std::memory_order_acquire))
ult::selectWidth.store(_selectWidth, std::memory_order_release);
const float buttonY = static_cast<float>(cfg::FramebufferHeight - 73 + 1);
// Draw back button rectangle
if (ult::touchingBack.load(std::memory_order_acquire)) {
renderer->drawRoundedRect(buttonStartX+2 - _halfGap, buttonY, _backWidth-1, 73.0f, 12.0f, a(clickColor));
}
// Draw select button rectangle (starts right after back button)
if (ult::touchingSelect.load(std::memory_order_acquire) && !m_noClickableItems) {
renderer->drawRoundedRect(buttonStartX+2 - _halfGap + _backWidth+1, buttonY,
_selectWidth-2, 73.0f, 12.0f, a(clickColor));
}
}
// Build current bottom line
const std::string currentBottomLine =
"\uE0E1" + ult::GAP_2 + ult::BACK + ult::GAP_1 +
(!m_noClickableItems
? "\uE0E0" + ult::GAP_2 + ult::OK + ult::GAP_1
: "");
const std::string& menuBottomLine = currentBottomLine;
// Render the text with special character handling
if (!deactivateOriginalFooter) {
renderer->drawStringWithColoredSections(menuBottomLine, false, tsl::s_footerSpecialChars,
buttonStartX, 693, 23, bottomTextColor, buttonColor);
if (!m_noClickableItems && !usingUnfocusedColor) {
renderer->drawStringWithColoredSections("\uE0E0" + ult::GAP_2 + ult::OK + ult::GAP_1, false,
tsl::s_footerSpecialChars,
buttonStartX + ult::backWidth.load(std::memory_order_acquire),
693, 23, unfocusedColor, unfocusedColor);
}
}
if (this->m_contentElement != nullptr)
this->m_contentElement->frame(renderer);
if (FullMode) {
if (!ult::useRightAlignment)
renderer->drawRect(447, 0, 448, 720, a(edgeSeparatorColor));
else
renderer->drawRect(0, 0, 1, 720, a(edgeSeparatorColor));
}
}
virtual void layout(u16 parentX, u16 parentY, u16 parentWidth, u16 parentHeight) override {
this->setBoundaries(parentX, parentY, parentWidth, parentHeight);
if (this->m_contentElement != nullptr) {
this->m_contentElement->setBoundaries(parentX + 35, parentY + ult::activeHeaderHeight, parentWidth - 85, parentHeight - 73 - 105);
this->m_contentElement->invalidate();
}
}
virtual Element* requestFocus(Element *oldFocus, FocusDirection direction) override {
if (this->m_contentElement != nullptr)
return this->m_contentElement->requestFocus(oldFocus, direction);
else
return nullptr;
}
virtual bool onTouch(TouchEvent event, s32 currX, s32 currY, s32 prevX, s32 prevY, s32 initialX, s32 initialY) {
// Discard touches outside bounds
if (!this->m_contentElement->inBounds(currX, currY))
return false;
if (this->m_contentElement != nullptr)
return this->m_contentElement->onTouch(event, currX, currY, prevX, prevY, initialX, initialY);
else return false;
}
/**
* @brief Sets the content of the frame
*
* @param content Element
*/
inline void setContent(Element *content) {
if (this->m_contentElement != nullptr)
delete this->m_contentElement;
this->m_contentElement = content;
if (content != nullptr) {
this->m_contentElement->setParent(this);
this->invalidate();
}
}
/**
* @brief Changes the title of the menu
*
* @param title Title to change to
*/
inline void setTitle(const std::string &title) {
this->m_title = title;
}
/**
* @brief Changes the subtitle of the menu
*
* @param title Subtitle to change to
*/
inline void setSubtitle(const std::string &subtitle) {
this->m_subtitle = subtitle;
}
protected:
Element *m_contentElement = nullptr;
//std::string m_title, m_subtitle;
};
#else
/**
* @brief The base frame which can contain another view with a customizable header
*
*/
class HeaderOverlayFrame : public Element {
public:
#if USING_WIDGET_DIRECTIVE
bool m_showWidget = false;
#endif
HeaderOverlayFrame(u16 headerHeight = 175) : Element(), m_headerHeight(headerHeight) {
ult::activeHeaderHeight = headerHeight;
// Load the bitmap file into memory
ult::loadWallpaperFileWhenSafe();
m_isItem = false;
}
virtual ~HeaderOverlayFrame() {
if (this->m_contentElement != nullptr)
delete this->m_contentElement;
if (this->m_header != nullptr)
delete this->m_header;
}
virtual void draw(gfx::Renderer *renderer) override {
renderer->fillScreen(a(defaultBackgroundColor));
renderer->drawWallpaper();
renderer->drawRect(15, tsl::cfg::FramebufferHeight - 73, tsl::cfg::FramebufferWidth - 30, 1, a(bottomSeparatorColor));
#if USING_WIDGET_DIRECTIVE
if (m_showWidget)
renderer->drawWidget();
#endif
// Get the exact gap width from ult::GAP_1
const float gapWidth = renderer->getTextDimensions(ult::GAP_1, false, 23).first;
const float _halfGap = gapWidth / 2.0f;
if (_halfGap != ult::halfGap.load(std::memory_order_acquire))
ult::halfGap.store(_halfGap, std::memory_order_release);
// Calculate text dimensions for buttons without gaps
const float backTextWidth = renderer->getTextDimensions("\uE0E1" + ult::GAP_2 + ult::BACK, false, 23).first;
const float selectTextWidth = renderer->getTextDimensions("\uE0E0" + ult::GAP_2 + ult::OK, false, 23).first;
// Store final widths with gap padding included
const float _backWidth = backTextWidth + gapWidth;
if (_backWidth != ult::backWidth.load(std::memory_order_acquire))
ult::backWidth.store(_backWidth, std::memory_order_release);
const float _selectWidth = selectTextWidth + gapWidth;
if (_selectWidth != ult::selectWidth.load(std::memory_order_acquire))
ult::selectWidth.store(_selectWidth, std::memory_order_release);
// Set initial button position
static constexpr float buttonStartX = 30;
const float buttonY = static_cast<float>(cfg::FramebufferHeight - 73 + 1);
// Draw back button rectangle
if (ult::touchingBack.load(std::memory_order_acquire)) {
renderer->drawRoundedRect(buttonStartX+2 - _halfGap, buttonY, _backWidth-1, 73.0f, 12.0f, a(clickColor));
}
// Draw select button rectangle
if (ult::touchingSelect.load(std::memory_order_acquire)) {
renderer->drawRoundedRect(buttonStartX+2 - _halfGap + _backWidth+1, buttonY,
_selectWidth-2, 73.0f, 12.0f, a(clickColor));
}
// Draw bottom text
const std::string menuBottomLine = "\uE0E1" + ult::GAP_2 + ult::BACK + ult::GAP_1 +
"\uE0E0" + ult::GAP_2 + ult::OK + ult::GAP_1;
renderer->drawStringWithColoredSections(menuBottomLine, false,
{"\uE0E1", "\uE0E0", "\uE0ED", "\uE0EE"},
buttonStartX, 693, 23,
bottomTextColor, buttonColor);
if (!usingUnfocusedColor) {
renderer->drawStringWithColoredSections("\uE0E0" + ult::GAP_2 + ult::OK + ult::GAP_1, false,
{"\uE0E1", "\uE0E0", "\uE0ED", "\uE0EE"},
buttonStartX + _backWidth, 693, 23,
unfocusedColor, unfocusedColor);
}
if (this->m_header != nullptr)
this->m_header->frame(renderer);
if (this->m_contentElement != nullptr)
this->m_contentElement->frame(renderer);
if (!ult::useRightAlignment)
renderer->drawRect(447, 0, 448, 720, a(edgeSeparatorColor));
else
renderer->drawRect(0, 0, 1, 720, a(edgeSeparatorColor));
}
virtual void layout(u16 parentX, u16 parentY, u16 parentWidth, u16 parentHeight) override {
this->setBoundaries(parentX, parentY, parentWidth, parentHeight);
if (this->m_contentElement != nullptr) {
this->m_contentElement->setBoundaries(parentX + 35, parentY + this->m_headerHeight, parentWidth - 85, parentHeight - 73 - this->m_headerHeight -8);
this->m_contentElement->invalidate();
}
if (this->m_header != nullptr) {
this->m_header->setBoundaries(parentX, parentY, parentWidth, this->m_headerHeight);
this->m_header->invalidate();
}
}
virtual bool onTouch(TouchEvent event, s32 currX, s32 currY, s32 prevX, s32 prevY, s32 initialX, s32 initialY) {
// Discard touches outside bounds
if (!this->m_contentElement->inBounds(currX, currY))
return false;
if (this->m_contentElement != nullptr)
return this->m_contentElement->onTouch(event, currX, currY, prevX, prevY, initialX, initialY);
else return false;
}
virtual Element* requestFocus(Element *oldFocus, FocusDirection direction) override {
if (this->m_contentElement != nullptr)
return this->m_contentElement->requestFocus(oldFocus, direction);
else
return nullptr;
}
/**
* @brief Sets the content of the frame
*
* @param content Element
*/
inline void setContent(Element *content) {
if (this->m_contentElement != nullptr)
delete this->m_contentElement;
this->m_contentElement = content;
if (content != nullptr) {
this->m_contentElement->setParent(this);
this->invalidate();
}
}
/**
* @brief Sets the header of the frame
*
* @param header Header custom drawer
*/
inline void setHeader(CustomDrawer *header) {
if (this->m_header != nullptr)
delete this->m_header;
this->m_header = header;
if (header != nullptr) {
this->m_header->setParent(this);
this->invalidate();
}
}
protected:
Element *m_contentElement = nullptr;
CustomDrawer *m_header = nullptr;
u16 m_headerHeight;
};
#endif
/**
* @brief Single color rectangle element mainly used for debugging to visualize boundaries
*
*/
class DebugRectangle : public Element {
public:
/**
* @brief Constructor
*
* @param color Color of the rectangle
*/
DebugRectangle(Color color) : Element(), m_color(color) {
m_isItem = false;
}
virtual ~DebugRectangle() {}
virtual void draw(gfx::Renderer *renderer) override {
renderer->drawRect(ELEMENT_BOUNDS(this), a(this->m_color));
}
virtual void layout(u16 parentX, u16 parentY, u16 parentWidth, u16 parentHeight) override {}
private:
Color m_color;
};
class ListItem; // forward declaration
static std::atomic<float> s_currentScrollVelocity{0};
static std::atomic<bool> s_directionalKeyReleased{false};
static std::atomic<bool> lastInternalTouchRelease{true};
static std::mutex s_safeToSwapMutex;
static std::atomic<bool> s_safeToSwap{false};
static std::atomic<bool> skipOnce{false};
static std::atomic<bool> isTableScrolling{false};
class List : public Element {
public:
List() : Element() {
s_safeToSwap.store(false, std::memory_order_release);
// Clear table scrolling flag when list is cleared
isTableScrolling.store(false, std::memory_order_release);
// Initialize instance state
m_pendingJump = false;
m_clearList = false;
m_focusedIndex = 0;
m_offset = 0;
m_nextOffset = 0;
m_listHeight = 0;
actualItemCount = 0;
m_isItem = false;
m_hasSetInitialFocusHack = false;
m_hasRenderedInitialFocus = false;
// Initialize new scrollbar color transition members
m_scrollbarAtWall = false;
m_scrollbarColorTransition = 0.0f;
m_lastWallReleaseTime = 0;
}
virtual ~List() {
s_safeToSwap.store(false, std::memory_order_release);
purgePendingItems();
clearItems();
}
virtual void draw(gfx::Renderer* renderer) override {
s_safeToSwap.store(false, std::memory_order_release);
std::lock_guard<std::mutex> lock(s_safeToSwapMutex);
if (m_clearList) {
clearItems();
return;
}
bool justResolved = false;
// Process pending operations
if (!m_itemsToAdd.empty()) {
// Add items to m_items but DON'T invalidate yet
addPendingItems(true); // Skip invalidate
// Calculate m_listHeight FIRST
m_listHeight = BOTTOM_PADDING;
for (Element* entry : m_items) {
m_listHeight += entry->getHeight();
}
// NOW invalidate with m_offset still at 0 to get initial positions
invalidate();
// THEN resolve jump AFTER layout has positioned items
if (m_pendingJump && !m_items.empty()) {
resolveJumpImmediately();
justResolved = true;
} else if (!m_hasSetInitialFocusHack && !m_items.empty()) {
// NO JUMP: Set up focus on first item
for (size_t i = 0; i < m_items.size(); ++i) {
if (m_items[i]->m_isItem) {
m_focusedIndex = i;
m_hasSetInitialFocusHack = true;
// Calculate position using the same logic as updateScrollOffset
float itemPos = 0.0f;
for (size_t j = 0; j < i && j < m_items.size(); ++j) {
itemPos += m_items[j]->getHeight();
}
const float itemHeight = m_items[i]->getHeight();
const float viewHeight = static_cast<float>(getHeight());
const float maxOffset = (m_listHeight > viewHeight) ?
static_cast<float>(m_listHeight - viewHeight) : 0.0f;
const float itemCenterPos = itemPos + (itemHeight / 2.0f);
const float viewportCenter = viewHeight / 2.0f + VIEW_CENTER_OFFSET + 0.5f;
const float idealOffset = std::max(0.0f, std::min(itemCenterPos - viewportCenter, maxOffset));
m_offset = m_nextOffset = idealOffset;
// Now invalidate AGAIN with correct offset
invalidate();
justResolved = true;
break;
}
}
}
}
if (!m_itemsToRemove.empty()) {
removePendingItems();
}
const s32 topBound = getTopBound();
const s32 bottomBound = getBottomBound();
const s32 height = getHeight();
renderer->enableScissoring(getLeftBound(), topBound-8, getWidth() + 8, height + 14);
// Manually set focus flag on the target item for the first frame
if (m_hasSetInitialFocusHack && !m_hasRenderedInitialFocus && !m_items.empty() && m_focusedIndex < m_items.size()) {
bool anyItemFocused = false;
for (Element* item : m_items) {
if (item && item->hasFocus()) {
anyItemFocused = true;
break;
}
}
if (!anyItemFocused) {
m_items[m_focusedIndex]->setFocused(true);
}
m_hasRenderedInitialFocus = true;
}
for (Element* entry : m_items) {
if (entry->getBottomBound() > topBound && entry->getTopBound() < bottomBound) {
entry->frame(renderer);
}
}
renderer->disableScissoring();
if (m_listHeight > height) {
drawScrollbar(renderer, height);
if (!justResolved) {
updateScrollAnimation();
}
}
s_safeToSwap.store(true, std::memory_order_release);
}
void resolveJumpImmediately() {
float h = 0.0f;
bool foundMatch = false;
for (size_t i = 0; i < m_items.size(); ++i) {
if (m_items[i]->matchesJumpCriteria(m_jumpToText, m_jumpToValue, m_jumpToExactMatch)) {
m_focusedIndex = i;
foundMatch = true;
// Calculate position using the same logic as updateScrollOffset
const float itemHeight = m_items[i]->getHeight();
const float viewHeight = static_cast<float>(getHeight());
const float maxOffset = (m_listHeight > viewHeight) ?
static_cast<float>(m_listHeight - viewHeight) : 0.0f;
const float itemCenterPos = h + (itemHeight / 2.0f);
const float viewportCenter = viewHeight / 2.0f + VIEW_CENTER_OFFSET + 0.5f;
const float idealOffset = std::max(0.0f, std::min(itemCenterPos - viewportCenter, maxOffset));
m_offset = m_nextOffset = idealOffset;
// Now invalidate AGAIN with correct offset so layout repositions items
invalidate();
// Manually set the focus flag for first frame drawing
m_items[m_focusedIndex]->setFocused(true);
m_hasSetInitialFocusHack = true;
m_hasRenderedInitialFocus = true;
m_pendingJump = false;
break;
}
h += m_items[i]->getHeight();
}
// FALLBACK: If no match found, focus first item instead
if (!foundMatch) {
for (size_t i = 0; i < m_items.size(); ++i) {
if (m_items[i]->m_isItem) {
m_focusedIndex = i;
m_hasSetInitialFocusHack = true;
// Calculate position using the same logic as updateScrollOffset
float itemPos = 0.0f;
for (size_t j = 0; j < i && j < m_items.size(); ++j) {
itemPos += m_items[j]->getHeight();
}
const float itemHeight = m_items[i]->getHeight();
const float viewHeight = static_cast<float>(getHeight());
const float maxOffset = (m_listHeight > viewHeight) ?
static_cast<float>(m_listHeight - viewHeight) : 0.0f;
const float itemCenterPos = itemPos + (itemHeight / 2.0f);
const float viewportCenter = viewHeight / 2.0f + VIEW_CENTER_OFFSET + 0.5f;
const float idealOffset = std::max(0.0f, std::min(itemCenterPos - viewportCenter, maxOffset));
m_offset = m_nextOffset = idealOffset;
// Now invalidate AGAIN with correct offset
invalidate();
// Manually set the focus flag for first frame drawing
m_items[i]->setFocused(true);
m_hasRenderedInitialFocus = true;
break;
}
}
}
m_pendingJump = false;
}
virtual void layout(u16 parentX, u16 parentY, u16 parentWidth, u16 parentHeight) override {
s32 y = getY() - m_offset;
// Position all items first (don't calculate m_listHeight here)
for (Element* entry : m_items) {
entry->setBoundaries(getX(), y, getWidth(), entry->getHeight());
entry->invalidate();
y += entry->getHeight();
}
// Calculate total height AFTER all invalidations are done
m_listHeight = BOTTOM_PADDING;
for (Element* entry : m_items) {
m_listHeight += entry->getHeight();
}
}
// Fixed onTouch method - prevents controller state corruption
virtual bool onTouch(TouchEvent event, s32 currX, s32 currY, s32 prevX, s32 prevY, s32 initialX, s32 initialY) override {
// Quick bounds check
if (!inBounds(currX, currY)) return false;
// Forward to children first
for (Element* item : m_items) {
if (item->onTouch(event, currX, currY, prevX, prevY, initialX, initialY)) {
return true;
}
}
// Handle scrolling
if (event != TouchEvent::Release && Element::getInputMode() == InputMode::TouchScroll) {
if (prevX && prevY) {
m_nextOffset += (prevY - currY);
m_nextOffset = std::clamp(m_nextOffset, 0.0f, static_cast<float>(m_listHeight - getHeight()));
// Track that we're touch scrolling
m_touchScrollActive = true;
}
return true;
}
return false;
}
inline void addItem(Element* element, u16 height = 0, ssize_t index = -1) {
if (!element) return;
// First item optimization
if (actualItemCount == 0 && element->m_isItem) {
auto* customDrawer = new tsl::elm::CustomDrawer([](gfx::Renderer*, s32, s32, s32, s32) {});
customDrawer->setBoundaries(getX(), getY(), getWidth(), 29+4);
customDrawer->setParent(this);
customDrawer->invalidate();
m_itemsToAdd.emplace_back(-1, customDrawer);
}
if (height) {
element->setBoundaries(getX(), getY(), getWidth(), height);
}
element->setParent(this);
element->invalidate();
m_itemsToAdd.emplace_back(index, element);
++actualItemCount;
}
virtual void removeItem(Element *element) {
if (element) m_itemsToRemove.push_back(element);
}
virtual void removeIndex(size_t index) {
if (index < m_items.size()) removeItem(m_items[index]);
}
inline void clear() {
m_clearList = true;
}
virtual Element* requestFocus(Element* oldFocus, FocusDirection direction) override {
if (m_clearList || !m_itemsToAdd.empty()) return nullptr;
// If jump was just resolved, return the target item with proper focus
if (m_hasSetInitialFocusHack && direction == FocusDirection::None && m_focusedIndex < m_items.size()) {
// Request focus properly through the focus system
Element* newFocus = m_items[m_focusedIndex]->requestFocus(oldFocus, FocusDirection::None);
if (newFocus && newFocus != oldFocus) {
return newFocus;
}
}
if (jumpToBottom.exchange(false, std::memory_order_acq_rel))
return handleJumpToBottom(oldFocus);
if (jumpToTop.exchange(false, std::memory_order_acq_rel))
return handleJumpToTop(oldFocus);
if (skipDown.exchange(false, std::memory_order_acq_rel))
return handleSkipDown(oldFocus);
if (skipUp.exchange(false, std::memory_order_acq_rel))
return handleSkipUp(oldFocus);
if (direction == FocusDirection::None) {
return handleInitialFocus(oldFocus);
}
else if (direction == FocusDirection::Down) {
return handleDownFocus(oldFocus);
}
else if (direction == FocusDirection::Up) {
return handleUpFocus(oldFocus);
}
return oldFocus;
}
inline void jumpToItem(const std::string& text = "", const std::string& value = "", bool exactMatch=true) {
if (!text.empty() || !value.empty()) {
m_pendingJump = true;
m_jumpToText = text;
m_jumpToValue = value;
m_jumpToExactMatch = exactMatch;
}
}
virtual Element* getItemAtIndex(u32 index) {
return (m_items.size() <= index) ? nullptr : m_items[index];
}
virtual s32 getIndexInList(Element *element) {
auto it = std::find(m_items.begin(), m_items.end(), element);
return (it == m_items.end()) ? -1 : static_cast<s32>(it - m_items.begin());
}
virtual s32 getLastIndex() {
return static_cast<s32>(m_items.size()) - 1;
}
virtual void setFocusedIndex(u32 index) {
if (m_items.size() > index) {
m_focusedIndex = index;
updateScrollOffset();
}
}
inline void onDirectionalKeyReleased() {
m_hasWrappedInCurrentSequence = false;
m_lastNavigationResult = NavigationResult::None;
m_isHolding = false;
m_stoppedAtBoundary = false;
m_justArrivedAtBoundary = false;
m_lastNavigationTime = 0;
m_lastScrollTime = 0;
}
protected:
std::vector<Element*> m_items;
u16 m_focusedIndex = 0;
float m_offset = 0, m_nextOffset = 0;
s32 m_listHeight = 0;
bool m_clearList = false;
std::vector<Element*> m_itemsToRemove;
std::vector<std::pair<ssize_t, Element*>> m_itemsToAdd;
std::vector<float> prefixSums;
// Enhanced navigation state tracking
bool m_justWrapped = false;
bool m_isHolding = false;
bool m_stoppedAtBoundary = false;
u64 m_lastNavigationTime = 0;
static constexpr u64 HOLD_THRESHOLD_NS = 100000000ULL; // 100ms
size_t actualItemCount = 0;
// Jump to navigation variables
std::string m_jumpToText;
std::string m_jumpToValue;
bool m_jumpToExactMatch = false;
bool m_pendingJump = false;
bool m_justArrivedAtBoundary = false;
bool m_hasSetInitialFocusHack = false;
bool m_hasRenderedInitialFocus = false;
// Stack variables for hot path - reused to avoid allocations
u32 scrollbarHeight;
u32 scrollbarOffset;
u32 prevOffset;
static constexpr float SCROLLBAR_X_OFFSET = 21.0f;
static constexpr float SCROLLBAR_Y_OFFSET = 3.0f;
static constexpr float SCROLLBAR_HEIGHT_TRIM = 6.0f;
bool m_scrollbarAtWall = false;
float m_scrollbarColorTransition = 0.0f; // 0.0 = scrollBarColor, 1.0 = scrollBarWallColor
u64 m_lastWallReleaseTime = 0;
static constexpr u64 COLOR_TRANSITION_DURATION_NS = 300000000ULL; // 0.3 seconds
static constexpr float TABLE_SCROLL_SPEED_PPS = 120.0f*4; // Pixels per second when holding
static constexpr float TABLE_SCROLL_SPEED_CLICK_PPS = 120.0f*4; // Pixels per second for single click
static constexpr float BOTTOM_PADDING = 7.0f;
static constexpr float VIEW_CENTER_OFFSET = 7.0f;
u64 m_lastScrollTime = 0;
float m_scrollVelocity = 0.0f;
bool m_touchScrollActive = false;
enum class NavigationResult {
None,
Success,
HitBoundary,
Wrapped
};
bool m_hasWrappedInCurrentSequence = false;
NavigationResult m_lastNavigationResult = NavigationResult::None;
private:
void clearItems() {
for (Element* item : m_items) delete item;
m_items = {};
m_offset = 0;
m_focusedIndex = 0;
invalidate();
m_clearList = false;
actualItemCount = 0;
m_hasSetInitialFocusHack = false;
// Clear table scrolling flag when list is cleared
isTableScrolling.store(false, std::memory_order_release);
}
void addPendingItems(bool skipInvalidate = false) {
for (auto [index, element] : m_itemsToAdd) {
element->invalidate();
if (index >= 0 && static_cast<size_t>(index) < m_items.size()) {
m_items.insert(m_items.begin() + index, element);
} else {
m_items.push_back(element);
}
}
m_itemsToAdd.clear();
if (!skipInvalidate) {
invalidate();
updateScrollOffset();
}
}
void removePendingItems() {
//size_t index;
for (Element* element : m_itemsToRemove) {
auto it = std::find(m_items.begin(), m_items.end(), element);
if (it != m_items.end()) {
const size_t index = static_cast<size_t>(it - m_items.begin());
m_items.erase(it);
if (m_focusedIndex >= index && m_focusedIndex > 0) {
--m_focusedIndex;
}
delete element;
}
}
m_itemsToRemove = {};
invalidate();
updateScrollOffset();
}
void purgePendingItems() {
for (auto& [_, element] : m_itemsToAdd) {
if (element) { element->invalidate(); delete element; }
}
m_itemsToAdd = {};
//size_t index;
for (Element* element : m_itemsToRemove) {
auto it = std::find(m_items.begin(), m_items.end(), element);
if (it != m_items.end()) {
const u16 index16 = static_cast<u16>(static_cast<std::size_t>(it - m_items.begin()));
element->invalidate();
delete element;
m_items.erase(it);
constexpr u16 noFocus = static_cast<u16>(0xFFFF);
if (m_focusedIndex == index16)
m_focusedIndex = noFocus;
else if (m_focusedIndex != noFocus && m_focusedIndex > index16)
--m_focusedIndex;
}
}
m_itemsToRemove = {};
invalidate();
updateScrollOffset();
}
void drawScrollbar(gfx::Renderer* renderer, s32 height) {
const float viewHeight = static_cast<float>(height);
const float totalHeight = static_cast<float>(m_listHeight);
const u32 maxScrollableHeight = std::max(static_cast<u32>(totalHeight - viewHeight), 1u);
scrollbarHeight = std::min(static_cast<u32>((viewHeight * viewHeight) / totalHeight),
static_cast<u32>(viewHeight));
scrollbarOffset = std::min(static_cast<u32>((m_offset / maxScrollableHeight) * (viewHeight - scrollbarHeight)),
static_cast<u32>(viewHeight - scrollbarHeight));
const u32 scrollbarX = getRightBound() + SCROLLBAR_X_OFFSET;
const u32 scrollbarY = getY() + scrollbarOffset + SCROLLBAR_Y_OFFSET;
scrollbarHeight -= SCROLLBAR_HEIGHT_TRIM;
// Check if we're at a wall (boundary)
const bool currentlyAtWall = (m_lastNavigationResult == NavigationResult::HitBoundary) &&
(m_stoppedAtBoundary || m_justArrivedAtBoundary);
static bool triggerOnce = true;
// Detect transition from "not at wall" to "at wall" - trigger flash ONCE
if (currentlyAtWall && !m_scrollbarAtWall && !s_directionalKeyReleased.load(std::memory_order_acquire)) {
m_scrollbarColorTransition = 1.0f; // Instant jump to wall color
if (triggerOnce) {
// NEW: Trigger wall effect here based on scroll position
const float maxOffset = static_cast<float>(m_listHeight - getHeight());
if (m_offset <= 0.0f) {
triggerWallEffect(FocusDirection::Up);
} else if (m_offset >= maxOffset - 1.0f) {
triggerWallEffect(FocusDirection::Down);
}
}
triggerOnce = false;
} else {
triggerOnce = true;
}
// Detect transition from "not at wall" to "at wall" - trigger flash ONCE
if (currentlyAtWall && !m_scrollbarAtWall && s_directionalKeyReleased.load(std::memory_order_acquire)) {
m_scrollbarAtWall = true;
m_scrollbarColorTransition = 1.0f; // Instant jump to wall color
m_lastWallReleaseTime = ult::nowNs(); // Start transition immediately
}
// Reset flag when we leave the wall (so we can trigger again next time)
if (!currentlyAtWall && m_scrollbarAtWall) {
m_scrollbarAtWall = false;
m_scrollbarColorTransition = 0.0f; // Reset to normal immediately
}
// Smooth transition back to scrollBarColor over 0.5s
if (m_scrollbarAtWall && m_scrollbarColorTransition > 0.0f) {
const u64 currentTime = ult::nowNs();
const u64 elapsed = currentTime - m_lastWallReleaseTime;
if (elapsed >= COLOR_TRANSITION_DURATION_NS) {
m_scrollbarColorTransition = 0.0f; // Transition complete
} else {
// Linear interpolation from 1.0 to 0.0
const float progress = static_cast<float>(elapsed) / static_cast<float>(COLOR_TRANSITION_DURATION_NS);
m_scrollbarColorTransition = 1.0f - progress;
}
}
// Interpolate between scrollBarColor and scrollBarWallColor
tsl::Color currentColor = scrollBarColor;
if (m_scrollbarColorTransition >= 1.0f) {
currentColor = scrollBarWallColor;
} else if (m_scrollbarColorTransition > 0.0f) {
const float t = m_scrollbarColorTransition;
const float oneMinusT = 1.0f - t;
const u8 r = static_cast<u8>(scrollBarColor.r * oneMinusT + scrollBarWallColor.r * t);
const u8 g = static_cast<u8>(scrollBarColor.g * oneMinusT + scrollBarWallColor.g * t);
const u8 b = static_cast<u8>(scrollBarColor.b * oneMinusT + scrollBarWallColor.b * t);
const u8 a = static_cast<u8>(scrollBarColor.a * oneMinusT + scrollBarWallColor.a * t);
currentColor = tsl::Color(r, g, b, a);
}
// Draw scrollbar with interpolated color
renderer->drawRect(scrollbarX, scrollbarY, 5, scrollbarHeight, a(currentColor));
renderer->drawCircle(scrollbarX + 2, scrollbarY, 2, true, a(currentColor));
renderer->drawCircle(scrollbarX + 2, scrollbarY + scrollbarHeight, 2, true, a(currentColor));
}
inline void updateScrollAnimation() {
if (Element::getInputMode() == InputMode::Controller) {
m_touchScrollActive = false;
const float diff = m_nextOffset - m_offset;
const float distance = std::abs(diff);
// Boundary snapping
if (distance < 1.0f) {
m_offset = m_nextOffset;
m_scrollVelocity = 0.0f;
s_currentScrollVelocity.store(m_scrollVelocity, std::memory_order_release);
if (prevOffset != m_offset) {
invalidate();
prevOffset = m_offset;
}
return;
}
const float maxOffset = static_cast<float>(m_listHeight - getHeight());
if (m_nextOffset == 0.0f || m_nextOffset == maxOffset) {
if (distance < 3.0f) {
m_offset = m_nextOffset;
m_scrollVelocity = 0.0f;
s_currentScrollVelocity.store(m_scrollVelocity, std::memory_order_release);
if (prevOffset != m_offset) {
invalidate();
prevOffset = m_offset;
}
return;
}
}
// Emergency correction
if (m_focusedIndex < m_items.size()) {
float itemTop = 0.0f;
for (size_t i = 0; i < m_focusedIndex; ++i) {
itemTop += m_items[i]->getHeight();
}
const float itemBottom = itemTop + m_items[m_focusedIndex]->getHeight();
const float viewBottom = m_offset + getHeight();
if (itemTop < m_offset || itemBottom > viewBottom) {
const float emergencySpeed = (itemBottom < m_offset || itemTop > viewBottom) ? 0.9f : 0.6f;
m_offset += diff * emergencySpeed;
m_scrollVelocity = diff * 0.3f;
s_currentScrollVelocity.store(m_scrollVelocity, std::memory_order_release);
if (prevOffset != m_offset) {
invalidate();
prevOffset = m_offset;
}
return;
}
}
const bool isTableScrolling = tsl::elm::isTableScrolling.load(std::memory_order_acquire);
if (isTableScrolling) {
// Direct assignment - instant updates, smoothness comes from small frequent steps
m_offset = m_nextOffset;
m_scrollVelocity = 0.0f;
} else {
// Original smooth scrolling for regular navigation
const bool isLargeJump = distance > getHeight() * 1.5f;
const bool isFromRest = std::abs(m_scrollVelocity) < 2.0f;
if (isLargeJump && isFromRest) {
static constexpr float gentleAcceleration = 0.08f;
static constexpr float gentleDamping = 0.85f;
const float targetVelocity = diff * gentleAcceleration;
m_scrollVelocity += (targetVelocity - m_scrollVelocity) * gentleDamping;
} else {
const float urgency = std::min(distance / getHeight(), 1.0f);
const float accelerationFactor = 0.18f + (0.24f * urgency);
const float dampingFactor = 0.48f - (0.18f * urgency);
const float targetVelocity = diff * accelerationFactor;
m_scrollVelocity += (targetVelocity - m_scrollVelocity) * dampingFactor;
}
m_offset += m_scrollVelocity;
}
// Overshoot prevention
if ((m_scrollVelocity > 0 && m_offset > m_nextOffset) ||
(m_scrollVelocity < 0 && m_offset < m_nextOffset)) {
m_offset = m_nextOffset;
m_scrollVelocity = 0.0f;
}
// Force exact boundary values
if (m_nextOffset == 0.0f && m_offset < 1.0f) {
m_offset = 0.0f;
m_scrollVelocity = 0.0f;
} else if (m_nextOffset == maxOffset && m_offset > maxOffset - 1.0f) {
m_offset = maxOffset;
m_scrollVelocity = 0.0f;
}
s_currentScrollVelocity.store(m_scrollVelocity, std::memory_order_release);
} else if (Element::getInputMode() == InputMode::TouchScroll) {
m_offset = m_nextOffset;
m_scrollVelocity = 0.0f;
if (m_touchScrollActive) {
const float viewCenter = m_offset + (getHeight() / 2.0f);
float accumHeight = 0.0f;
for (size_t i = 0; i < m_items.size(); ++i) {
const float itemHeight = m_items[i]->getHeight();
const float itemCenter = accumHeight + (itemHeight / 2.0f);
if (itemCenter >= viewCenter) {
m_focusedIndex = i;
break;
}
accumHeight += itemHeight;
}
}
}
if (prevOffset != m_offset) {
invalidate();
prevOffset = m_offset;
}
}
Element* handleInitialFocus(Element* oldFocus) {
const size_t itemCount = m_items.size();
if (itemCount == 0) return nullptr;
size_t startIndex = 0;
// Calculate starting index based on current scroll position
if (!oldFocus && m_offset > 0) {
float elementHeight = 0.0f;
const size_t maxIndex = itemCount - 1;
while (elementHeight < m_offset && startIndex < maxIndex) {
elementHeight += m_items[startIndex]->getHeight();
++startIndex;
}
}
// Save current offset to prevent scroll jumping
const float savedOffset = m_offset;
const float savedNextOffset = m_nextOffset;
// Single loop with wraparound logic - visits each item exactly once
for (size_t count = 0; count < itemCount; ++count) {
const size_t i = (startIndex + count) % itemCount;
if (!m_items[i]->isTable()) {
Element* const newFocus = m_items[i]->requestFocus(oldFocus, FocusDirection::None);
if (newFocus && newFocus != oldFocus) {
m_focusedIndex = i;
m_offset = savedOffset;
m_nextOffset = savedNextOffset;
return newFocus;
}
}
}
return nullptr;
}
inline void triggerWallEffect(FocusDirection direction) {
if (m_items.empty()) {
triggerWallFeedback();
return;
}
// Directional search bounds
ssize_t i = static_cast<ssize_t>(m_focusedIndex);
ssize_t end = (direction == FocusDirection::Down) ? -1 : static_cast<ssize_t>(m_items.size());
ssize_t step = (direction == FocusDirection::Down) ? -1 : 1;
// Walk until we hit a real item
for (; i != end; i += step) {
auto *it = m_items[i];
if (it->m_isItem) {
it->shakeHighlight(direction);
return;
}
}
triggerWallFeedback();
}
inline Element* handleDownFocus(Element* oldFocus) {
const bool atBottom = isAtBottom();
updateHoldState();
// Check if the next item is non-focusable
if (m_focusedIndex + 1 < static_cast<int>(m_items.size()) &&
!m_items[m_focusedIndex + 1]->m_isItem && m_listHeight > getHeight()) {
isTableScrolling.store(true, std::memory_order_release);
}
// If holding and at boundary, try to scroll first
if (m_isHolding && m_stoppedAtBoundary && !atBottom) {
scrollDown();
m_stoppedAtBoundary = false;
return oldFocus;
}
Element* result = navigateDown(oldFocus);
if (result != oldFocus) {
m_lastNavigationResult = NavigationResult::Success;
m_stoppedAtBoundary = false;
// NEW: Check if we just navigated to the last focusable item
// If so, set boundary flag regardless of scroll position
bool isLastFocusableItem = true;
for (size_t i = m_focusedIndex + 1; i < m_items.size(); ++i) {
if (m_items[i]->m_isItem) {
isLastFocusableItem = false;
break;
}
}
// Set boundary flag if we're at last focusable item OR at scroll bottom
m_justArrivedAtBoundary = isLastFocusableItem || isAtBottom();
triggerNavigationFeedback();
return result;
}
// Check if we can still scroll down
if (!atBottom) {
scrollDown();
return oldFocus;
}
// Force boundary hit before allowing wrap
if (m_justArrivedAtBoundary) {
m_justArrivedAtBoundary = false;
m_stoppedAtBoundary = true;
m_lastNavigationResult = NavigationResult::HitBoundary;
if (m_listHeight <= getHeight())
triggerWallEffect(FocusDirection::Down);
return oldFocus;
}
// Check for wrapping (single tap only)
if (!m_isHolding && !m_hasWrappedInCurrentSequence) {
s_directionalKeyReleased.store(false, std::memory_order_release);
m_hasWrappedInCurrentSequence = true;
m_lastNavigationResult = NavigationResult::Wrapped;
return handleJumpToTop(oldFocus);
}
// Set boundary flag (for holding)
m_lastNavigationResult = NavigationResult::HitBoundary;
if (m_isHolding) {
m_stoppedAtBoundary = true;
}
return oldFocus;
}
inline Element* handleUpFocus(Element* oldFocus) {
const bool atTop = isAtTop();
updateHoldState();
// Check if the previous item is non-focusable
if (m_focusedIndex > 0 && m_items[m_focusedIndex - 1]->isTable() && m_listHeight > getHeight()) {
isTableScrolling.store(true, std::memory_order_release);
}
// If holding and at boundary, try to scroll first
if (m_isHolding && m_stoppedAtBoundary && !atTop) {
scrollUp();
m_stoppedAtBoundary = false;
return oldFocus;
}
Element* result = navigateUp(oldFocus);
if (result != oldFocus) {
m_lastNavigationResult = NavigationResult::Success;
m_stoppedAtBoundary = false;
// NEW: Check if we just navigated to the first focusable item
// If so, set boundary flag regardless of scroll position
bool isFirstFocusableItem = true;
for (size_t i = 0; i < m_focusedIndex; ++i) {
if (m_items[i]->m_isItem) {
isFirstFocusableItem = false;
break;
}
}
// Set boundary flag if we're at first focusable item OR at scroll top
m_justArrivedAtBoundary = isFirstFocusableItem || isAtTop();
triggerNavigationFeedback();
return result;
}
// Check if we can still scroll up
if (!atTop) {
scrollUp();
return oldFocus;
}
// Force boundary hit before allowing wrap
if (m_justArrivedAtBoundary) {
m_justArrivedAtBoundary = false;
m_stoppedAtBoundary = true;
m_lastNavigationResult = NavigationResult::HitBoundary;
if (m_listHeight <= getHeight())
triggerWallEffect(FocusDirection::Up);
return oldFocus;
}
// Check for wrapping (single tap only)
if (!m_isHolding && !m_hasWrappedInCurrentSequence) {
s_directionalKeyReleased.store(false, std::memory_order_release);
m_hasWrappedInCurrentSequence = true;
m_lastNavigationResult = NavigationResult::Wrapped;
return handleJumpToBottom(oldFocus);
}
// Set boundary flag (for holding)
m_lastNavigationResult = NavigationResult::HitBoundary;
if (m_isHolding) {
m_stoppedAtBoundary = true;
}
return oldFocus;
}
inline bool isAtTop() {
if (m_items.empty()) return true;
// Check if we're at scroll position 0
if (m_offset != 0.0f) return false;
// Even at offset 0, check if the first item is actually visible
// This handles cases where the first item might be partially above viewport
if (!m_items.empty()) {
Element* firstItem = m_items[0];
return firstItem->getTopBound() >= getTopBound();
}
return true;
}
inline bool isAtBottom() {
if (m_items.empty()) return true;
// First check: are we at the maximum scroll offset?
const bool atMaxOffset = (m_offset >= static_cast<float>(m_listHeight - getHeight()));
// If list is shorter than viewport, we're always at bottom
if (m_listHeight <= getHeight()) return true;
// If we're not at max offset, we're definitely not at bottom
if (!atMaxOffset) return false;
// At max offset - now check if the last item is actually fully visible
// This prevents wrap-around when there's still content below viewport
if (!m_items.empty()) {
Element* lastItem = m_items.back();
// We're truly at bottom only if:
// 1. We're at max scroll offset AND
// 2. The last item's bottom is at or above the viewport bottom
return lastItem->getBottomBound() <= getBottomBound();
}
return atMaxOffset;
}
// Helper to check if there are any focusable items
inline bool hasAnyFocusableItems() {
for (size_t i = 0; i < m_items.size(); ++i) {
if (m_items[i]->m_isItem) return true;
}
return false;
}
inline void updateHoldState() {
const u64 currentTime = ult::nowNs();
if ((m_lastNavigationTime != 0 && (currentTime - m_lastNavigationTime) < HOLD_THRESHOLD_NS)) {
m_isHolding = true;
} else {
m_isHolding = false;
m_stoppedAtBoundary = false;
m_hasWrappedInCurrentSequence = false;
}
m_lastNavigationTime = currentTime;
// bug fix, boundary reset upon key release
if (s_directionalKeyReleased.load(std::memory_order_acquire))
m_justArrivedAtBoundary = false;
}
inline void resetNavigationState() {
m_hasWrappedInCurrentSequence = false;
m_lastNavigationResult = NavigationResult::None;
m_isHolding = false;
m_stoppedAtBoundary = false;
m_justArrivedAtBoundary = false;
m_lastNavigationTime = 0;
}
inline Element* handleJumpToItem(Element* oldFocus) {
resetNavigationState();
invalidate();
const bool needsScroll = m_listHeight > getHeight();
const float viewHeight = static_cast<float>(getHeight());
const float maxOffset = needsScroll ? m_listHeight - viewHeight : 0.0f;
float h = 0.0f;
for (size_t i = 0; i < m_items.size(); ++i) {
m_focusedIndex = i;
Element* newFocus = m_items[i]->requestFocus(oldFocus, FocusDirection::Down);
if (newFocus && newFocus != oldFocus && m_items[i]->matchesJumpCriteria(m_jumpToText, m_jumpToValue, m_jumpToExactMatch)) {
// CHANGED: Calculate center of the item and center it in viewport
const float itemHeight = m_items[i]->getHeight();
// For middle items, use centering logic
const float itemCenterPos = h + (itemHeight / 2.0f); // FIXED: Use center, not bottom
const float viewportCenter = viewHeight / 2.0f + VIEW_CENTER_OFFSET + 0.5f; // Same offset as updateScrollOffset
// Clamp to valid bounds (same as updateScrollOffset)
const float idealOffset = std::max(0.0f, std::min(itemCenterPos - viewportCenter, maxOffset));
// Set both current and target offset
m_offset = m_nextOffset = idealOffset;
return newFocus;
}
h += m_items[i]->getHeight();
}
// No match found
return handleInitialFocus(oldFocus);
}
inline void syncFocusIndex(Element* oldFocus, ssize_t& searchIndex) {
for (size_t i = 0; i < m_items.size(); ++i) {
if (m_items[i] == oldFocus) {
m_focusedIndex = i;
searchIndex = static_cast<ssize_t>(i);
break;
}
}
}
// Core navigation logic
// Optimized version with variable definitions pulled outside the loop
inline Element* navigateDown(Element* oldFocus) {
size_t searchIndex = m_focusedIndex + 1;
// If currently on a table that needs more scrolling
if (m_focusedIndex < m_items.size() && m_items[m_focusedIndex]->isTable()) {
Element* currentTable = m_items[m_focusedIndex];
if (currentTable->getBottomBound() > getBottomBound()) {
isTableScrolling.store(true, std::memory_order_release);
scrollDown();
return oldFocus;
}
}
// Sync AFTER table check - if we're not mid-table-scroll
if (oldFocus && !isTableScrolling.load(std::memory_order_acquire)) {
syncFocusIndex(oldFocus, reinterpret_cast<ssize_t&>(searchIndex));
searchIndex++; // Down increments after sync
}
const s32 viewBottom = getBottomBound();
const float containerHeight = getHeight();
const float offsetPlusHeight = m_offset + containerHeight;
while (searchIndex < m_items.size()) {
Element* item = m_items[searchIndex];
m_focusedIndex = searchIndex;
if (item->isTable()) {
const s32 tableBottom = item->getBottomBound();
if (tableBottom > viewBottom) {
isTableScrolling.store(true, std::memory_order_release);
scrollDown();
return oldFocus;
}
searchIndex++;
continue;
}
Element* newFocus = item->requestFocus(oldFocus, FocusDirection::Down);
if (newFocus && newFocus != oldFocus) {
isTableScrolling.store(false, std::memory_order_release);
updateScrollOffset();
return newFocus;
} else {
const float itemBottom = calculateItemPosition(searchIndex) + item->getHeight();
if (itemBottom > offsetPlusHeight) {
isTableScrolling.store(true, std::memory_order_release);
scrollDown();
return oldFocus;
}
searchIndex++;
}
}
// ADDED: Clear flag when navigation completes without finding anything
if (m_focusedIndex >= m_items.size() || !m_items[m_focusedIndex]->isTable())
isTableScrolling.store(false, std::memory_order_release);
return oldFocus;
}
inline Element* navigateUp(Element* oldFocus) {
ssize_t searchIndex = static_cast<ssize_t>(m_focusedIndex) - 1;
// If currently on a table that needs more scrolling
if (m_focusedIndex < m_items.size() && m_items[m_focusedIndex]->isTable()) {
Element* currentTable = m_items[m_focusedIndex];
if (currentTable->getTopBound() < getTopBound()) {
isTableScrolling.store(true, std::memory_order_release);
scrollUp();
return oldFocus;
}
}
// Sync AFTER table check - if we're not mid-table-scroll
if (oldFocus && !isTableScrolling.load(std::memory_order_acquire)) {
syncFocusIndex(oldFocus, searchIndex);
searchIndex--; // Up decrements after sync
}
const s32 viewTop = getTopBound();
const float offset = m_offset;
while (searchIndex >= 0) {
Element* item = m_items[searchIndex];
m_focusedIndex = static_cast<size_t>(searchIndex);
if (item->isTable()) {
const s32 tableTop = item->getTopBound();
if (tableTop < viewTop) {
isTableScrolling.store(true, std::memory_order_release);
scrollUp();
return oldFocus;
}
searchIndex--;
continue;
}
Element* newFocus = item->requestFocus(oldFocus, FocusDirection::Up);
if (newFocus && newFocus != oldFocus) {
isTableScrolling.store(false, std::memory_order_release);
updateScrollOffset();
return newFocus;
} else {
const float itemTop = calculateItemPosition(static_cast<size_t>(searchIndex));
if (itemTop < offset) {
isTableScrolling.store(true, std::memory_order_release);
scrollUp();
return oldFocus;
}
searchIndex--;
}
}
// Only clear table scrolling if we're not still focused on a table
if (m_focusedIndex >= m_items.size() || !m_items[m_focusedIndex]->isTable())
isTableScrolling.store(false, std::memory_order_release);
return oldFocus;
}
// Helper method to calculate an item's position in the list
inline float calculateItemPosition(size_t index) {
float position = 0.0f;
for (size_t i = 0; i < index && i < m_items.size(); ++i) {
position += m_items[i]->getHeight();
}
return position;
}
inline float getScrollDelta() {
const u64 currentTime = ult::nowNs();
float deltaTime = (m_lastScrollTime != 0)
? static_cast<float>(currentTime - m_lastScrollTime) / 1000000000.0f
: 1.0f / 60.0f;
m_lastScrollTime = currentTime;
deltaTime = std::min(deltaTime, 0.1f);
const float speedPPS = m_isHolding ? TABLE_SCROLL_SPEED_PPS : TABLE_SCROLL_SPEED_CLICK_PPS;
return speedPPS * deltaTime;
}
// Enhanced scroll methods that snap to exact boundaries
inline void scrollDown() {
m_nextOffset = std::min(m_nextOffset + getScrollDelta(),
static_cast<float>(m_listHeight - getHeight()));
}
inline void scrollUp() {
m_nextOffset = std::max(m_nextOffset - getScrollDelta(), 0.0f);
}
// Unified jump-to-edge: toBottom=true → jump to bottom, false → jump to top
Element* handleJumpToEdge(Element* oldFocus, bool toBottom) {
if (m_items.empty()) return oldFocus;
invalidate();
resetNavigationState();
if (toBottom) jumpToBottom.store(false, std::memory_order_release);
else jumpToTop.store(false, std::memory_order_release);
static constexpr float tolerance = 5.0f;
const float targetOffset = toBottom
? ((m_listHeight > getHeight()) ? static_cast<float>(m_listHeight - getHeight()) : 0.0f)
: 0.0f;
// Find the edge focusable item
size_t edgeFocusableIndex = m_items.size();
if (toBottom) {
for (ssize_t i = static_cast<ssize_t>(m_items.size()) - 1; i >= 0; --i) {
if (m_items[i]->requestFocus(nullptr, FocusDirection::None)) {
edgeFocusableIndex = static_cast<size_t>(i);
break;
}
}
} else {
for (size_t i = 0; i < m_items.size(); ++i) {
if (m_items[i]->requestFocus(nullptr, FocusDirection::None)) {
edgeFocusableIndex = i;
break;
}
}
}
if (edgeFocusableIndex == m_items.size()) return oldFocus;
const bool alreadyAtEdge = (m_focusedIndex == edgeFocusableIndex) &&
(std::abs(m_nextOffset - targetOffset) <= tolerance);
if (alreadyAtEdge) return oldFocus;
const float oldOffset = m_nextOffset;
m_focusedIndex = edgeFocusableIndex;
m_nextOffset = targetOffset;
// Check for adjacent tables and update table scrolling state
bool hasTables = false;
if (toBottom) {
for (size_t i = edgeFocusableIndex + 1; i < m_items.size(); ++i) {
if (m_items[i]->isTable()) { m_focusedIndex = i; hasTables = true; }
}
} else if (edgeFocusableIndex > 0) {
for (ssize_t i = static_cast<ssize_t>(edgeFocusableIndex) - 1; i >= 0; --i) {
if (m_items[i]->isTable()) { m_focusedIndex = static_cast<size_t>(i); hasTables = true; }
}
}
if (hasTables && m_listHeight > getHeight()) {
float itemPos = 0.0f;
for (size_t i = 0; i < edgeFocusableIndex; ++i) itemPos += m_items[i]->getHeight();
const float itemCenter = itemPos + m_items[edgeFocusableIndex]->getHeight() * 0.5f;
const float viewHeight = static_cast<float>(getHeight());
const float viewportCenter = m_nextOffset + (viewHeight * 0.5f + VIEW_CENTER_OFFSET + 0.5f);
isTableScrolling.store(
toBottom ? (itemCenter < viewportCenter - 1.0f)
: (itemCenter > viewportCenter + 1.0f),
std::memory_order_release);
} else {
isTableScrolling.store(false, std::memory_order_release);
}
Element* newFocus = m_items[edgeFocusableIndex]->requestFocus(oldFocus, FocusDirection::None);
if ((newFocus && newFocus != oldFocus) || (std::abs(m_nextOffset - oldOffset) > tolerance))
triggerNavigationFeedback();
return newFocus ? newFocus : oldFocus;
}
Element* handleJumpToBottom(Element* oldFocus) { return handleJumpToEdge(oldFocus, true); }
Element* handleJumpToTop (Element* oldFocus) { return handleJumpToEdge(oldFocus, false); }
// Unified page-skip: skipDown=true → skip down, false → skip up
Element* handleSkip(Element* oldFocus, bool skipDown) {
if (m_items.empty()) return oldFocus;
invalidate();
resetNavigationState();
const float viewHeight = static_cast<float>(getHeight());
const float maxOffset = (m_listHeight > viewHeight) ? static_cast<float>(m_listHeight - viewHeight) : 0.0f;
static constexpr float tolerance = 0.0f;
// Find the edge focusable item for the "already there" check
size_t edgeFocusableIndex = m_items.size();
if (skipDown) {
for (ssize_t i = static_cast<ssize_t>(m_items.size()) - 1; i >= 0; --i) {
if (m_items[i]->requestFocus(nullptr, FocusDirection::None)) {
edgeFocusableIndex = static_cast<size_t>(i); break;
}
}
const bool alreadyAtEdge = (edgeFocusableIndex < m_items.size()) &&
(m_focusedIndex == edgeFocusableIndex) &&
(std::abs(m_nextOffset - maxOffset) <= tolerance);
if (alreadyAtEdge) return oldFocus;
} else {
for (size_t i = 0; i < m_items.size(); ++i) {
if (m_items[i]->requestFocus(nullptr, FocusDirection::None)) {
edgeFocusableIndex = i; break;
}
}
const bool alreadyAtEdge = (edgeFocusableIndex < m_items.size()) &&
(m_focusedIndex == edgeFocusableIndex) &&
(std::abs(m_nextOffset - 0.0f) <= tolerance);
if (alreadyAtEdge) return oldFocus;
}
// Calculate target viewport
const float targetViewportTop = skipDown
? std::min(m_offset + viewHeight, maxOffset)
: std::max(0.0f, m_offset - viewHeight);
const float actualTravelDistance = skipDown
? (targetViewportTop - m_offset)
: (m_offset - targetViewportTop);
const bool traveledFullViewport = (actualTravelDistance >= viewHeight - tolerance);
const float targetViewportCenter = targetViewportTop + (viewHeight * 0.5f + VIEW_CENTER_OFFSET);
// Find the focusable item closest to the target viewport center
float itemTop = 0.0f;
size_t targetIndex = 0;
bool foundFocusable = false;
float bestDistance = std::numeric_limits<float>::max();
for (size_t i = 0; i < m_items.size(); ++i) {
const float itemHeight = m_items[i]->getHeight();
const float itemCenter = itemTop + itemHeight * 0.5f;
const float dist = std::abs(itemCenter - targetViewportCenter);
Element* test = m_items[i]->requestFocus(nullptr, FocusDirection::None);
if (test && test->m_isItem && dist < bestDistance) {
targetIndex = i; bestDistance = dist; foundFocusable = true;
}
itemTop += itemHeight;
}
const float oldOffset = m_nextOffset;
if (foundFocusable) {
bool nearEdge = true;
const bool movedPastFocus = skipDown ? (targetIndex > m_focusedIndex)
: (targetIndex < m_focusedIndex);
if (movedPastFocus && traveledFullViewport) {
m_focusedIndex = targetIndex;
nearEdge = false;
}
isTableScrolling.store(false, std::memory_order_release);
updateScrollOffset();
Element* newFocus = m_items[targetIndex]->requestFocus(oldFocus, FocusDirection::None);
if (newFocus && newFocus != oldFocus && !nearEdge && traveledFullViewport) {
triggerNavigationFeedback();
return newFocus;
} else {
return handleJumpToEdge(oldFocus, skipDown);
}
} else {
// No focusable items — scroll viewport and update focus to nearest visible item
isTableScrolling.store(true, std::memory_order_release);
m_nextOffset = targetViewportTop;
if (std::abs(m_nextOffset - oldOffset) > 0.0f)
triggerNavigationFeedback();
float searchItemTop = 0.0f;
size_t bestVisible = m_focusedIndex;
for (size_t i = 0; i < m_items.size(); ++i) {
const float itemHeight = m_items[i]->getHeight();
const float itemBottom = searchItemTop + itemHeight;
const bool inViewport = itemBottom > targetViewportTop &&
searchItemTop < targetViewportTop + viewHeight;
if (skipDown) {
// Wants the LAST visible focusable
if (searchItemTop >= targetViewportTop + viewHeight) break;
if (inViewport) {
Element* test = m_items[i]->requestFocus(nullptr, FocusDirection::None);
if (test && test->m_isItem) bestVisible = i;
}
} else {
// Wants the FIRST visible focusable
if (inViewport) {
Element* test = m_items[i]->requestFocus(nullptr, FocusDirection::None);
if (test && test->m_isItem) { bestVisible = i; break; }
}
}
searchItemTop += itemHeight;
}
if (bestVisible != m_focusedIndex) {
m_focusedIndex = bestVisible;
Element* newFocus = m_items[m_focusedIndex]->requestFocus(oldFocus, FocusDirection::None);
if (newFocus && newFocus != oldFocus) {
triggerNavigationFeedback();
return newFocus;
}
}
}
return oldFocus;
}
Element* handleSkipDown(Element* oldFocus) { return handleSkip(oldFocus, true); }
Element* handleSkipUp (Element* oldFocus) { return handleSkip(oldFocus, false); }
inline void initializePrefixSums() {
prefixSums.clear();
prefixSums.resize(m_items.size() + 1, 0.0f);
for (size_t i = 1; i < prefixSums.size(); ++i) {
prefixSums[i] = prefixSums[i - 1] + m_items[i - 1]->getHeight();
}
}
virtual void updateScrollOffset() {
if (Element::getInputMode() != InputMode::Controller) return;
if (m_listHeight <= getHeight()) {
m_nextOffset = m_offset = 0;
return;
}
// Calculate position of focused item
float itemPos = 0.0f;
for (size_t i = 0; i < m_focusedIndex && i < m_items.size(); ++i) {
itemPos += m_items[i]->getHeight();
}
// Get the focused item's height
const float itemHeight = (m_focusedIndex < m_items.size()) ? m_items[m_focusedIndex]->getHeight() : 0.0f;
// Calculate viewport height
const float viewHeight = static_cast<float>(getHeight());
// FIXED: Special handling for items near the bottom
const float maxOffset = static_cast<float>(m_listHeight - getHeight());
// For middle items, use centering logic
const float itemCenterPos = itemPos + (itemHeight / 2.0f);
const float viewportCenter = viewHeight / 2.0f + VIEW_CENTER_OFFSET + 0.5f; // add slight offset
// Clamp to valid scroll bounds
const float idealOffset = std::max(0.0f, std::min(itemCenterPos - viewportCenter, maxOffset));
// Set target for smooth animation
m_nextOffset = idealOffset;
}
};
/**
* @brief A item that goes into a list
*
*/
class ListItem : public Element {
public:
u32 width, height;
u64 m_touchStartTime_ns;
bool isLocked = false;
bool m_touched = false;
u64 m_shortHoldKey = KEY_Y;
u64 m_longHoldKey = KEY_X;
ListItem(const std::string& text, const std::string& value = "", bool isMini = false)
: Element(), m_text(text), m_value(value), m_listItemHeight(isMini ? tsl::style::MiniListItemDefaultHeight : tsl::style::ListItemDefaultHeight) {
m_isItem = true;
m_flags.m_useClickAnimation = true;
m_text_clean = m_text;
ult::removeTag(m_text_clean);
applyInitialTranslations();
if (!value.empty()) applyInitialTranslations(true);
}
virtual ~ListItem() = default;
virtual void draw(gfx::Renderer *renderer) override {
const bool useClickTextColor = m_touched && Element::getInputMode() == InputMode::Touch && ult::touchInBounds;
if (useClickTextColor && !m_flags.m_isTouchHolding) [[unlikely]]
renderer->drawRectAdaptive(this->getX() + 4, this->getY(), this->getWidth() - 8, this->getHeight(), aWithOpacity(clickColor));
#if IS_LAUNCHER_DIRECTIVE
if (m_flags.m_isTouchHolding) [[unlikely]] {
// Determine the active percentage to use
const float activePercentage = ult::displayPercentage.load(std::memory_order_acquire);
if (activePercentage > 0){
renderer->drawRectAdaptive(this->getX() + 4, this->getY(), (this->getWidth()- 12 +4)*(activePercentage * 0.01f), this->getHeight(), aWithOpacity(progressColor)); // Direct percentage conversion
}
}
#endif
const s16 yOffset = ((tsl::style::ListItemDefaultHeight - m_listItemHeight) >> 1) + 1;
if (!m_maxWidth) [[unlikely]] {
calculateWidths(renderer);
}
// Optimized separator drawing
const float topBound = this->getTopBound();
const float bottomBound = this->getBottomBound();
static float lastBottomBound = 0.0f;
if (lastBottomBound != topBound) [[unlikely]] {
renderer->drawRect(this->getX() + 4, topBound, this->getWidth() + 10, 1, a(separatorColor));
}
renderer->drawRect(this->getX() + 4, bottomBound, this->getWidth() + 10, 1, a(separatorColor));
lastBottomBound = bottomBound;
#if IS_LAUNCHER_DIRECTIVE
static const std::vector<std::string> specialChars = {ult::STAR_SYMBOL};
#else
static const std::vector<std::string> specialChars = s_dividerSpecialChars;
#endif
// Fast path for non-truncated text
if (!m_flags.m_truncated) [[likely]] {
const Color textColor = m_focused
? (!ult::useSelectionText
? (m_flags.m_hasCustomTextColor ? m_customTextColor : defaultTextColor)
: (useClickTextColor
? clickTextColor
: selectedTextColor))
: (m_flags.m_hasCustomTextColor
? m_customTextColor
: (useClickTextColor
? clickTextColor
: defaultTextColor));
#if IS_LAUNCHER_DIRECTIVE
renderer->drawStringWithColoredSections(m_text_clean, false, specialChars, this->getX() + 19, this->getY() + 45 - yOffset, 23,
textColor, m_focused ? starColor : selectionStarColor);
#else
renderer->drawStringWithColoredSections(m_text_clean, false, specialChars, this->getX() + 19, this->getY() + 45 - yOffset, 23,
textColor, textSeparatorColor);
#endif
} else {
drawTruncatedText(renderer, yOffset, useClickTextColor, specialChars);
}
if (!m_value.empty()) [[likely]] {
drawValue(renderer, yOffset, useClickTextColor);
}
}
virtual void layout(u16 parentX, u16 parentY, u16 parentWidth, u16 parentHeight) override {
this->setBoundaries(this->getX() + 3, this->getY(), this->getWidth() + 9, m_listItemHeight);
}
virtual bool onClick(u64 keys) override {
if (keys & KEY_A) [[likely]] {
if (!isLocked) {
triggerRumbleClick.store(true, std::memory_order_release);
if (m_value.find(ult::CAPITAL_ON_STR) != std::string::npos)
triggerOffSound.store(true, std::memory_order_release);
else if (m_value.find(ult::CAPITAL_OFF_STR) != std::string::npos)
triggerOnSound.store(true, std::memory_order_release);
else
triggerEnterSound.store(true, std::memory_order_release);
} else {
triggerRumbleDoubleClick.store(true,std::memory_order_release);
triggerWallSound.store(true, std::memory_order_release);
}
if (m_flags.m_useClickAnimation)
triggerClickAnimation();
} else if (keys & (KEY_UP | KEY_DOWN | KEY_LEFT | KEY_RIGHT)) [[unlikely]] {
m_clickAnimationProgress = 0;
}
return Element::onClick(keys);
}
virtual bool onTouch(TouchEvent event, s32 currX, s32 currY, s32 prevX, s32 prevY, s32 initialX, s32 initialY) override {
if (event == TouchEvent::Touch) [[likely]] {
if ((m_touched = inBounds(currX, currY))) [[likely]] {
m_touchStartTime_ns = ult::nowNs();
m_flags.m_isTouchHolding = false; // Will be set to true when hold activates
m_flags.m_shortThresholdCrossed = false;
m_flags.m_longThresholdCrossed = false;
triggerNavigationFeedback();
}
}
if (event == TouchEvent::Hold && m_touched) [[likely]] {
const u64 touchDuration_ns = ult::nowNs() - m_touchStartTime_ns;
const float touchDurationInSeconds = static_cast<float>(touchDuration_ns) * 1e-9f;
// Activate touch hold immediately when Hold event fires
if (m_flags.m_usingTouchHolding && !m_flags.m_isTouchHolding && touchDurationInSeconds >= 0.1f) {
m_flags.m_isTouchHolding = true;
// Trigger the click with KEY_A to start hold behavior
return onClick(KEY_A);
}
if (m_flags.m_useLongThreshold && !m_flags.m_longThresholdCrossed && touchDurationInSeconds >= 1.0f) [[unlikely]] {
m_flags.m_longThresholdCrossed = true;
triggerRumbleClick.store(true, std::memory_order_release);
} else if (m_flags.m_useShortThreshold && !m_flags.m_shortThresholdCrossed && touchDurationInSeconds >= 0.5f) [[unlikely]] {
m_flags.m_shortThresholdCrossed = true;
triggerRumbleClick.store(true, std::memory_order_release);
}
return true; // Keep handling hold
}
if (event == TouchEvent::Release && m_touched) [[likely]] {
m_touched = false;
const bool wasHolding = m_flags.m_isTouchHolding;
m_flags.m_isTouchHolding = false; // Stop tracking hold on release
if (Element::getInputMode() == InputMode::Touch) [[likely]] {
m_clickAnimationProgress = 0;
// Only trigger normal click if we weren't in a hold
if (!wasHolding) {
return onClick(determineKeyOnTouchRelease());
}
}
}
return false;
}
virtual void setFocused(bool state) override {
if (state != m_focused) [[likely]] {
m_flags.m_scroll = false;
m_scrollOffset = 0;
timeIn_ns = ult::nowNs();
Element::setFocused(state);
}
}
virtual Element* requestFocus(Element *oldFocus, FocusDirection direction) override {
return this;
}
inline void setText(const std::string& text) {
if (m_text_clean != text) [[likely]] {
m_text = text;
m_text_clean = m_text;
if (!m_flags.m_keepTag) ult::removeTag(m_text_clean);
resetTextProperties();
applyInitialTranslations();
}
}
inline void setValue(const std::string& value, bool faint = false) {
if (m_value != value || m_flags.m_faint != faint) [[likely]] {
m_value = value;
m_flags.m_faint = faint;
m_maxWidth = 0;
if (!value.empty()) applyInitialTranslations(true);
}
}
inline void setTextColor(Color color) {
m_customTextColor = color;
m_flags.m_hasCustomTextColor = true;
}
inline void setValueColor(Color color) {
m_customValueColor = color;
m_flags.m_hasCustomValueColor = true;
}
inline void clearTextColor() {
m_flags.m_hasCustomTextColor = false;
}
inline void clearValueColor() {
m_flags.m_hasCustomValueColor = false;
}
inline void disableClickAnimation() {
m_flags.m_useClickAnimation = false;
}
inline void enableClickAnimation() {
m_flags.m_useClickAnimation = true;
}
inline void enableShortHoldKey() {
m_flags.m_useShortThreshold = true;
}
inline void disableShortHoldKey() {
m_flags.m_useShortThreshold = false;
}
inline void enableLongHoldKey() {
m_flags.m_useLongThreshold = true;
}
inline void disableLongHoldKey() {
m_flags.m_useLongThreshold = false;
}
inline void enableTouchHolding() {
m_flags.m_usingTouchHolding = true;
}
inline void disableTouchHolding() {
m_flags.m_usingTouchHolding = false;
}
inline bool isTouchHolding() const noexcept {
return m_flags.m_isTouchHolding;
}
inline void resetTouchHold() {
m_flags.m_isTouchHolding = false;
}
inline void setKeepTag(bool keep) {
m_flags.m_keepTag = keep;
setText(m_text);
}
inline const std::string& getText() const noexcept {
return m_text;
}
inline const std::string& getValue() const noexcept {
return m_value;
}
virtual bool matchesJumpCriteria(const std::string& jumpText, const std::string& jumpValue, bool exactMatch=true) const {
if (jumpText.empty() && jumpValue.empty()) return false;
bool textMatches, valueMatches;
if (exactMatch) {
textMatches = (m_text == jumpText);
valueMatches = (m_value == jumpValue);
} else { // contains check
textMatches = (m_text.find(jumpText) != std::string::npos);
valueMatches = (m_value.find(jumpValue) != std::string::npos);
}
if (jumpText.empty() && !jumpValue.empty())
return valueMatches;
else if (!jumpText.empty() && jumpValue.empty())
return textMatches;
return (textMatches && valueMatches);
}
protected:
u64 timeIn_ns;
std::string m_text;
std::string m_text_clean;
std::string m_value;
std::string m_scrollText;
std::string m_ellipsisText;
u16 m_listItemHeight; // Changed from u32 to u16
// Bitfield for boolean flags - saves ~7 bytes per instance
struct {
bool m_keepTag : 1;
bool m_scroll : 1;
bool m_truncated : 1;
bool m_faint : 1;
bool m_hasCustomTextColor : 1;
bool m_hasCustomValueColor : 1;
bool m_useClickAnimation : 1;
bool m_useShortThreshold : 1;
bool m_useLongThreshold : 1;
bool m_usingTouchHolding : 1;
bool m_isTouchHolding: 1;
bool m_shortThresholdCrossed : 1;
bool m_longThresholdCrossed : 1;
} m_flags = {};
Color m_customTextColor;
Color m_customValueColor;
float m_scrollOffset = 0.0f;
u16 m_maxWidth = 0; // Changed from u32 to u16
u16 m_textWidth = 0; // Changed from u32 to u16
private:
// Consolidated scroll constants struct
struct ScrollConstants {
double totalCycleDuration;
double delayDuration;
double scrollDuration;
double accelTime;
double constantVelocityTime;
double maxVelocity;
double accelDistance;
double constantVelocityDistance;
double minScrollDistance;
double invAccelTime;
double invDecelTime;
double invBillion;
bool initialized = false;
};
void applyInitialTranslations(bool isValue = false) {
std::string& target = isValue ? m_value : m_text_clean;
ult::applyLangReplacements(target, isValue);
ult::convertComboToUnicode(target);
{
const std::string originalKey = target;
std::shared_lock<std::shared_mutex> readLock(tsl::gfx::s_translationCacheMutex);
auto translatedIt = ult::translationCache.find(originalKey);
if (translatedIt != ult::translationCache.end()) {
target = translatedIt->second;
} else {
readLock.unlock();
std::unique_lock<std::shared_mutex> writeLock(tsl::gfx::s_translationCacheMutex);
translatedIt = ult::translationCache.find(originalKey);
if (translatedIt != ult::translationCache.end()) {
target = translatedIt->second;
} else {
ult::translationCache[originalKey] = originalKey;
}
}
}
}
void calculateWidths(gfx::Renderer* renderer) {
if (m_value.empty()) {
m_maxWidth = getWidth() - 62;
} else {
m_maxWidth = getWidth() - renderer->getTextDimensions(m_value, false, 20).first - 66;
}
const u16 width = renderer->getTextDimensions(m_text_clean, false, 23).first;
m_flags.m_truncated = width > m_maxWidth + 20;
if (m_flags.m_truncated) [[unlikely]] {
m_scrollText.clear();
m_scrollText.reserve(m_text_clean.size() * 2 + 8);
m_scrollText.append(m_text_clean).append(" ");
m_textWidth = renderer->getTextDimensions(m_scrollText, false, 23).first;
m_scrollText.append(m_text_clean);
m_ellipsisText = renderer->limitStringLength(m_text_clean, false, 23, m_maxWidth);
} else {
m_textWidth = width;
}
}
void drawTruncatedText(gfx::Renderer* renderer, s32 yOffset, bool useClickTextColor, const std::vector<std::string>& specialSymbols = {}) {
if (m_focused) {
renderer->enableScissoring(getX() + 6, 97, m_maxWidth + (m_value.empty() ? 49 : 27), tsl::cfg::FramebufferHeight - 170);
#if IS_LAUNCHER_DIRECTIVE
renderer->drawStringWithColoredSections(m_scrollText, false, specialSymbols, getX() + 19 - static_cast<s32>(m_scrollOffset), getY() + 45 - yOffset, 23,
!ult::useSelectionText ? defaultTextColor: (useClickTextColor ? clickTextColor : selectedTextColor), starColor);
#else
renderer->drawStringWithColoredSections(m_scrollText, false, specialSymbols, getX() + 19 - static_cast<s32>(m_scrollOffset), getY() + 45 - yOffset, 23,
!ult::useSelectionText ? defaultTextColor: (useClickTextColor ? clickTextColor : selectedTextColor), textSeparatorColor);
#endif
renderer->disableScissoring();
handleScrolling();
} else {
#if IS_LAUNCHER_DIRECTIVE
renderer->drawStringWithColoredSections(m_ellipsisText, false, specialSymbols, getX() + 19, getY() + 45 - yOffset, 23,
m_flags.m_hasCustomTextColor ? m_customTextColor : (useClickTextColor ? clickTextColor : defaultTextColor), starColor);
#else
renderer->drawStringWithColoredSections(m_ellipsisText, false, specialSymbols, getX() + 19, getY() + 45 - yOffset, 23,
m_flags.m_hasCustomTextColor ? m_customTextColor : (useClickTextColor ? clickTextColor : defaultTextColor), textSeparatorColor);
#endif
}
}
void handleScrolling() {
static ScrollConstants sc;
static u64 lastUpdateTime = 0;
static float cachedScrollOffset = 0.0f;
const u64 currentTime_ns = ult::nowNs();
const u64 elapsed_ns = currentTime_ns - timeIn_ns;
if (!sc.initialized || sc.minScrollDistance != static_cast<double>(m_textWidth)) {
sc.delayDuration = 2.0;
static constexpr double pauseDuration = 1.0;
sc.maxVelocity = 166.0;
sc.accelTime = 0.5;
static constexpr double decelTime = 0.5;
sc.minScrollDistance = static_cast<double>(m_textWidth);
sc.accelDistance = 0.5 * sc.maxVelocity * sc.accelTime;
const double decelDistance = 0.5 * sc.maxVelocity * decelTime;
sc.constantVelocityDistance = std::max(0.0, sc.minScrollDistance - sc.accelDistance - decelDistance);
sc.constantVelocityTime = sc.constantVelocityDistance / sc.maxVelocity;
sc.scrollDuration = sc.accelTime + sc.constantVelocityTime + decelTime;
sc.totalCycleDuration = sc.delayDuration + sc.scrollDuration + pauseDuration;
sc.invAccelTime = 1.0 / sc.accelTime;
sc.invDecelTime = 1.0 / decelTime;
sc.invBillion = 1.0 / 1000000000.0;
sc.initialized = true;
}
const double elapsed_seconds = static_cast<double>(elapsed_ns) * sc.invBillion;
if (currentTime_ns - lastUpdateTime >= 8333333ULL) {
const double cyclePosition = std::fmod(elapsed_seconds, sc.totalCycleDuration);
if (cyclePosition < sc.delayDuration) [[likely]] {
cachedScrollOffset = 0.0f;
} else if (cyclePosition < sc.delayDuration + sc.scrollDuration) [[likely]] {
const double scrollTime = cyclePosition - sc.delayDuration;
double distance;
if (scrollTime <= sc.accelTime) {
const double t = scrollTime * sc.invAccelTime;
const double smoothT = t * t;
distance = smoothT * sc.accelDistance;
} else if (scrollTime <= sc.accelTime + sc.constantVelocityTime) {
const double constantTime = scrollTime - sc.accelTime;
distance = sc.accelDistance + (constantTime * sc.maxVelocity);
} else {
const double decelStartTime = sc.accelTime + sc.constantVelocityTime;
const double t = (scrollTime - decelStartTime) * sc.invDecelTime;
const double oneMinusT = 1.0 - t;
const double smoothT = 1.0 - oneMinusT * oneMinusT;
distance = sc.accelDistance + sc.constantVelocityDistance + (smoothT * (sc.minScrollDistance - sc.accelDistance - sc.constantVelocityDistance));
}
cachedScrollOffset = static_cast<float>(distance < sc.minScrollDistance ? distance : sc.minScrollDistance);
} else [[unlikely]] {
cachedScrollOffset = static_cast<float>(m_textWidth);
}
lastUpdateTime = currentTime_ns;
}
m_scrollOffset = cachedScrollOffset;
if (elapsed_seconds >= sc.totalCycleDuration) [[unlikely]] {
timeIn_ns = currentTime_ns;
}
}
void drawValue(gfx::Renderer* renderer, s32 yOffset, bool useClickTextColor) {
const s32 xPosition = getX() + m_maxWidth + 47;
const s32 yPosition = getY() + 45 - yOffset-1;
static constexpr s32 fontSize = 20;
#if IS_LAUNCHER_DIRECTIVE
static bool lastRunningInterpreter = false;
const auto textColor = determineValueTextColor(useClickTextColor, lastRunningInterpreter);
if (m_value != ult::INPROGRESS_SYMBOL) [[likely]] {
renderer->drawStringWithColoredSections(m_value, false, s_dividerSpecialChars, xPosition, yPosition, fontSize, textColor, textSeparatorColor);
} else {
drawThrobber(renderer, xPosition, yPosition, fontSize, textColor);
}
lastRunningInterpreter = ult::runningInterpreter.load(std::memory_order_acquire);
#else
const auto textColor = determineValueTextColor(useClickTextColor);
if (m_value != ult::INPROGRESS_SYMBOL) [[likely]] {
renderer->drawStringWithColoredSections(m_value, false, s_dividerSpecialChars, xPosition, yPosition, fontSize, textColor, textSeparatorColor);
} else {
drawThrobber(renderer, xPosition, yPosition, fontSize, textColor);
}
#endif
}
#if IS_LAUNCHER_DIRECTIVE
Color determineValueTextColor(bool useClickTextColor, bool lastRunningInterpreter = false) const {
#else
Color determineValueTextColor(bool useClickTextColor) const {
#endif
if (m_focused && ult::useSelectionValue) {
if (m_value == ult::DROPDOWN_SYMBOL || m_value == ult::OPTION_SYMBOL) {
return useClickTextColor ? clickTextColor :
(m_flags.m_faint ? offTextColor : (ult::useSelectionText ? selectedTextColor : defaultTextColor));
}
// unique to focused: falls through to shared block below, but returns selectedValueTextColor at end
} else {
if (m_flags.m_hasCustomValueColor) return m_customValueColor;
if (m_value == ult::DROPDOWN_SYMBOL || m_value == ult::OPTION_SYMBOL) {
return useClickTextColor ? clickTextColor :
(m_flags.m_faint ? offTextColor : (m_focused ? (ult::useSelectionText ? selectedTextColor : defaultTextColor) : defaultTextColor));
}
}
// shared logic — only reached once per path
#if IS_LAUNCHER_DIRECTIVE
const bool isRunning = ult::runningInterpreter.load(std::memory_order_acquire) || lastRunningInterpreter;
if (isRunning && (m_value.find(ult::DOWNLOAD_SYMBOL) != std::string::npos ||
m_value.find(ult::UNZIP_SYMBOL) != std::string::npos ||
m_value.find(ult::COPY_SYMBOL) != std::string::npos))
return m_flags.m_faint ? offTextColor : inprogressTextColor;
#endif
if (m_value == ult::INPROGRESS_SYMBOL) return m_flags.m_faint ? offTextColor : inprogressTextColor;
if (m_value == ult::CROSSMARK_SYMBOL) return m_flags.m_faint ? offTextColor : invalidTextColor;
return (m_focused && ult::useSelectionValue)
? (useClickTextColor ? clickTextColor : selectedValueTextColor)
: (m_flags.m_faint ? offTextColor : onTextColor);
}
void drawThrobber(gfx::Renderer* renderer, s32 xPosition, s32 yPosition, s32 fontSize, Color textColor) {
static size_t throbberCounter = 0;
const auto& throbberSymbol = ult::THROBBER_SYMBOLS[(throbberCounter / 10) % ult::THROBBER_SYMBOLS.size()];
throbberCounter = (throbberCounter + 1) % (10 * ult::THROBBER_SYMBOLS.size());
renderer->drawString(throbberSymbol, false, xPosition, yPosition, fontSize, textColor);
}
s64 determineKeyOnTouchRelease() const {
const u64 touchDuration_ns = ult::nowNs() - m_touchStartTime_ns;
const float touchDurationInSeconds = static_cast<float>(touchDuration_ns) * 1e-9f;
if (m_flags.m_useLongThreshold) {
if (touchDurationInSeconds >= 1.0f) {
ult::longTouchAndRelease.store(true, std::memory_order_release);
return m_longHoldKey;
}
}
if (m_flags.m_useShortThreshold) {
if (touchDurationInSeconds >= 0.5f) {
ult::shortTouchAndRelease.store(true, std::memory_order_release);
return m_shortHoldKey;
}
}
return KEY_A;
}
void resetTextProperties() {
m_scrollText.clear();
m_ellipsisText.clear();
m_maxWidth = 0;
}
};
class SilentListItem : public tsl::elm::ListItem {
public:
using tsl::elm::ListItem::ListItem;
virtual bool onClick(u64 keys) override {
// Skip all sound/rumble triggers, go straight to click listener
if (keys & KEY_A) {
if (m_flags.m_useClickAnimation)
triggerClickAnimation();
} else if (keys & (KEY_UP | KEY_DOWN | KEY_LEFT | KEY_RIGHT)) {
m_clickAnimationProgress = 0;
}
return Element::onClick(keys);
}
};
class MiniListItem : public ListItem {
public:
MiniListItem(const std::string& text, const std::string& value = "")
: ListItem(text, value, true) { // Call the parent constructor with `isMini = true`
}
// Destructor if needed (inherits default behavior from ListItem)
virtual ~MiniListItem() {}
};
/**
* @brief A wrapper item that extends ListItem with custom color support for inputs
* (this version uses value and faint color sourcing)
*/
class ListItemV2 : public ListItem {
public:
/**
* @brief Constructor
*
* @param text Initial description text
* @param value Initial value text
* @param valueColor Color to use for the value when not faint
* @param faintColor Color to use for the value when faint
* @param isMini Whether to use mini list item height
* @param useScriptKey Whether to use script key (launcher only)
*/
ListItemV2(const std::string& text,
const std::string& value = "",
Color valueColor = onTextColor,
Color faintColor = offTextColor,
bool isMini = false)
: ListItem(text, value, isMini),
m_valueColorOverride(valueColor),
m_faintColorOverride(faintColor),
m_hasColorOverrides(true) {
// Set the custom value color on the base ListItem
setValueColor(valueColor);
}
virtual ~ListItemV2() = default;
/**
* @brief Override setValue to maintain custom color behavior
*/
inline void setValue(const std::string& value, bool faint = false) {
// Call parent implementation
ListItem::setValue(value, faint);
// Re-apply color override based on faint state
if (m_hasColorOverrides) {
setValueColor(faint ? m_faintColorOverride : m_valueColorOverride);
}
}
/**
* @brief Set custom value color
*/
inline void setValueColorOverride(Color color) {
m_valueColorOverride = color;
m_hasColorOverrides = true;
// Update the base class if not currently faint
if (!m_flags.m_faint) {
setValueColor(color);
}
}
/**
* @brief Set custom faint color
*/
inline void setFaintColorOverride(Color color) {
m_faintColorOverride = color;
m_hasColorOverrides = true;
// Update the base class if currently faint
if (m_flags.m_faint) {
setValueColor(color);
}
}
/**
* @brief Get the current value color override
*/
inline Color getValueColorOverride() const {
return m_valueColorOverride;
}
/**
* @brief Get the current faint color override
*/
inline Color getFaintColorOverride() const {
return m_faintColorOverride;
}
/**
* @brief Clear color overrides and revert to default behavior
*/
inline void clearColorOverrides() {
m_hasColorOverrides = false;
clearValueColor();
}
protected:
Color m_valueColorOverride;
Color m_faintColorOverride;
bool m_hasColorOverrides;
};
/**
* @brief Mini version of ListItemV2
*/
class MiniListItemV2 : public ListItemV2 {
public:
MiniListItemV2(const std::string& text,
const std::string& value = "",
Color valueColor = onTextColor,
Color faintColor = offTextColor)
: ListItemV2(text, value, valueColor, faintColor, true) {
}
virtual ~MiniListItemV2() {}
};
/**
* @brief A toggleable list item that changes the state from On to Off when the A button gets pressed
*
*/
class ToggleListItem : public ListItem {
public:
/**
* @brief Constructor
*
* @param text Initial description text
* @param initialState Is the toggle set to On or Off initially
* @param onValue Value drawn if the toggle is on
* @param offValue Value drawn if the toggle is off
*/
ToggleListItem(const std::string& text, bool initialState, const std::string& onValue = ult::ON, const std::string& offValue = ult::OFF, bool isMini = false, bool delayedHandle=false)
: ListItem(text, "", isMini), m_state(initialState), m_onValue(onValue), m_offValue(offValue), m_delayedHandle(delayedHandle) {
this->setState(this->m_state);
}
virtual ~ToggleListItem() {}
virtual bool onClick(u64 keys) override {
#if IS_LAUNCHER_DIRECTIVE
if (ult::runningInterpreter.load(std::memory_order_acquire))
return false;
#endif
// Handle KEY_A for toggling
if (keys & KEY_A) {
triggerRumbleClick.store(true, std::memory_order_release);
if (!this->m_state)
triggerOnSound.store(true, std::memory_order_release);
else
triggerOffSound.store(true, std::memory_order_release);
this->m_state = !this->m_state;
if (!m_delayedHandle)
this->setState(this->m_state);
this->m_stateChangedListener(this->m_state);
this->triggerClickAnimation();
return Element::onClick(keys);
}
#if IS_LAUNCHER_DIRECTIVE
// Handle SCRIPT_KEY for executing script logic
else if (keys & SCRIPT_KEY) {
// Trigger the script key listener
if (this->m_scriptKeyListener) {
this->m_scriptKeyListener(this->m_state); // Pass the current state to the script key listener
}
return ListItem::onClick(keys);
}
#endif
return false;
}
/**
* @brief Gets the current state of the toggle
*
* @return State
*/
virtual bool getState() {
return this->m_state;
}
/**
* @brief Sets the current state of the toggle. Updates the Value
*
* @param state State
*/
virtual void setState(bool state) {
#if IS_LAUNCHER_DIRECTIVE
if (ult::runningInterpreter.load(std::memory_order_acquire))
return;
#endif
this->m_state = state;
this->setValue(state ? this->m_onValue : this->m_offValue, !state);
}
/**
* @brief Adds a listener that gets called whenever the state of the toggle changes
*
* @param stateChangedListener Listener with the current state passed in as parameter
*/
void setStateChangedListener(std::function<void(bool)> stateChangedListener) {
this->m_stateChangedListener = stateChangedListener;
}
#if IS_LAUNCHER_DIRECTIVE
// Attach the script key listener for SCRIPT_KEY handling
void setScriptKeyListener(std::function<void(bool)> scriptKeyListener) {
this->m_scriptKeyListener = scriptKeyListener;
}
#endif
protected:
bool m_state = true;
std::string m_onValue, m_offValue;
bool m_delayedHandle = false;
std::function<void(bool)> m_stateChangedListener = [](bool){};
#if IS_LAUNCHER_DIRECTIVE
std::function<void(bool)> m_scriptKeyListener = nullptr; // Script key listener (with state)
#endif
};
class MiniToggleListItem : public ToggleListItem {
public:
// Constructor for MiniToggleListItem, with no `isMini` boolean.
MiniToggleListItem(const std::string& text, bool initialState, const std::string& onValue = ult::ON, const std::string& offValue = ult::OFF)
: ToggleListItem(text, initialState, onValue, offValue, true) {
}
// Destructor if needed (inherits default behavior from ListItem)
virtual ~MiniToggleListItem() {}
};
class DummyListItem : public ListItem {
public:
DummyListItem()
: ListItem("") { // Use an empty string for the base class constructor
// Set the properties to indicate it's a dummy item
this->m_text = "";
this->m_value = "";
this->m_maxWidth = 0;
this->width = 0;
this->height = 0;
m_isItem = false;
isLocked = true;
}
virtual ~DummyListItem() {}
// Override the draw method to do nothing
virtual void draw(gfx::Renderer* renderer) override {
// Intentionally left blank
}
// Override the layout method to set the dimensions to zero
virtual void layout(u16 parentX, u16 parentY, u16 parentWidth, u16 parentHeight) override {
this->setBoundaries(this->getX(), this->getY(), 0, 0);
}
// Override the requestFocus method to allow this item to be focusable
virtual Element* requestFocus(Element* oldFocus, FocusDirection direction) override {
return this; // Allow this item to be focusable
}
};
class CategoryHeader : public Element {
public:
CategoryHeader(const std::string &title, bool hasSeparator = true)
: m_text(title),
m_value(""),
m_valueColor(tsl::headerTextColor),
m_hasSeparator(hasSeparator),
m_scroll(false),
m_truncated(false),
m_scrollOffset(0.0f),
m_maxWidth(0),
m_textWidth(0) {
ult::applyLangReplacements(m_text);
ult::convertComboToUnicode(m_text);
m_isItem = false;
}
virtual ~CategoryHeader() {}
// --- new setters ---
void setValue(const std::string &value, const tsl::Color &color = tsl::headerTextColor) {
m_value = value;
m_valueColor = color;
}
void clearValue() {
m_value.clear();
}
virtual void draw(gfx::Renderer* renderer) override {
if (!m_maxWidth) calculateWidths(renderer);
const int fontHeight = 16;
// Keep a fixed header area for separator and text (matches old 33px height)
const int headerTop = this->getBottomBound() - 33;
const int textY = this->getBottomBound() - 16;
const int textX = m_hasSeparator ? (this->getX() + 16) : this->getX();
// Draw the separator rectangle on the left (fixed 22px height, 4px wide)
if (m_hasSeparator) {
renderer->drawRect(
this->getX() + 2,
headerTop,
4,
22,
aWithOpacity(headerSeparatorColor));
}
// Draw header text
if (m_truncated) {
if (!m_scroll) m_scroll = true;
handleScrolling();
renderer->enableScissoring(textX, ult::activeHeaderHeight-8, m_maxWidth, cfg::FramebufferHeight - 73 - (ult::activeHeaderHeight-8));
renderer->drawStringWithColoredSections(
m_scrollText, false, s_dividerSpecialChars,
textX - static_cast<s32>(m_scrollOffset),
textY,
fontHeight,
headerTextColor,
textSeparatorColor
);
renderer->disableScissoring();
} else {
renderer->drawStringWithColoredSections(
m_text, false, s_dividerSpecialChars,
textX, textY,
fontHeight,
headerTextColor,
textSeparatorColor
);
}
// Draw optional value, right-aligned
if (!m_value.empty()) {
const int valueWidth = renderer->getTextDimensions(m_value, false, fontHeight).first;
const int valueX = this->getX() + 2 + this->getWidth() - valueWidth;
renderer->drawString(
m_value,
false,
valueX,
textY,
fontHeight,
m_valueColor,
0, true, nullptr, nullptr, 0, 0, false
);
}
}
virtual void layout(u16 parentX, u16 parentY, u16 parentWidth, u16 parentHeight) override {
if (List *list = static_cast<List *>(this->getParent()); list != nullptr) {
if (list->getIndexInList(this) == 0) {
this->setBoundaries(this->getX(), this->getY(), this->getWidth(), 33);
return;
}
}
this->setBoundaries(
this->getX(),
this->getY(),
this->getWidth(),
tsl::style::ListItemDefaultHeight * 0.90);
}
inline void setText(const std::string &text) {
if (m_text != text) {
m_text = text;
ult::applyLangReplacements(m_text);
ult::convertComboToUnicode(m_text);
resetTextProperties();
}
}
inline const std::string &getText() const {
return m_text;
}
private:
std::string m_text;
std::string m_value;
tsl::Color m_valueColor;
bool m_hasSeparator;
bool m_scroll;
bool m_truncated;
float m_scrollOffset;
u32 m_maxWidth;
u32 m_textWidth;
std::string m_scrollText;
/* Delta-time animation state */
u64 lastFrameTime_ns = 0;
double accumulatedTime_s = 0.0;
/* Cached calculations */
bool constantsInitialized = false;
double totalCycleDuration;
double delayDuration;
double scrollDuration;
double accelTime;
double constantVelocityTime;
double maxVelocity;
double accelDistance;
double constantVelocityDistance;
double minScrollDistance;
double invAccelTime;
double invDecelTime;
double invBillion;
float cachedScrollOffset = 0.0f;
void calculateWidths(gfx::Renderer *renderer) {
m_maxWidth = getWidth() - (m_hasSeparator ? 17 : 4);
const u32 width = renderer->getTextDimensions(m_text, false, 16).first;
m_truncated = width > m_maxWidth;
if (m_truncated) {
m_scrollText.clear();
m_scrollText.reserve(m_text.size() * 2 + 8);
m_scrollText.append(m_text).append(" ");
m_textWidth = renderer->getTextDimensions(m_scrollText, false, 16).first;
m_scrollText.append(m_text);
} else {
m_textWidth = width;
}
constantsInitialized = false;
}
void handleScrolling() {
const u64 currentTime_ns = ult::nowNs();
if (!constantsInitialized || minScrollDistance != static_cast<double>(m_textWidth)) {
delayDuration = 3.0;
static constexpr double pauseDuration = 2.0;
maxVelocity = 100.0;
accelTime = 0.5;
static constexpr double decelTime = 0.5;
minScrollDistance = static_cast<double>(m_textWidth);
accelDistance = 0.5 * maxVelocity * accelTime;
const double decelDistance = 0.5 * maxVelocity * decelTime;
constantVelocityDistance =
std::max(0.0, minScrollDistance - accelDistance - decelDistance);
constantVelocityTime = constantVelocityDistance / maxVelocity;
scrollDuration = accelTime + constantVelocityTime + decelTime;
totalCycleDuration = delayDuration + scrollDuration + pauseDuration;
invAccelTime = 1.0 / accelTime;
invDecelTime = 1.0 / decelTime;
invBillion = 1.0 / 1000000000.0;
constantsInitialized = true;
}
if (lastFrameTime_ns != 0) {
double delta_s =
static_cast<double>(currentTime_ns - lastFrameTime_ns) * invBillion;
/* Clamp large jumps (list switches / lag spikes) */
delta_s = std::min(delta_s, 0.05);
accumulatedTime_s += delta_s;
}
lastFrameTime_ns = currentTime_ns;
const double cyclePosition =
std::fmod(accumulatedTime_s, totalCycleDuration);
if (cyclePosition < delayDuration) {
cachedScrollOffset = 0.0f;
} else if (cyclePosition < delayDuration + scrollDuration) {
const double scrollTime = cyclePosition - delayDuration;
double distance;
if (scrollTime <= accelTime) {
const double t = scrollTime * invAccelTime;
distance = (t * t) * accelDistance;
} else if (scrollTime <= accelTime + constantVelocityTime) {
const double t = scrollTime - accelTime;
distance = accelDistance + (t * maxVelocity);
} else {
const double decelStart = accelTime + constantVelocityTime;
const double t = (scrollTime - decelStart) * invDecelTime;
const double smooth = 1.0 - (1.0 - t) * (1.0 - t);
distance = accelDistance + constantVelocityDistance +
smooth * (minScrollDistance - accelDistance - constantVelocityDistance);
}
cachedScrollOffset = static_cast<float>(
distance < minScrollDistance ? distance : minScrollDistance);
} else {
cachedScrollOffset = static_cast<float>(m_textWidth);
}
m_scrollOffset = cachedScrollOffset;
}
void resetTextProperties() {
m_scrollOffset = 0.0f;
cachedScrollOffset = 0.0f;
lastFrameTime_ns = 0;
accumulatedTime_s = 0.0;
m_maxWidth = 0;
m_textWidth = 0;
m_scroll = false;
constantsInitialized = false;
}
};
/**
* @brief A customizable analog trackbar going from 0% to 100% (like the brightness slider)
*
*/
class TrackBar : public Element {
public:
/**
* @brief Constructor
*
* @param icon Icon shown next to the track bar
* @param usingStepTrackbar Whether this is a step trackbar
* @param usingNamedStepTrackbar Whether this is a named step trackbar
* @param useV2Style Whether to use V2 visual style (label + value instead of icon)
* @param label Label text for V2 style
* @param units Units text for V2 style
*/
TrackBar(const char icon[3], bool usingStepTrackbar=false, bool usingNamedStepTrackbar = false,
bool useV2Style = false, const std::string& label = "", const std::string& units = "",
bool unlockedTrackbar = true)
: m_icon(icon), m_usingStepTrackbar(usingStepTrackbar), m_usingNamedStepTrackbar(usingNamedStepTrackbar),
m_unlockedTrackbar(unlockedTrackbar), m_useV2Style(useV2Style), m_label(label), m_units(units) {
m_isItem = true;
}
virtual ~TrackBar() {}
virtual void triggerClickAnimation() {
Element::triggerClickAnimation();
// Activate the click animation
this->m_clickAnimationStartTime = ult::nowNs();
this->m_clickAnimationActive = true;
}
virtual Element* requestFocus(Element *oldFocus, FocusDirection direction) {
return this;
}
virtual bool handleInput(u64 keysDown, u64 keysHeld, const HidTouchState &touchPos, HidAnalogStickState leftJoyStick, HidAnalogStickState rightJoyStick) override {
const u64 keysReleased = m_prevKeysHeld & ~keysHeld;
m_prevKeysHeld = keysHeld;
const u64 currentTime_ns = ult::nowNs();
static u64 lastUpdate_ns = currentTime_ns;
const u64 elapsed_ns = currentTime_ns - lastUpdate_ns;
// KEY_R + directional: shake highlight (same as V2)
if (keysHeld & KEY_R) {
if (keysDown & KEY_UP && !(keysHeld & ~KEY_UP & ~KEY_R & ALL_KEYS_MASK))
this->shakeHighlight(FocusDirection::Up);
else if (keysDown & KEY_DOWN && !(keysHeld & ~KEY_DOWN & ~KEY_R & ALL_KEYS_MASK))
this->shakeHighlight(FocusDirection::Down);
else if (keysDown & KEY_LEFT && !(keysHeld & ~KEY_LEFT & ~KEY_R & ALL_KEYS_MASK))
this->shakeHighlight(FocusDirection::Left);
else if (keysDown & KEY_RIGHT && !(keysHeld & ~KEY_RIGHT & ~KEY_R & ALL_KEYS_MASK))
this->shakeHighlight(FocusDirection::Right);
return true;
}
// KEY_A: lock/unlock toggle (when locked), or click animation (when unlocked)
if ((keysDown & KEY_A) && !(keysHeld & ~KEY_A & ALL_KEYS_MASK)) {
if (!m_unlockedTrackbar) {
ult::atomicToggle(ult::allowSlide);
m_holding = false;
if (ult::allowSlide.load(std::memory_order_acquire)) {
// Unlocking: rumble + on sound only, no click animation, no enter feedback
triggerRumbleClick.store(true, std::memory_order_release);
triggerOnSound.store(true, std::memory_order_release);
} else {
// Locking: rumble + off sound only, no click animation
triggerRumbleClick.store(true, std::memory_order_release);
triggerOffSound.store(true, std::memory_order_release);
}
} else {
// Always-unlocked trackbar: full click animation + enter feedback
this->triggerClickAnimation();
triggerEnterFeedback();
}
return true;
}
// Guard all movement behind lock state
if (!m_unlockedTrackbar && !ult::allowSlide.load(std::memory_order_acquire)) {
return false;
}
static s16 lastHapticSegment = -1;
// Handle key release
if ((keysReleased & KEY_LEFT) || (keysReleased & KEY_RIGHT)) {
lastHapticSegment = -1;
if (m_wasLastHeld) {
m_wasLastHeld = false;
m_holding = false;
lastUpdate_ns = currentTime_ns;
return true;
} else if (m_holding) {
m_holding = false;
lastUpdate_ns = currentTime_ns;
return true;
}
}
// Ignore simultaneous left+right
if (keysHeld & KEY_LEFT && keysHeld & KEY_RIGHT)
return true;
// Handle initial key press
if (keysDown & KEY_LEFT || keysDown & KEY_RIGHT) {
m_holding = true;
m_wasLastHeld = false;
m_holdStartTime_ns = currentTime_ns;
lastUpdate_ns = currentTime_ns;
if (keysDown & KEY_LEFT && this->m_value > 0) {
this->m_value--;
this->m_valueChangedListener(this->m_value);
const s16 currentSegment = (this->m_value * 10) / 100;
if (this->m_value == 0 || currentSegment != lastHapticSegment) {
lastHapticSegment = currentSegment;
triggerNavigationFeedback();
}
} else if (keysDown & KEY_RIGHT && this->m_value < 100) {
this->m_value++;
this->m_valueChangedListener(this->m_value);
const s16 currentSegment = (this->m_value * 10) / 100;
if (this->m_value == 0 || currentSegment != lastHapticSegment) {
lastHapticSegment = currentSegment;
triggerNavigationFeedback();
}
}
return true;
}
// Handle continued holding with acceleration
if (m_holding && ((keysHeld & KEY_LEFT) || (keysHeld & KEY_RIGHT))) {
const u64 holdDuration_ns = currentTime_ns - m_holdStartTime_ns;
static constexpr u64 initialDelay_ns = 300000000ULL;
static constexpr u64 initialInterval_ns = 67000000ULL;
static constexpr u64 shortInterval_ns = 10000000ULL;
static constexpr u64 transitionPoint_ns = 1000000000ULL;
if (holdDuration_ns < initialDelay_ns) {
return true;
}
const u64 holdDurationAfterDelay_ns = holdDuration_ns - initialDelay_ns;
const float t = std::min(1.0f, static_cast<float>(holdDurationAfterDelay_ns) / static_cast<float>(transitionPoint_ns));
const u64 currentInterval_ns = static_cast<u64>((initialInterval_ns - shortInterval_ns) * (1.0f - t) + shortInterval_ns);
if (elapsed_ns >= currentInterval_ns) {
if (keysHeld & KEY_LEFT && this->m_value > 0) {
this->m_value--;
this->m_valueChangedListener(this->m_value);
const s16 currentSegment = (this->m_value * 10) / 100;
if (this->m_value == 0 || currentSegment != lastHapticSegment) {
lastHapticSegment = currentSegment;
triggerNavigationFeedback();
}
lastUpdate_ns = currentTime_ns;
m_wasLastHeld = true;
return true;
}
if (keysHeld & KEY_RIGHT && this->m_value < 100) {
this->m_value++;
this->m_valueChangedListener(this->m_value);
const s16 currentSegment = (this->m_value * 10) / 100;
if (this->m_value == 0 || currentSegment != lastHapticSegment) {
lastHapticSegment = currentSegment;
triggerNavigationFeedback();
}
lastUpdate_ns = currentTime_ns;
m_wasLastHeld = true;
return true;
}
}
} else {
m_holding = false;
}
return false;
}
virtual bool onTouch(TouchEvent event, s32 currX, s32 currY, s32 prevX, s32 prevY, s32 initialX, s32 initialY) override {
s32 trackBarLeft = this->getX() + 59;
s32 width = this->getWidth() - 95;
if (m_icon[0] != '\0') {
const s32 iconOffset = 14 + 23;
trackBarLeft += iconOffset;
width -= iconOffset;
}
const s32 trackBarRight = trackBarLeft + width;
const u16 handlePos = (width * this->m_value) / 100;
const s32 circleCenterX = trackBarLeft + handlePos;
const s32 circleCenterY = this->getY() + 40 + 16 - 3 - ((!m_usingNamedStepTrackbar && !m_useV2Style) ? 11 : 0);
static constexpr s32 circleRadius = 16;
static bool triggerOnce = true;
static s16 lastHapticSegment = -1;
const bool touchInCircle = (std::abs(currX - circleCenterX) <= circleRadius) && (std::abs(currY - circleCenterY) <= circleRadius);
const bool currentlyInHorizontalBounds = (currX >= trackBarLeft && currX <= trackBarRight);
if (event == TouchEvent::Release) {
triggerOnce = true;
lastHapticSegment = -1;
if (touchInSliderBounds) {
triggerRumbleDoubleClick.store(true, std::memory_order_release);
triggerOffSound.store(true, std::memory_order_release);
tsl::shiftItemFocus(this);
}
touchInSliderBounds = false;
return false;
}
if (touchInCircle || touchInSliderBounds) {
if (touchInSliderBounds && !currentlyInHorizontalBounds) {
if (currX > trackBarRight) {
this->m_value = 100;
} else if (currX < trackBarLeft) {
this->m_value = 0;
}
this->m_valueChangedListener(this->getProgress());
touchInSliderBounds = false;
return false;
}
if (currentlyInHorizontalBounds) {
if (triggerOnce) {
triggerOnce = false;
triggerRumbleClick.store(true, std::memory_order_release);
triggerOnSound.store(true, std::memory_order_release);
}
touchInSliderBounds = true;
s16 newValue = (static_cast<float>(currX - trackBarLeft) / static_cast<float>(width)) * 100;
if (newValue < 0) {
newValue = 0;
} else if (newValue > 100) {
newValue = 100;
}
if (newValue != this->m_value) {
this->m_value = newValue;
this->m_valueChangedListener(this->getProgress());
const s16 currentSegment = (this->m_value * 10) / 100;
if (this->m_value == 0 || currentSegment != lastHapticSegment) {
lastHapticSegment = currentSegment;
triggerNavigationFeedback();
}
}
return true;
}
}
return false;
}
// Define drawBar function outside the draw method
void drawBar(gfx::Renderer *renderer, s32 x, s32 y, u16 width, Color& color, bool isRounded = true) {
if (isRounded) {
renderer->drawUniformRoundedRect(x, y, width, 7, a(color));
} else {
renderer->drawRect(x, y, width, 7, a(color));
}
}
virtual void draw(gfx::Renderer *renderer) override {
if (touchInSliderBounds) {
m_drawFrameless = true;
drawHighlight(renderer);
} else {
m_drawFrameless = false;
}
s32 xPos = this->getX() + 59;
s32 yPos = this->getY() + 40 + 16 - 3;
s32 width = this->getWidth() - 95;
const int maxValue = (m_usingStepTrackbar || m_usingNamedStepTrackbar)
? ((100 / (this->m_numSteps - 1)) * (this->m_numSteps - 1))
: 100;
u16 handlePos = width * (this->m_value) / maxValue;
if (!m_usingNamedStepTrackbar && !m_useV2Style) {
yPos -= 11;
}
s32 iconOffset = 0;
if (m_icon[0] != '\0') {
s32 iconWidth = 23;
iconOffset = 14 + iconWidth;
xPos += iconOffset;
width -= iconOffset;
handlePos = (width) * (this->m_value) / (100);
}
// Draw step tick marks if this is a step trackbar
if (m_usingStepTrackbar || m_usingNamedStepTrackbar) {
const u8 numSteps = m_numSteps;
const u16 baseX = xPos;
const u16 baseY = this->getY() + 44;
const u8 halfNumSteps = (numSteps - 1) / 2;
const u16 lastStepX = baseX + width - 1;
const float stepSpacing = static_cast<float>(width) / (numSteps - 1);
const auto stepColor = a(trackBarEmptyColor);
u16 stepX;
for (u8 i = 0; i < numSteps; i++) {
if (i == numSteps - 1) {
stepX = lastStepX;
} else {
stepX = baseX + static_cast<u16>(std::round(i * stepSpacing));
if (i > halfNumSteps) {
stepX -= 1;
}
}
renderer->drawRect(stepX, baseY, 1, 8, stepColor);
}
}
// Draw track bar background
drawBar(renderer, xPos, yPos-3, width, trackBarEmptyColor, !m_usingNamedStepTrackbar);
const bool isEffectivelyUnlocked = m_unlockedTrackbar || ult::allowSlide.load(std::memory_order_acquire);
if (!this->m_focused) {
drawBar(renderer, xPos, yPos-3, handlePos, trackBarFullColor, !m_usingNamedStepTrackbar);
renderer->drawCircle(xPos + handlePos, yPos, 16, true, a(m_drawFrameless ? s_highlightColor : trackBarSliderBorderColor));
renderer->drawCircle(xPos + handlePos, yPos, 13, true, a((isEffectivelyUnlocked || touchInSliderBounds) ? trackBarSliderMalleableColor : trackBarSliderColor));
} else {
touchInSliderBounds = false;
if (m_unlockedTrackbar != ult::unlockedSlide.load(std::memory_order_acquire))
ult::unlockedSlide.store(m_unlockedTrackbar, std::memory_order_release);
drawBar(renderer, xPos, yPos-3, handlePos, trackBarFullColor, !m_usingNamedStepTrackbar);
renderer->drawCircle(xPos + x + handlePos, yPos +y, 16, true, a(s_highlightColor));
renderer->drawCircle(xPos + x + handlePos, yPos +y, 12, true, a(isEffectivelyUnlocked ? trackBarSliderMalleableColor : trackBarSliderColor));
}
// Draw icon (always if provided), then label + value (V2 style)
if (m_useV2Style) {
std::string labelPart = this->m_label;
ult::removeTag(labelPart);
std::string valuePart;
if (!m_usingNamedStepTrackbar) {
valuePart = (m_units.compare("%") == 0 || m_units.compare("°C") == 0 || m_units.compare("°F") == 0)
? ult::to_string(m_value) + m_units
: ult::to_string(m_value) + (m_units.empty() ? "" : " ") + m_units;
} else {
valuePart = this->m_selection;
}
const auto valueWidth = renderer->getTextDimensions(valuePart, false, 16).first;
const s32 labelX = xPos;
const s32 valueX = xPos + width - valueWidth;
renderer->drawString(labelPart, false, labelX, this->getY() + 14 + 16, 16,
((!this->m_focused || !ult::useSelectionText) ? defaultTextColor : selectedTextColor));
renderer->drawString(valuePart, false, valueX, this->getY() + 14 + 16, 16,
(this->m_focused && ult::useSelectionValue) ? selectedValueTextColor : onTextColor);
if (m_icon[0] != '\0')
renderer->drawString(this->m_icon, false, this->getX()+42, this->getY() + 50+2+2, 30, tsl::style::color::ColorText);
} else {
if (m_icon[0] != '\0')
renderer->drawString(this->m_icon, false, this->getX()+42, this->getY() + 50+2+2, 30, tsl::style::color::ColorText);
}
if (m_lastBottomBound != this->getTopBound())
renderer->drawRect(this->getX() + 4+20-1, this->getTopBound(), this->getWidth() + 6 + 10+20 +4, 1, a(separatorColor));
renderer->drawRect(this->getX() + 4+20-1, this->getBottomBound(), this->getWidth() + 6 + 10+20 +4, 1, a(separatorColor));
m_lastBottomBound = this->getBottomBound();
}
virtual void layout(u16 parentX, u16 parentY, u16 parentWidth, u16 parentHeight) override {
this->setBoundaries(this->getX() - 16 , this->getY(), this->getWidth()+20+4, tsl::style::TrackBarDefaultHeight );
}
virtual void drawFocusBackground(gfx::Renderer *renderer) {
// No background drawn here in HOS
}
virtual void drawHighlight(gfx::Renderer *renderer) override {
const u64 currentTime_ns = ult::nowNs();
const double time_seconds = static_cast<double>(currentTime_ns) / 1000000000.0;
progress = (ult::cos(2.0 * ult::_M_PI * std::fmod(time_seconds, 1.0) - ult::_M_PI / 2) + 1.0) / 2.0;
if (m_clickAnimationActive) {
Color clickColor1 = highlightColor1;
Color clickColor2 = clickColor;
if (progress >= 0.5) {
clickColor1 = clickColor;
clickColor2 = highlightColor2;
}
const u64 elapsedTime_ns = currentTime_ns - this->m_clickAnimationStartTime;
if (elapsedTime_ns < 500000000ULL) {
s_highlightColor = lerpColor(clickColor1, clickColor2, progress);
} else {
m_clickAnimationActive = false;
}
} else {
// Use dim colors when locked, bright colors when unlocked
if (!m_unlockedTrackbar && !ult::allowSlide.load(std::memory_order_acquire)) {
s_highlightColor = lerpColor(highlightColor3, highlightColor4, progress);
} else {
s_highlightColor = lerpColor(highlightColor1, highlightColor2, progress);
}
}
x = 0;
y = 0;
if (this->m_highlightShaking) {
t_ns = currentTime_ns - this->m_highlightShakingStartTime;
const double t_ms = t_ns / 1000000.0;
static constexpr double SHAKE_DURATION_MS = 200.0;
if (t_ms >= SHAKE_DURATION_MS)
this->m_highlightShaking = false;
else {
const double amplitude = 6.0 + ((this->m_highlightShakingStartTime / 1000000) % 5);
const double progress = t_ms / SHAKE_DURATION_MS;
const double damping = 1.0 / (1.0 + 2.5 * progress * (1.0 + 1.3 * progress));
const double oscillation = ult::cos(ult::_M_PI * 4.0 * progress);
const double displacement = amplitude * oscillation * damping;
const int offset = static_cast<int>(displacement);
switch (this->m_highlightShakingDirection) {
case FocusDirection::Up: y = -offset; break;
case FocusDirection::Down: y = offset; break;
case FocusDirection::Left: x = -offset; break;
case FocusDirection::Right: x = offset; break;
default: break;
}
}
}
if (!m_drawFrameless) {
if (ult::useSelectionBG) {
renderer->drawRectAdaptive(this->getX() + x +19, this->getY() + y, this->getWidth()-11-4, this->getHeight(), aWithOpacity(selectionBGColor));
}
renderer->drawBorderedRoundedRect(this->getX() + x +19, this->getY() + y, this->getWidth()-11, this->getHeight(), 5, 5, a(s_highlightColor));
} else {
if (ult::useSelectionBG) {
renderer->drawRectAdaptive(this->getX() + x +19, this->getY() + y, this->getWidth()-11-4, this->getHeight(), aWithOpacity(clickColor));
}
}
ult::onTrackBar.exchange(true, std::memory_order_acq_rel);
if (this->m_clickAnimationActive) {
const u64 elapsedTime_ns = currentTime_ns - this->m_clickAnimationStartTime;
auto clickAnimationProgress = tsl::style::ListItemHighlightLength * (1.0f - (static_cast<float>(elapsedTime_ns) / 500000000.0f));
if (clickAnimationProgress < 0.0f) {
clickAnimationProgress = 0.0f;
this->m_clickAnimationActive = false;
}
if (clickAnimationProgress > 0.0f) {
const u8 saturation = tsl::style::ListItemHighlightSaturation * (float(clickAnimationProgress) / float(tsl::style::ListItemHighlightLength));
Color animColor = {0xF, 0xF, 0xF, 0xF};
if (invertBGClickColor) {
animColor.r = 15 - saturation;
animColor.g = 15 - saturation;
animColor.b = 15 - saturation;
} else {
animColor.r = saturation;
animColor.g = saturation;
animColor.b = saturation;
}
animColor.a = selectionBGColor.a;
renderer->drawRect(this->getX() +22, this->getY(), this->getWidth() -22, this->getHeight(), aWithOpacity(animColor));
}
}
}
/**
* @brief Gets the current value of the trackbar
*
* @return State
*/
virtual u8 getProgress() {
return this->m_value;
}
/**
* @brief Sets the current state of the toggle. Updates the Value
*
* @param state State
*/
virtual void setProgress(u8 value) {
this->m_value = value;
}
/**
* @brief Adds a listener that gets called whenever the state of the toggle changes
*
* @param stateChangedListener Listener with the current state passed in as parameter
*/
void setValueChangedListener(std::function<void(u8)> valueChangedListener) {
this->m_valueChangedListener = valueChangedListener;
}
protected:
const char *m_icon = nullptr;
s16 m_value = 0;
bool m_interactionLocked = false;
std::function<void(u8)> m_valueChangedListener = [](u8){};
bool m_usingStepTrackbar = false;
bool m_usingNamedStepTrackbar = false;
bool m_unlockedTrackbar = true;
bool touchInSliderBounds = false;
u64 m_clickAnimationStartTime = 0;
bool m_clickAnimationActive = false;
u8 m_numSteps = 101;
// V2 Style properties
bool m_useV2Style = false;
std::string m_label;
std::string m_units;
std::string m_selection; // Used for named step trackbars
bool m_drawFrameless = false;
float m_lastBottomBound;
s16 m_index = 0; // Add index tracking like V2
u64 m_holdStartTime_ns = 0;
bool m_holding = false;
bool m_wasLastHeld = false;
u64 m_prevKeysHeld = 0;
};
/**
* @brief A customizable analog trackbar going from 0% to 100% but using discrete steps (Like the volume slider)
*
*/
class StepTrackBar : public TrackBar {
public:
/**
* @brief Constructor
*
* @param icon Icon shown next to the track bar
* @param numSteps Number of steps the track bar has
* @param usingNamedStepTrackbar Whether this is a named step trackbar
* @param useV2Style Whether to use V2 visual style (label + value instead of icon)
* @param label Label text for V2 style
* @param units Units text for V2 style
*/
StepTrackBar(const char icon[3], size_t numSteps, bool usingNamedStepTrackbar = false,
bool useV2Style = false, const std::string& label = "", const std::string& units = "",
bool unlockedTrackbar = true)
: TrackBar(icon, true, usingNamedStepTrackbar, useV2Style, label, units, unlockedTrackbar), m_numSteps(numSteps) {}
virtual ~StepTrackBar() {}
virtual bool handleInput(u64 keysDown, u64 keysHeld, const HidTouchState &touchPos, HidAnalogStickState leftJoyStick, HidAnalogStickState rightJoyStick) override {
const u64 keysReleased = m_prevKeysHeld & ~keysHeld;
m_prevKeysHeld = keysHeld;
const u64 currentTime_ns = ult::nowNs();
static u64 lastUpdate_ns = currentTime_ns;
const u64 elapsed_ns = currentTime_ns - lastUpdate_ns;
// KEY_R + directional: shake highlight
if (keysHeld & KEY_R) {
if (keysDown & KEY_UP && !(keysHeld & ~KEY_UP & ~KEY_R & ALL_KEYS_MASK))
this->shakeHighlight(FocusDirection::Up);
else if (keysDown & KEY_DOWN && !(keysHeld & ~KEY_DOWN & ~KEY_R & ALL_KEYS_MASK))
this->shakeHighlight(FocusDirection::Down);
else if (keysDown & KEY_LEFT && !(keysHeld & ~KEY_LEFT & ~KEY_R & ALL_KEYS_MASK))
this->shakeHighlight(FocusDirection::Left);
else if (keysDown & KEY_RIGHT && !(keysHeld & ~KEY_RIGHT & ~KEY_R & ALL_KEYS_MASK))
this->shakeHighlight(FocusDirection::Right);
return true;
}
// KEY_A: lock/unlock toggle (when locked), or click animation (when unlocked)
if ((keysDown & KEY_A) && !(keysHeld & ~KEY_A & ALL_KEYS_MASK)) {
if (!m_unlockedTrackbar) {
ult::atomicToggle(ult::allowSlide);
m_holding = false;
if (ult::allowSlide.load(std::memory_order_acquire)) {
// Unlocking: rumble + on sound only, no click animation, no enter feedback
triggerRumbleClick.store(true, std::memory_order_release);
triggerOnSound.store(true, std::memory_order_release);
} else {
// Locking: rumble + off sound only, no click animation
triggerRumbleClick.store(true, std::memory_order_release);
triggerOffSound.store(true, std::memory_order_release);
}
} else {
// Always-unlocked trackbar: full click animation + enter feedback
this->triggerClickAnimation();
triggerEnterFeedback();
}
return true;
}
// Guard all movement behind lock state
if (!m_unlockedTrackbar && !ult::allowSlide.load(std::memory_order_acquire)) {
return false;
}
// Calculate actual max value based on steps
const int stepSize = 100 / (this->m_numSteps - 1);
const int maxValue = stepSize * (this->m_numSteps - 1);
// Handle key release
if ((keysReleased & KEY_LEFT) || (keysReleased & KEY_RIGHT)) {
if (m_wasLastHeld) {
m_wasLastHeld = false;
m_holding = false;
lastUpdate_ns = currentTime_ns;
return true;
} else if (m_holding) {
m_holding = false;
lastUpdate_ns = currentTime_ns;
return true;
}
}
// Ignore simultaneous left+right
if (keysHeld & KEY_LEFT && keysHeld & KEY_RIGHT)
return true;
// Handle initial key press
if (keysDown & KEY_LEFT || keysDown & KEY_RIGHT) {
m_holding = true;
m_wasLastHeld = false;
m_holdStartTime_ns = currentTime_ns;
lastUpdate_ns = currentTime_ns;
if (keysDown & KEY_LEFT && this->m_value > 0) {
triggerNavigationFeedback();
this->m_value = std::max(this->m_value - stepSize, 0);
this->m_valueChangedListener(this->getProgress());
} else if (keysDown & KEY_RIGHT && this->m_value < maxValue) {
triggerNavigationFeedback();
this->m_value = std::min(this->m_value + stepSize, maxValue);
this->m_valueChangedListener(this->getProgress());
}
return true;
}
// Handle continued holding with acceleration
if (m_holding && ((keysHeld & KEY_LEFT) || (keysHeld & KEY_RIGHT))) {
const u64 holdDuration_ns = currentTime_ns - m_holdStartTime_ns;
static constexpr u64 initialDelay_ns = 300000000ULL;
static constexpr u64 initialInterval_ns = 67000000ULL;
static constexpr u64 shortInterval_ns = 10000000ULL;
static constexpr u64 transitionPoint_ns = 1000000000ULL;
if (holdDuration_ns < initialDelay_ns) {
return true;
}
const u64 holdDurationAfterDelay_ns = holdDuration_ns - initialDelay_ns;
const float t = std::min(1.0f, static_cast<float>(holdDurationAfterDelay_ns) / static_cast<float>(transitionPoint_ns));
const u64 currentInterval_ns = static_cast<u64>((initialInterval_ns - shortInterval_ns) * (1.0f - t) + shortInterval_ns);
if (elapsed_ns >= currentInterval_ns) {
if (keysHeld & KEY_LEFT && this->m_value > 0) {
triggerNavigationFeedback();
this->m_value = std::max(this->m_value - stepSize, 0);
this->m_valueChangedListener(this->getProgress());
lastUpdate_ns = currentTime_ns;
m_wasLastHeld = true;
return true;
}
if (keysHeld & KEY_RIGHT && this->m_value < maxValue) {
triggerNavigationFeedback();
this->m_value = std::min(this->m_value + stepSize, maxValue);
this->m_valueChangedListener(this->getProgress());
lastUpdate_ns = currentTime_ns;
m_wasLastHeld = true;
return true;
}
}
} else {
m_holding = false;
}
return false;
}
virtual bool onTouch(TouchEvent event, s32 currX, s32 currY, s32 prevX, s32 prevY, s32 initialX, s32 initialY) override {
const int stepSize = 100 / (this->m_numSteps - 1);
const int maxValue = stepSize * (this->m_numSteps - 1);
s32 trackBarLeft = this->getX() + 59;
s32 width = this->getWidth() - 95;
if (m_icon[0] != '\0') {
const s32 iconOffset = 14 + 23;
trackBarLeft += iconOffset;
width -= iconOffset;
}
const s32 trackBarRight = trackBarLeft + width;
const u16 handlePos = (width * this->m_value) / maxValue;
const s32 circleCenterX = trackBarLeft + handlePos;
const s32 circleCenterY = this->getY() + 40 + 16 - 3 - ((!m_usingNamedStepTrackbar && !m_useV2Style) ? 11 : 0);
static constexpr s32 circleRadius = 16;
static bool triggerOnce = true;
const bool touchInCircle = (std::abs(currX - circleCenterX) <= circleRadius) && (std::abs(currY - circleCenterY) <= circleRadius);
const bool currentlyInHorizontalBounds = (currX >= trackBarLeft && currX <= trackBarRight);
if (event == TouchEvent::Release) {
triggerOnce = true;
if (touchInSliderBounds) {
triggerRumbleDoubleClick.store(true, std::memory_order_release);
triggerOffSound.store(true, std::memory_order_release);
tsl::shiftItemFocus(this);
}
touchInSliderBounds = false;
return false;
}
if (touchInCircle || touchInSliderBounds) {
if (touchInSliderBounds && !currentlyInHorizontalBounds) {
if (currX > trackBarRight) {
this->m_value = maxValue;
} else if (currX < trackBarLeft) {
this->m_value = 0;
}
this->m_valueChangedListener(this->getProgress());
touchInSliderBounds = false;
return false;
}
if (currentlyInHorizontalBounds) {
if (triggerOnce) {
triggerOnce = false;
triggerRumbleClick.store(true, std::memory_order_release);
triggerOnSound.store(true, std::memory_order_release);
}
touchInSliderBounds = true;
float rawValue = (static_cast<float>(currX - trackBarLeft) / static_cast<float>(width)) * maxValue;
s16 newValue;
if (rawValue < 0) {
newValue = 0;
} else if (rawValue > maxValue) {
newValue = maxValue;
} else {
newValue = std::round(rawValue / stepSize) * stepSize;
newValue = std::min(std::max(newValue, s16(0)), s16(maxValue));
}
if (newValue != this->m_value) {
triggerNavigationFeedback();
this->m_value = newValue;
this->m_valueChangedListener(this->getProgress());
}
return true;
}
}
return false;
}
/**
* @brief Gets the current value of the trackbar
*
* @return State
*/
virtual u8 getProgress() override {
return this->m_value / (100 / (this->m_numSteps - 1));
}
/**
* @brief Sets the current state of the toggle. Updates the Value
*
* @param state State
*/
virtual void setProgress(u8 value) override {
value = std::min(value, u8(this->m_numSteps - 1));
this->m_value = value * (100 / (this->m_numSteps - 1));
}
protected:
u8 m_numSteps = 1;
};
/**
* @brief A customizable trackbar with multiple discrete steps with specific names. Name gets displayed above the bar
*
*/
class NamedStepTrackBar : public StepTrackBar {
public:
NamedStepTrackBar(const char icon[3], std::initializer_list<std::string> stepDescriptions,
bool useV2Style = false, const std::string& label = "", bool unlockedTrackbar = true)
: StepTrackBar(icon, stepDescriptions.size(), true, useV2Style, label, "", unlockedTrackbar),
m_stepDescriptions(stepDescriptions.begin(), stepDescriptions.end()) {
this->m_usingNamedStepTrackbar = true;
m_numSteps = m_stepDescriptions.size();
if (!m_stepDescriptions.empty()) {
this->m_selection = m_stepDescriptions[0];
}
}
virtual ~NamedStepTrackBar() {}
virtual bool handleInput(u64 keysDown, u64 keysHeld, const HidTouchState &touchPos,
HidAnalogStickState leftJoyStick, HidAnalogStickState rightJoyStick) override {
const u8 prevProgress = this->getProgress();
const bool result = StepTrackBar::handleInput(keysDown, keysHeld, touchPos, leftJoyStick, rightJoyStick);
if (this->getProgress() != prevProgress) {
const u8 currentIndex = this->getProgress();
if (currentIndex < m_stepDescriptions.size()) {
this->m_selection = m_stepDescriptions[currentIndex];
}
}
return result;
}
virtual bool onTouch(TouchEvent event, s32 currX, s32 currY, s32 prevX, s32 prevY,
s32 initialX, s32 initialY) override {
const u8 prevProgress = this->getProgress();
const bool result = StepTrackBar::onTouch(event, currX, currY, prevX, prevY, initialX, initialY);
if (result && this->getProgress() != prevProgress) {
const u8 currentIndex = this->getProgress();
if (currentIndex < m_stepDescriptions.size()) {
this->m_selection = m_stepDescriptions[currentIndex];
}
}
return result;
}
virtual void setProgress(u8 value) override {
StepTrackBar::setProgress(value);
const u8 currentIndex = this->getProgress();
if (currentIndex < m_stepDescriptions.size()) {
this->m_selection = m_stepDescriptions[currentIndex];
}
}
const std::string& getSelection() const {
return this->m_selection;
}
virtual void draw(gfx::Renderer *renderer) override {
if (touchInSliderBounds) {
m_drawFrameless = true;
drawHighlight(renderer);
} else {
m_drawFrameless = false;
}
s32 xPos = this->getX() + 59;
s32 yPos = this->getY() + 40 + 16 - 3;
s32 width = this->getWidth() - 95;
const int maxValue = (100 / (this->m_numSteps - 1)) * (this->m_numSteps - 1);
u16 handlePos = width * (this->m_value) / maxValue;
s32 iconOffset = 0;
if (m_icon[0] != '\0') {
s32 iconWidth = 23;
iconOffset = 14 + iconWidth;
xPos += iconOffset;
width -= iconOffset;
handlePos = (width) * (this->m_value) / (100);
}
// Draw step tick marks
const u8 numSteps = m_numSteps;
const u16 baseX = xPos;
const u16 baseY = this->getY() + 44;
const u8 halfNumSteps = (numSteps - 1) / 2;
const u16 lastStepX = baseX + width - 1;
const float stepSpacing = static_cast<float>(width) / (numSteps - 1);
const auto stepColor = a(trackBarEmptyColor);
u16 stepX;
for (u8 i = 0; i < numSteps; i++) {
if (i == numSteps - 1) {
stepX = lastStepX;
} else {
stepX = baseX + static_cast<u16>(std::round(i * stepSpacing));
if (i > halfNumSteps) {
stepX -= 1;
}
}
renderer->drawRect(stepX, baseY, 1, 8, stepColor);
}
// Draw track bar background
drawBar(renderer, xPos, yPos-3, width, trackBarEmptyColor, false);
const bool isEffectivelyUnlocked = m_unlockedTrackbar || ult::allowSlide.load(std::memory_order_acquire);
if (!this->m_focused) {
drawBar(renderer, xPos, yPos-3, handlePos, trackBarFullColor, false);
renderer->drawCircle(xPos + handlePos, yPos, 16, true, a(m_drawFrameless ? s_highlightColor : trackBarSliderBorderColor));
renderer->drawCircle(xPos + handlePos, yPos, 13, true, a((isEffectivelyUnlocked || touchInSliderBounds) ? trackBarSliderMalleableColor : trackBarSliderColor));
} else {
touchInSliderBounds = false;
if (m_unlockedTrackbar != ult::unlockedSlide.load(std::memory_order_acquire))
ult::unlockedSlide.store(m_unlockedTrackbar, std::memory_order_release);
drawBar(renderer, xPos, yPos-3, handlePos, trackBarFullColor, false);
renderer->drawCircle(xPos + x + handlePos, yPos +y, 16, true, a(s_highlightColor));
renderer->drawCircle(xPos + x + handlePos, yPos +y, 12, true, a(isEffectivelyUnlocked ? trackBarSliderMalleableColor : trackBarSliderColor));
}
if (m_useV2Style) {
std::string labelPart = this->m_label;
ult::removeTag(labelPart);
std::string valuePart = this->m_selection;
const auto valueWidth = renderer->getTextDimensions(valuePart, false, 16).first;
const s32 labelX = xPos;
const s32 valueX = xPos + width - valueWidth;
renderer->drawString(labelPart, false, labelX, this->getY() + 14 + 16, 16,
((!this->m_focused || !ult::useSelectionText) ? defaultTextColor : selectedTextColor));
renderer->drawString(valuePart, false, valueX, this->getY() + 14 + 16, 16,
(this->m_focused && ult::useSelectionValue) ? selectedValueTextColor : onTextColor);
if (m_icon[0] != '\0')
renderer->drawString(this->m_icon, false, this->getX()+42, this->getY() + 50+2+2, 30, tsl::style::color::ColorText);
} else {
const auto textDimensions = renderer->getTextDimensions(this->m_selection, false, 16);
const s32 textWidth = textDimensions.first;
const s32 textX = xPos + (width / 2) - (textWidth / 2);
const s32 textY = this->getY() + 14 + 16;
renderer->drawString(this->m_selection.c_str(), false, textX, textY, 16,
a(this->m_focused ? tsl::style::color::ColorHighlight : tsl::style::color::ColorText));
if (m_icon[0] != '\0')
renderer->drawString(this->m_icon, false, this->getX()+42, this->getY() + 50+2+2, 30, tsl::style::color::ColorText);
}
// Draw separators
if (m_lastBottomBound != this->getTopBound())
renderer->drawRect(this->getX() + 4+20-1, this->getTopBound(), this->getWidth() + 6 + 10+20 +4, 1, a(separatorColor));
renderer->drawRect(this->getX() + 4+20-1, this->getBottomBound(), this->getWidth() + 6 + 10+20 +4, 1, a(separatorColor));
m_lastBottomBound = this->getBottomBound();
}
protected:
std::vector<std::string> m_stepDescriptions;
};
/**
* @brief A customizable analog trackbar going from minValue to maxValue
*
*/
class TrackBarV2 : public Element {
public:
using SimpleValueChangeCallback = std::function<void(s16 value, s16 index)>;
u64 lastUpdate_ns;
Color highlightColor = {0xf, 0xf, 0xf, 0xf};
float progress;
float counter = 0.0;
s32 x, y;
s32 amplitude;
u32 descWidth, descHeight;
void setScriptKeyListener(std::function<void()> listener) {
m_scriptKeyListener = std::move(listener);
}
TrackBarV2(std::string label, std::string packagePath = "", s16 minValue = 0, s16 maxValue = 100, std::string units = "",
std::function<bool(std::vector<std::vector<std::string>>&&, const std::string&, const std::string&)> executeCommands = nullptr,
std::function<std::vector<std::vector<std::string>>(const std::vector<std::vector<std::string>>&, const std::string&, size_t, const std::string&)> sourceReplacementFunc = nullptr,
std::vector<std::vector<std::string>> cmd = {}, const std::string& selCmd = "", bool usingStepTrackbar = false, bool usingNamedStepTrackbar = false, s16 numSteps = -1, bool unlockedTrackbar = false, bool executeOnEveryTick = false)
: m_label(label), m_packagePath(packagePath), m_minValue(minValue), m_maxValue(maxValue), m_units(units),
interpretAndExecuteCommands(executeCommands), getSourceReplacement(sourceReplacementFunc), commands(std::move(cmd)), selectedCommand(selCmd),
m_usingStepTrackbar(usingStepTrackbar), m_usingNamedStepTrackbar(usingNamedStepTrackbar), m_numSteps(numSteps), m_unlockedTrackbar(unlockedTrackbar), m_executeOnEveryTick(executeOnEveryTick) {
m_isItem = true;
if (maxValue < minValue) {
std::swap(minValue, maxValue);
m_minValue = minValue;
m_maxValue = maxValue;
}
if ((!usingStepTrackbar && !usingNamedStepTrackbar) || numSteps == -1) {
m_numSteps = (maxValue - minValue) + 1;
}
if (m_numSteps < 2) {
m_numSteps = 2;
}
bool loadedValue = false;
if (!m_packagePath.empty()) {
auto configIniData = ult::getParsedDataFromIniFile(m_packagePath + "config.ini");
auto sectionIt = configIniData.find(m_label);
if (sectionIt != configIniData.end()) {
auto indexIt = sectionIt->second.find("index");
if (indexIt != sectionIt->second.end() && !indexIt->second.empty()) {
m_index = static_cast<s16>(ult::stoi(indexIt->second));
}
if (!m_usingNamedStepTrackbar) {
auto valueIt = sectionIt->second.find("value");
if (valueIt != sectionIt->second.end() && !valueIt->second.empty()) {
m_value = static_cast<s16>(ult::stoi(valueIt->second));
loadedValue = true;
}
}
}
}
if (m_index >= m_numSteps) m_index = m_numSteps - 1;
if (m_index < 0) m_index = 0;
if (!loadedValue) {
if (m_numSteps > 1) {
m_value = minValue + m_index * (static_cast<float>(maxValue - minValue) / (m_numSteps - 1));
} else {
m_value = minValue;
}
}
if (m_value > maxValue) m_value = maxValue;
if (m_value < minValue) m_value = minValue;
lastUpdate_ns = ult::nowNs();
}
virtual ~TrackBarV2() {}
virtual Element* requestFocus(Element *oldFocus, FocusDirection direction) {
return this;
}
inline void updateAndExecute(bool updateIni = true) {
if (m_simpleCallback) {
m_simpleCallback(m_value, m_index);
return;
}
if (m_packagePath.empty()) {
return;
}
const std::string indexStr = ult::to_string(m_index);
const std::string valueStr = m_usingNamedStepTrackbar ? m_selection : ult::to_string(m_value);
if (updateIni) {
const std::string configPath = m_packagePath + "config.ini";
ult::setIniFileValue(configPath, m_label, "index", indexStr);
ult::setIniFileValue(configPath, m_label, "value", valueStr);
}
bool success = false;
static const std::string valuePlaceholder = "{value}";
static const std::string indexPlaceholder = "{index}";
static const size_t valuePlaceholderLen = valuePlaceholder.length();
static const size_t indexPlaceholderLen = indexPlaceholder.length();
const size_t valueStrLen = valueStr.length();
const size_t indexStrLen = indexStr.length();
size_t tryCount = 0;
while (!success) {
if (interpretAndExecuteCommands) {
if (tryCount > 3)
break;
auto modifiedCmds = getSourceReplacement(commands, valueStr, m_index, m_packagePath);
for (auto& cmd : modifiedCmds) {
for (auto& arg : cmd) {
for (size_t pos = 0; (pos = arg.find(valuePlaceholder, pos)) != std::string::npos; pos += valueStrLen) {
arg.replace(pos, valuePlaceholderLen, valueStr);
}
if (m_usingNamedStepTrackbar) {
for (size_t pos = 0; (pos = arg.find(indexPlaceholder, pos)) != std::string::npos; pos += indexStrLen) {
arg.replace(pos, indexPlaceholderLen, indexStr);
}
}
}
}
success = interpretAndExecuteCommands(std::move(modifiedCmds), m_packagePath, selectedCommand);
ult::resetPercentages();
if (success)
break;
tryCount++;
}
}
}
virtual bool handleInput(u64 keysDown, u64 keysHeld, const HidTouchState &touchPos, HidAnalogStickState leftJoyStick, HidAnalogStickState rightJoyStick) override {
const u64 keysReleased = m_prevKeysHeld & ~keysHeld;
m_prevKeysHeld = keysHeld;
const u64 currentTime_ns = ult::nowNs();
const u64 elapsed_ns = currentTime_ns - lastUpdate_ns;
m_keyRHeld = (keysHeld & KEY_R) != 0;
if ((keysHeld & KEY_R)) {
if (keysDown & KEY_UP && !(keysHeld & ~KEY_UP & ~KEY_R & ALL_KEYS_MASK))
this->shakeHighlight(FocusDirection::Up);
else if (keysDown & KEY_DOWN && !(keysHeld & ~KEY_DOWN & ~KEY_R & ALL_KEYS_MASK))
this->shakeHighlight(FocusDirection::Down);
else if (keysDown & KEY_LEFT && !(keysHeld & ~KEY_LEFT & ~KEY_R & ALL_KEYS_MASK)){
this->shakeHighlight(FocusDirection::Left);
}
else if (keysDown & KEY_RIGHT && !(keysHeld & ~KEY_RIGHT & ~KEY_R & ALL_KEYS_MASK)) {
this->shakeHighlight(FocusDirection::Right);
}
return true;
}
if ((keysDown & KEY_A) && !(keysHeld & ~KEY_A & ALL_KEYS_MASK)) {
if (!m_unlockedTrackbar) {
ult::atomicToggle(ult::allowSlide);
m_holding = false;
if (ult::allowSlide.load(std::memory_order_acquire)) {
triggerRumbleClick.store(true, std::memory_order_release);
triggerOnSound.store(true, std::memory_order_release);
}
}
if (m_unlockedTrackbar || (!m_unlockedTrackbar && !ult::allowSlide.load(std::memory_order_acquire))) {
// Only trigger click animation when unlocked
if (m_unlockedTrackbar || ult::allowSlide.load(std::memory_order_acquire)) {
triggerClick = true;
triggerEnterFeedback();
} else if (!m_unlockedTrackbar && !ult::allowSlide.load(std::memory_order_acquire)) {
triggerRumbleClick.store(true, std::memory_order_release);
triggerOffSound.store(true, std::memory_order_release);
}
updateAndExecute();
}
return true;
}
if ((keysDown & SCRIPT_KEY) && !(keysHeld & ~SCRIPT_KEY & ALL_KEYS_MASK)) {
if (m_scriptKeyListener) {
m_scriptKeyListener();
}
return true;
}
if (ult::allowSlide.load(std::memory_order_acquire) || m_unlockedTrackbar) {
static s16 lastHapticSegment = -1;
// Handle key release
if (((keysReleased & KEY_LEFT) || (keysReleased & KEY_RIGHT))) {
lastHapticSegment = -1; // Reset for next interaction
// If we were holding and repeating, just stop
if (m_wasLastHeld) {
m_wasLastHeld = false;
m_holding = false;
updateAndExecute();
lastUpdate_ns = ult::nowNs();
return true;
}
// If it was a quick tap (no repeat happened), handle the single tick
else if (m_holding) {
m_holding = false;
updateAndExecute();
lastUpdate_ns = ult::nowNs();
return true;
}
}
// Ignore simultaneous left+right
if (keysDown & KEY_LEFT && keysDown & KEY_RIGHT)
return true;
if (keysHeld & KEY_LEFT && keysHeld & KEY_RIGHT)
return true;
// Handle initial key press
if (keysDown & KEY_LEFT || keysDown & KEY_RIGHT) {
triggerRumbleClick.store(true, std::memory_order_release);
// Start tracking the hold
m_holding = true;
m_wasLastHeld = false;
m_holdStartTime_ns = ult::nowNs();
lastUpdate_ns = currentTime_ns;
// Perform the initial single tick
if (keysDown & KEY_LEFT && this->m_value > m_minValue) {
this->m_index--;
this->m_value--;
this->m_valueChangedListener(this->m_value);
updateAndExecute(false);
// Calculate and store initial segment (0-10 for 11 segments)
const s16 currentSegment = (this->m_index * 10) / (m_numSteps - 1);
if (this->m_index == 0 || currentSegment != lastHapticSegment) {
lastHapticSegment = currentSegment;
triggerNavigationFeedback();
}
} else if (keysDown & KEY_RIGHT && this->m_value < m_maxValue) {
this->m_index++;
this->m_value++;
this->m_valueChangedListener(this->m_value);
updateAndExecute(false);
// Calculate and store initial segment (0-10 for 11 segments)
const s16 currentSegment = (this->m_index * 10) / (m_numSteps - 1);
if (this->m_index == 0 || currentSegment != lastHapticSegment) {
lastHapticSegment = currentSegment;
triggerNavigationFeedback();
}
}
return true;
}
// Handle continued holding (after initial press)
if (m_holding && ((keysHeld & KEY_LEFT) || (keysHeld & KEY_RIGHT))) {
const u64 holdDuration_ns = currentTime_ns - m_holdStartTime_ns;
// Initial delay before repeating starts (e.g., 300ms)
static constexpr u64 initialDelay_ns = 300000000ULL;
// Calculate interval with acceleration
static constexpr u64 initialInterval_ns = 67000000ULL; // ~67ms
static constexpr u64 shortInterval_ns = 10000000ULL; // ~10ms
static constexpr u64 transitionPoint_ns = 1000000000ULL; // 1 second
// If we haven't passed the initial delay, don't repeat yet
if (holdDuration_ns < initialDelay_ns) {
return true;
}
const u64 holdDurationAfterDelay_ns = holdDuration_ns - initialDelay_ns;
const float t = std::min(1.0f, static_cast<float>(holdDurationAfterDelay_ns) / static_cast<float>(transitionPoint_ns));
const u64 currentInterval_ns = static_cast<u64>((initialInterval_ns - shortInterval_ns) * (1.0f - t) + shortInterval_ns);
if (elapsed_ns >= currentInterval_ns) {
if (keysHeld & KEY_LEFT && this->m_value > m_minValue) {
this->m_index--;
this->m_value--;
this->m_valueChangedListener(this->m_value);
if (m_executeOnEveryTick) {
updateAndExecute(false);
}
// Calculate current segment (0-10 for 11 segments) and trigger haptics on segment change
const s16 currentSegment = (this->m_index * 10) / (m_numSteps - 1);
if (this->m_index == 0 || currentSegment != lastHapticSegment) {
lastHapticSegment = currentSegment;
triggerNavigationFeedback();
}
lastUpdate_ns = currentTime_ns;
m_wasLastHeld = true;
return true;
}
if (keysHeld & KEY_RIGHT && this->m_value < m_maxValue) {
this->m_index++;
this->m_value++;
this->m_valueChangedListener(this->m_value);
if (m_executeOnEveryTick) {
updateAndExecute(false);
}
// Calculate current segment (0-10 for 11 segments) and trigger haptics on segment change
const s16 currentSegment = (this->m_index * 10) / (m_numSteps - 1);
if (this->m_index == 0 || currentSegment != lastHapticSegment) {
lastHapticSegment = currentSegment;
triggerNavigationFeedback();
}
lastUpdate_ns = currentTime_ns;
m_wasLastHeld = true;
return true;
}
}
} else {
m_holding = false;
}
}
return false;
}
virtual bool onTouch(TouchEvent event, s32 currX, s32 currY, s32 prevX, s32 prevY, s32 initialX, s32 initialY) override {
const u16 trackBarWidth = this->getWidth() - 95;
const u16 handlePos = (trackBarWidth * (this->m_value - m_minValue)) / (m_maxValue - m_minValue);
const s32 circleCenterX = this->getX() + 59 + handlePos;
const s32 circleCenterY = this->getY() + 40 + 16 - 1;
static constexpr s32 circleRadius = 16;
static bool triggerOnce = true;
static s16 lastHapticSegment = -1;
static bool wasOriginallyLocked = false;
const bool touchInCircle = (std::abs(initialX - circleCenterX) <= circleRadius) && (std::abs(initialY - circleCenterY) <= circleRadius);
// CRITICAL FIX: Check if current touch is within valid horizontal bounds
// Allow vertical drift (top/bottom), only care about left/right bounds
const s32 trackBarLeft = this->getX() + 59;
const s32 trackBarRight = trackBarLeft + trackBarWidth;
const bool currentlyInHorizontalBounds = (currX >= trackBarLeft && currX <= trackBarRight);
// Handle touch start
if (event == TouchEvent::Touch && touchInCircle) {
// Remember if it was locked before we touched it
wasOriginallyLocked = !m_unlockedTrackbar && !ult::allowSlide.load(std::memory_order_acquire);
// Temporarily unlock if it was locked
if (wasOriginallyLocked) {
ult::allowSlide.store(true, std::memory_order_release);
}
}
// Handle release
if (event == TouchEvent::Release) {
triggerOnce = true;
lastHapticSegment = -1;
// Re-lock if it was originally locked
if (wasOriginallyLocked) {
ult::allowSlide.store(false, std::memory_order_release);
wasOriginallyLocked = false;
}
if (touchInSliderBounds) {
updateAndExecute();
touchInSliderBounds = false;
triggerRumbleDoubleClick.store(true, std::memory_order_release);
triggerOffSound.store(true, std::memory_order_release);
tsl::shiftItemFocus(this);
}
return false;
}
const bool isUnlocked = m_unlockedTrackbar || ult::allowSlide.load(std::memory_order_acquire);
// CRITICAL FIX: Only process touch if we're in bounds OR if we were already interacting
// When going out of horizontal bounds, clamp to min/max value before stopping
if ((touchInCircle || touchInSliderBounds) && isUnlocked) {
// If we were touching but now went out of horizontal bounds, clamp to edge value then stop
if (touchInSliderBounds && !currentlyInHorizontalBounds) {
// Clamp to max if past right edge, min if past left edge
if (currX > trackBarRight) {
this->m_value = m_maxValue;
this->m_index = m_numSteps - 1;
} else if (currX < trackBarLeft) {
this->m_value = m_minValue;
this->m_index = 0;
}
this->m_valueChangedListener(this->getProgress());
if (m_executeOnEveryTick) {
updateAndExecute(false);
}
touchInSliderBounds = false;
return false;
}
// We're in valid horizontal bounds, continue interaction
if (currentlyInHorizontalBounds) {
touchInSliderBounds = true;
if (triggerOnce) {
triggerOnce = false;
triggerRumbleClick.store(true, std::memory_order_release);
triggerOnSound.store(true, std::memory_order_release);
}
// Add 0.5 to round to nearest step instead of truncating
const s16 newIndex = std::max(static_cast<s16>(0), std::min(static_cast<s16>((currX - trackBarLeft) / static_cast<float>(trackBarWidth) * (m_numSteps - 1) + 0.5f), static_cast<s16>(m_numSteps - 1)));
const s16 newValue = m_minValue + newIndex * (static_cast<float>(m_maxValue - m_minValue) / (m_numSteps - 1));
if (newValue != this->m_value || newIndex != this->m_index) {
this->m_value = newValue;
this->m_index = newIndex;
this->m_valueChangedListener(this->getProgress());
if (m_executeOnEveryTick) {
updateAndExecute(false);
}
// Calculate which 10% segment we're in (0-10 for 11 segments)
const s16 currentSegment = (newIndex * 10) / (m_numSteps - 1);
// Trigger haptics when crossing into a new 10% segment OR at index 0
if (newIndex == 0 || currentSegment != lastHapticSegment) {
lastHapticSegment = currentSegment;
triggerNavigationFeedback();
}
}
return true;
}
}
return false;
}
void drawBar(gfx::Renderer *renderer, s32 x, s32 y, u16 width, Color& color, bool isRounded = true) {
if (isRounded) {
renderer->drawUniformRoundedRect(x, y, width, 7, a(color));
} else {
renderer->drawRect(x, y, width, 7, a(color));
}
}
virtual void draw(gfx::Renderer *renderer) override {
const u16 handlePos = (this->getWidth() - 95) * (this->m_value - m_minValue) / (m_maxValue - m_minValue);
const s32 xPos = this->getX() + 59;
const s32 yPos = this->getY() + 40 + 16 - 3;
const s32 width = this->getWidth() - 95;
const bool shouldAppearLocked = m_unlockedTrackbar && m_keyRHeld;
const bool visuallyUnlocked = (m_unlockedTrackbar && !m_keyRHeld) || touchInSliderBounds;
if (visuallyUnlocked && touchInSliderBounds) {
m_drawFrameless = true;
drawHighlight(renderer);
} else {
m_drawFrameless = false;
}
drawBar(renderer, xPos, yPos-3, width, trackBarEmptyColor, !m_usingNamedStepTrackbar);
if (!this->m_focused) {
drawBar(renderer, xPos, yPos-3, handlePos, trackBarFullColor, !m_usingNamedStepTrackbar);
renderer->drawCircle(xPos + handlePos, yPos, 16, true, a(!m_drawFrameless ? trackBarSliderBorderColor : highlightColor));
renderer->drawCircle(xPos + handlePos, yPos, 13, true, a(visuallyUnlocked ? trackBarSliderMalleableColor : trackBarSliderColor));
} else {
touchInSliderBounds = false;
if (m_unlockedTrackbar != ult::unlockedSlide.load(std::memory_order_acquire))
ult::unlockedSlide.store(m_unlockedTrackbar, std::memory_order_release);
drawBar(renderer, xPos, yPos-3, handlePos, trackBarFullColor, !m_usingNamedStepTrackbar);
renderer->drawCircle(xPos + x + handlePos, yPos +y, 16, true, a(highlightColor));
const bool focusedVisuallyUnlocked = (ult::allowSlide.load(std::memory_order_acquire) || m_unlockedTrackbar) && !shouldAppearLocked;
renderer->drawCircle(xPos + x + handlePos, yPos +y, 12, true, a(focusedVisuallyUnlocked ? trackBarSliderMalleableColor : trackBarSliderColor));
}
std::string labelPart = this->m_label;
ult::removeTag(labelPart);
if (!m_usingNamedStepTrackbar) {
m_valuePart = (this->m_units.compare("%") == 0 || this->m_units.compare("°C") == 0 || this->m_units.compare("°F") == 0)
? ult::to_string(this->m_value) + this->m_units
: ult::to_string(this->m_value) + (this->m_units.empty() ? "" : " ") + this->m_units;
} else
m_valuePart = this->m_selection;
const auto valueWidth = renderer->getTextDimensions(m_valuePart, false, 16).first;
renderer->drawString(labelPart, false, xPos, this->getY() + 14 + 16, 16, (!this->m_focused || !ult::useSelectionText) ? defaultTextColor : selectedTextColor);
renderer->drawString(m_valuePart, false, this->getWidth() -17 - valueWidth, this->getY() + 14 + 16, 16,
(this->m_focused && ult::useSelectionValue) ? selectedValueTextColor : onTextColor);
if (m_lastBottomBound != this->getTopBound())
renderer->drawRect(this->getX() + 4+20-1, this->getTopBound(), this->getWidth() + 6 + 10+20 +4, 1, a(separatorColor));
renderer->drawRect(this->getX() + 4+20-1, this->getBottomBound(), this->getWidth() + 6 + 10+20 +4, 1, a(separatorColor));
m_lastBottomBound = this->getBottomBound();
}
virtual void layout(u16 parentX, u16 parentY, u16 parentWidth, u16 parentHeight) override {
this->setBoundaries(this->getX() - 16 , this->getY(), this->getWidth()+20+4, tsl::style::TrackBarDefaultHeight );
}
virtual void drawFocusBackground(gfx::Renderer *renderer) {
}
virtual void drawHighlight(gfx::Renderer *renderer) override {
const u64 currentTime_ns = ult::nowNs();
const double timeInSeconds = static_cast<double>(currentTime_ns) / 1000000000.0;
progress = ((ult::cos(2.0 * ult::_M_PI * std::fmod(timeInSeconds, 1.0) - ult::_M_PI / 2) + 1.0) / 2.0);
Color clickColor1 = highlightColor1;
Color clickColor2 = clickColor;
if (triggerClick && !m_clickActive) {
m_clickStartTime_ns = currentTime_ns;
m_clickActive = true;
if (progress >= 0.5) {
clickColor1 = clickColor;
clickColor2 = highlightColor2;
}
}
if (m_lastLabel != m_label) {
m_clickActive = false;
triggerClick = false;
}
m_lastLabel = m_label;
if (m_clickActive) {
const u64 elapsedTime_ns = currentTime_ns - m_clickStartTime_ns;
if (elapsedTime_ns < 500000000ULL) {
highlightColor = lerpColor(clickColor1, clickColor2, progress);
} else {
m_clickActive = false;
triggerClick = false;
}
} else {
const bool shouldAppearLocked = m_unlockedTrackbar && m_keyRHeld;
if ((ult::allowSlide.load(std::memory_order_acquire) || m_unlockedTrackbar) && !shouldAppearLocked) {
highlightColor = lerpColor(highlightColor1, highlightColor2, progress);
} else {
highlightColor = lerpColor(highlightColor3, highlightColor4, progress);
}
}
x = 0;
y = 0;
if (this->m_highlightShaking) {
t_ns = currentTime_ns - this->m_highlightShakingStartTime;
const double t_ms = t_ns / 1000000.0;
static constexpr double SHAKE_DURATION_MS = 200.0;
if (t_ms >= SHAKE_DURATION_MS)
this->m_highlightShaking = false;
else {
// Generate random amplitude only once per shake using the start time as seed
const double amplitude = 6.0 + ((this->m_highlightShakingStartTime / 1000000) % 5);
const double progress = t_ms / SHAKE_DURATION_MS; // 0 to 1
// Lighter damping so both bounces are visible
const double damping = 1.0 / (1.0 + 2.5 * progress * (1.0 + 1.3 * progress));
// 2 full oscillations = 2 clear bounces
const double oscillation = ult::cos(ult::_M_PI * 4.0 * progress);
const double displacement = amplitude * oscillation * damping;
const int offset = static_cast<int>(displacement);
switch (this->m_highlightShakingDirection) {
case FocusDirection::Up: y = -offset; break;
case FocusDirection::Down: y = offset; break;
case FocusDirection::Left: x = -offset; break;
case FocusDirection::Right: x = offset; break;
default: break;
}
}
}
if (ult::useSelectionBG)
renderer->drawRectAdaptive(this->getX() + x +19, this->getY() + y, this->getWidth()-11-4, this->getHeight(), aWithOpacity(m_drawFrameless ? clickColor : selectionBGColor));
if (!m_drawFrameless)
renderer->drawBorderedRoundedRect(this->getX() + x +19, this->getY() + y, this->getWidth()-11, this->getHeight(), 5, 5, a(highlightColor));
ult::onTrackBar.store(true, std::memory_order_release);
if (m_clickActive && m_useClickAnimation) {
const u64 elapsedTime_ns = currentTime_ns - m_clickStartTime_ns;
auto clickAnimationProgress = tsl::style::ListItemHighlightLength * (1.0f - (static_cast<float>(elapsedTime_ns) / 500000000.0f));
if (clickAnimationProgress < 0.0f) {
clickAnimationProgress = 0.0f;
}
if (clickAnimationProgress > 0.0f) {
const u8 saturation = tsl::style::ListItemHighlightSaturation * (float(clickAnimationProgress) / float(tsl::style::ListItemHighlightLength));
Color animColor = {0xF, 0xF, 0xF, 0xF};
if (invertBGClickColor) {
animColor.r = 15 - saturation;
animColor.g = 15 - saturation;
animColor.b = 15 - saturation;
} else {
animColor.r = saturation;
animColor.g = saturation;
animColor.b = saturation;
}
animColor.a = selectionBGColor.a;
renderer->drawRect(this->getX() +22, this->getY(), this->getWidth() -22, this->getHeight(), aWithOpacity(animColor));
}
}
}
virtual u8 getIndex() {
return this->m_index;
}
virtual u8 getProgress() {
return this->m_value;
}
virtual void setProgress(u8 value) {
this->m_value = value;
}
void setValueChangedListener(std::function<void(u8)> valueChangedListener) {
this->m_valueChangedListener = valueChangedListener;
}
void setSimpleCallback(SimpleValueChangeCallback callback) {
m_simpleCallback = std::move(callback);
}
inline void disableClickAnimation() {
m_useClickAnimation = false;
}
protected:
std::string m_label;
std::string m_packagePath;
std::string m_selection;
s16 m_value = 0;
s16 m_minValue = 0;
s16 m_maxValue = 100;
std::string m_units;
bool m_interactionLocked = false;
bool m_keyRHeld = false;
std::function<void(u8)> m_valueChangedListener = [](u8) {};
std::function<bool(std::vector<std::vector<std::string>>&&, const std::string&, const std::string&)> interpretAndExecuteCommands;
std::function<std::vector<std::vector<std::string>>(const std::vector<std::vector<std::string>>&, const std::string&, size_t, const std::string&)> getSourceReplacement;
std::vector<std::vector<std::string>> commands;
std::string selectedCommand;
bool m_usingStepTrackbar = false;
bool m_usingNamedStepTrackbar = false;
s16 m_numSteps = 2;
s16 m_index = 0;
bool m_unlockedTrackbar = false;
bool m_executeOnEveryTick = false;
bool touchInSliderBounds = false;
bool triggerClick = false;
std::function<void()> m_scriptKeyListener;
// Instance variables replacing static ones
float m_lastBottomBound = 0.0f;
std::string m_valuePart = "";
u64 m_clickStartTime_ns = 0;
bool m_clickActive = false;
std::string m_lastLabel = "";
bool m_holding = false;
u64 m_holdStartTime_ns = 0;
u64 m_prevKeysHeld = 0;
bool m_wasLastHeld = false;
bool m_drawFrameless = false;
bool m_useClickAnimation = true;
SimpleValueChangeCallback m_simpleCallback = nullptr;
};
/**
* @brief A customizable analog trackbar going from 0% to 100% but using discrete steps (Like the volume slider)
*
*/
class StepTrackBarV2 : public TrackBarV2 {
public:
/**
* @brief Constructor
*
* @param icon Icon shown next to the track bar
* @param numSteps Number of steps the track bar has
*/
StepTrackBarV2(std::string label, std::string packagePath, size_t numSteps, s16 minValue, s16 maxValue, std::string units,
std::function<bool(std::vector<std::vector<std::string>>&&, const std::string&, const std::string&)> executeCommands = nullptr,
std::function<std::vector<std::vector<std::string>>(const std::vector<std::vector<std::string>>&, const std::string&, size_t, const std::string&)> sourceReplacementFunc = nullptr,
std::vector<std::vector<std::string>> cmd = {}, const std::string& selCmd = "", bool usingNamedStepTrackbar = false, bool unlockedTrackbar = false, bool executeOnEveryTick = false)
: TrackBarV2(label, packagePath, minValue, maxValue, units, executeCommands, sourceReplacementFunc, cmd, selCmd, !usingNamedStepTrackbar, usingNamedStepTrackbar, numSteps, unlockedTrackbar, executeOnEveryTick) {}
virtual ~StepTrackBarV2() {}
virtual bool handleInput(u64 keysDown, u64 keysHeld, const HidTouchState &touchPos, HidAnalogStickState leftJoyStick, HidAnalogStickState rightJoyStick) override {
static u32 tick = 0;
static bool holding = false;
static u64 prevKeysHeld = 0;
const u64 keysReleased = prevKeysHeld & ~keysHeld;
prevKeysHeld = keysHeld;
static bool wasLastHeld = false;
// Update KEY_R state for visual appearance
m_keyRHeld = (keysHeld & KEY_R) != 0;
if ((keysHeld & KEY_R)) {
if (keysDown & KEY_UP && !(keysHeld & ~KEY_UP & ~KEY_R & ALL_KEYS_MASK))
this->shakeHighlight(FocusDirection::Up);
else if (keysDown & KEY_DOWN && !(keysHeld & ~KEY_DOWN & ~KEY_R & ALL_KEYS_MASK))
this->shakeHighlight(FocusDirection::Down);
else if (keysDown & KEY_LEFT && !(keysHeld & ~KEY_LEFT & ~KEY_R & ALL_KEYS_MASK)){
this->shakeHighlight(FocusDirection::Left);
}
else if (keysDown & KEY_RIGHT && !(keysHeld & ~KEY_RIGHT & ~KEY_R & ALL_KEYS_MASK)) {
this->shakeHighlight(FocusDirection::Right);
}
return true;
}
// Check if KEY_A is pressed to toggle ult::allowSlide
if ((keysDown & KEY_A) && !(keysHeld & ~KEY_A & ALL_KEYS_MASK)) {
if (!m_unlockedTrackbar) {
ult::atomicToggle(ult::allowSlide);
m_holding = false;
if (ult::allowSlide.load(std::memory_order_acquire)) {
triggerRumbleClick.store(true, std::memory_order_release);
triggerOnSound.store(true, std::memory_order_release);
}
}
if (m_unlockedTrackbar || (!m_unlockedTrackbar && !ult::allowSlide.load(std::memory_order_acquire))) {
// Only trigger click animation when unlocked
if (m_unlockedTrackbar || ult::allowSlide.load(std::memory_order_acquire)) {
triggerClick = true;
triggerEnterFeedback();
} else if (!m_unlockedTrackbar && !ult::allowSlide.load(std::memory_order_acquire)) {
triggerRumbleClick.store(true, std::memory_order_release);
triggerOffSound.store(true, std::memory_order_release);
}
updateAndExecute();
}
return true;
}
// Handle SCRIPT_KEY press
if ((keysDown & SCRIPT_KEY) && !(keysHeld & ~SCRIPT_KEY & ALL_KEYS_MASK)) {
if (m_scriptKeyListener) {
m_scriptKeyListener();
}
return true;
}
if (ult::allowSlide.load(std::memory_order_acquire) || m_unlockedTrackbar) {
if (((keysReleased & KEY_LEFT) || (keysReleased & KEY_RIGHT)) ||
(wasLastHeld && !(keysHeld & (KEY_LEFT | KEY_RIGHT)))) {
updateAndExecute();
holding = false;
wasLastHeld = false;
tick = 0;
return true;
}
if (keysHeld & KEY_LEFT && keysHeld & KEY_RIGHT) {
tick = 0;
return true;
}
if (keysHeld & (KEY_LEFT | KEY_RIGHT)) {
if (!holding) {
holding = true;
tick = 0;
}
if ((tick == 0 || tick > 20) && (tick % 3) == 0) {
const float stepSize = static_cast<float>(m_maxValue - m_minValue) / (this->m_numSteps - 1);
if (keysHeld & KEY_LEFT && this->m_index > 0) {
triggerNavigationFeedback();
this->m_index--;
this->m_value = static_cast<s16>(std::round(m_minValue + m_index * stepSize));
} else if (keysHeld & KEY_RIGHT && this->m_index < this->m_numSteps-1) {
triggerNavigationFeedback();
this->m_index++;
this->m_value = static_cast<s16>(std::round(m_minValue + m_index * stepSize));
} else {
return false;
}
this->m_valueChangedListener(this->getProgress());
if (m_executeOnEveryTick)
updateAndExecute(false);
wasLastHeld = true;
}
tick++;
return true;
} else {
holding = false;
tick = 0;
}
}
return false;
}
/**
* @brief Gets the current value of the trackbar
*
* @return State
*/
virtual u8 getProgress() override {
return this->m_value / (100 / (this->m_numSteps - 1));
}
/**
* @brief Sets the current state of the toggle. Updates the Value
*
* @param state State
*/
virtual void setProgress(u8 value) override {
value = std::min(value, u8(this->m_numSteps - 1));
this->m_index = value;
// If using simple callback (modern API), use minValue/maxValue range
// Otherwise use legacy 0-100 range for config.ini compatibility
if (m_simpleCallback) {
const float stepSize = static_cast<float>(m_maxValue - m_minValue) / (this->m_numSteps - 1);
this->m_value = static_cast<s16>(std::round(m_minValue + m_index * stepSize));
} else {
// Legacy behavior for command system
this->m_value = value * (100 / (this->m_numSteps - 1));
}
}
};
/**
* @brief A customizable trackbar with multiple discrete steps with specific names. Name gets displayed above the bar
*
*/
class NamedStepTrackBarV2 : public StepTrackBarV2 {
public:
u16 trackBarWidth, stepWidth, currentDescIndex;
u32 descWidth, descHeight;
/**
* @brief Constructor
*
* @param icon Icon shown next to the track bar
* @param stepDescriptions Step names displayed above the track bar
*/
NamedStepTrackBarV2(std::string label, std::string packagePath, std::vector<std::string>& stepDescriptions,
std::function<bool(std::vector<std::vector<std::string>>&&, const std::string&, const std::string&)> executeCommands = nullptr,
std::function<std::vector<std::vector<std::string>>(const std::vector<std::vector<std::string>>&, const std::string&, size_t, const std::string&)> sourceReplacementFunc = nullptr,
std::vector<std::vector<std::string>> cmd = {}, const std::string& selCmd = "", bool unlockedTrackbar = false, bool executeOnEveryTick = false)
: StepTrackBarV2(label, packagePath, stepDescriptions.size(), 0, (stepDescriptions.size()-1), "", executeCommands, sourceReplacementFunc, cmd, selCmd, true, unlockedTrackbar, executeOnEveryTick), m_stepDescriptions(stepDescriptions) {
// Initialize the selection with the current index
if (!m_stepDescriptions.empty() && m_index >= 0 && m_index < static_cast<s16>(m_stepDescriptions.size())) {
this->m_selection = m_stepDescriptions[m_index];
currentDescIndex = m_index;
}
}
virtual ~NamedStepTrackBarV2() {}
virtual void draw(gfx::Renderer *renderer) override {
// Cache frequently used values
const u16 trackBarWidth = this->getWidth() - 95;
const u16 baseX = this->getX() + 59;
const u16 baseY = this->getY() + 44; // 50 - 3
const u8 numSteps = this->m_numSteps;
const u8 halfNumSteps = (numSteps - 1) / 2;
const u16 lastStepX = baseX + trackBarWidth - 1;
// Pre-calculate step spacing
const float stepSpacing = static_cast<float>(trackBarWidth) / (numSteps - 1);
// Cache color for multiple drawRect calls
const auto stepColor = a(trackBarEmptyColor);
// Draw step rectangles - optimized loop
u16 stepX;
for (u8 i = 0; i < numSteps; i++) {
if (i == numSteps - 1) {
// Last step - avoid overshooting
stepX = lastStepX;
} else {
stepX = baseX + static_cast<u16>(std::round(i * stepSpacing));
// Adjust for steps on right side of center
if (i > halfNumSteps) {
stepX -= 1;
}
}
renderer->drawRect(stepX, baseY, 1, 8, stepColor);
}
// Update selection (only if index changed - optional optimization)
if (currentDescIndex != this->m_index) {
currentDescIndex = this->m_index;
this->m_selection = this->m_stepDescriptions[currentDescIndex];
}
// Draw the parent trackbar
StepTrackBarV2::draw(renderer);
}
protected:
std::vector<std::string> m_stepDescriptions;
};
}
// Global state and event system
static inline Event notificationEvent;
static inline std::mutex notificationJsonMutex;
static inline std::atomic<uint32_t> notificationGeneration{0};
// Max notifications cap (max value of 4 on limited memory, 8 otherwise)
extern int maxNotifications;
class NotificationPrompt {
public:
NotificationPrompt()
: enabled_(true),
generation_(notificationGeneration.load(std::memory_order_acquire))
{}
~NotificationPrompt() { shutdown(); }
enum class PromptState : u8 {
Inactive, FadingIn, Visible, FadingOut
};
enum class Alignment : u8 { Center = 0, Left = 1, Right = 2 };
enum class SplitType : u8 { Word = 0, Char = 1 };
struct NotifEntry {
std::string text;
std::string title;
char timestamp[10] = {};
std::string fileName;
u8 fontSize = 28;
u16 durationMs = 3000;
u8 priority = 20;
u64 arrivalNs = 0;
PromptState state = PromptState::Inactive;
u64 expireNs = 0;
u64 stateStartNs = 0;
bool showTime = true;
bool hasIcon = false;
bool iconPending = false;
Alignment alignment = Alignment::Center;
SplitType splitType = SplitType::Word;
};
struct NotifCompare {
bool operator()(const NotifEntry& a, const NotifEntry& b) const {
if (a.priority == b.priority) return a.arrivalNs > b.arrivalNs;
return a.priority > b.priority;
}
};
struct Lines {
static constexpr u8 MAX_LINES = 10;
std::string buf[MAX_LINES];
u8 count = 0;
const std::string& operator[](s32 i) const { return buf[i]; }
};
static constexpr size_t TITLE_FONT = 18;
static constexpr s32 NOTIF_ICON_DIM = 50;
static constexpr size_t NOTIF_ICON_BYTES = NOTIF_ICON_DIM * NOTIF_ICON_DIM * 2;
static constexpr int MAX_VISIBLE = 8;
static constexpr s32 NOTIF_WIDTH = 448;
static constexpr s32 NOTIF_HEIGHT = 88;
// ── Public API ───────────────────────────────────────────────────────────
void show(const std::string& msg, size_t fontSize = 26, u32 priority = 20,
const std::string& fileName = "", const std::string& title = "",
u32 durationMs = 3000,
bool immediately = false, bool resume = false, bool showTime = true,
std::string_view alignment = {},
std::string_view splitType = {},
std::string_view timestamp = {}) {
if (msg.empty()) return;
if (isStale()) return;
NotifEntry data;
data.text = msg;
data.title = title;
data.fileName = fileName;
data.fontSize = static_cast<u8>(std::clamp(fontSize, size_t(8), size_t(48)));
data.durationMs = (durationMs == 0) ? 0
: static_cast<u16>(std::clamp(durationMs, 500u, 30000u));
data.priority = static_cast<u8>(immediately ? 0u : priority);
data.showTime = showTime;
data.alignment = parseAlignment(alignment, !title.empty());
data.splitType = (!splitType.empty() && splitType[0] == 'c') ? SplitType::Char : SplitType::Word;
data.arrivalNs = ult::nowNs();
if (!timestamp.empty()) {
const size_t n = std::min(timestamp.size(), sizeof(data.timestamp) - 1);
std::memcpy(data.timestamp, timestamp.data(), n);
data.timestamp[n] = '\0';
} else {
ult::formatTimestamp(time(nullptr), data.timestamp, sizeof(data.timestamp));
}
std::lock_guard<std::mutex> lg(state_mutex_);
if (isStale()) return;
if (immediately) {
bool skipFadeIn = false;
Slot& s0 = slots_[0];
if (s0.flags & SLOT_ACTIVE) {
if (s0.flags & SLOT_SHOW_NOW) {
evictSlot_NoLock(0);
skipFadeIn = true;
} else {
// Delay bottom-most only if all slots are full
if (slots_[maxNotifications - 1].flags & SLOT_ACTIVE) {
if (pending_queue_.size() < MAX_NOTIFS)
pending_queue_.push(std::move(slots_[maxNotifications - 1].data));
slots_[maxNotifications - 1] = Slot{};
}
// Shift all slots down by one to make room at slot 0
for (int j = maxNotifications - 1; j >= 1; --j)
slots_[j] = std::move(slots_[j - 1]);
slots_[0] = Slot{};
}
}
placeInSlot_NoLock(0, std::move(data), true, skipFadeIn);
repackSlots_NoLock(ult::nowNs());
} else {
int freeSlot = -1;
for (int i = 0; i < maxNotifications; ++i)
if (!(slots_[i].flags & SLOT_ACTIVE)) { freeSlot = i; break; }
if (freeSlot >= 0) {
placeInSlot_NoLock(freeSlot, std::move(data), false, resume, resume);
} else {
if (pending_queue_.size() < MAX_NOTIFS)
pending_queue_.push(std::move(data));
return;
}
}
eventFire(&notificationEvent);
#if IS_STATUS_MONITOR_DIRECTIVE
if (isRendering) {
isRendering = false;
wasRendering = true;
leventSignal(&renderingStopEvent);
}
#endif
}
void showNow(const std::string& msg, size_t fontSize = 26,
const std::string& title = "",
u32 durationMs = 2500,
bool showTime = true,
const std::string& fileName = "",
std::string_view alignment = {},
std::string_view splitType = {}) {
show(msg, fontSize, 0u, fileName, title, durationMs, true, false, showTime, alignment, splitType);
}
[[nodiscard]] bool hasActiveFile(std::string_view fname) const;
void draw(gfx::Renderer* renderer, bool promptOnly = false);
void update();
[[nodiscard]] bool isActive() const;
[[nodiscard]] int activeCount() const;
void shutdown();
void forceShutdown() { enabled_.store(false, std::memory_order_release); }
[[nodiscard]] bool hitTest(s32 tx, s32 ty) const;
bool dismissAt(s32 tx, s32 ty);
bool dismissFront();
private:
static constexpr size_t MAX_NOTIFS = 30;
static constexpr u32 FADE_DURATION_MS = 83;
static constexpr u32 SLIDE_DURATION_MS = 150;
static constexpr u8 SLOT_ACTIVE = 1 << 0;
static constexpr u8 SLOT_SHOW_NOW = 1 << 1;
static constexpr u8 SLOT_SLIDING = 1 << 2;
static constexpr u8 SLOT_ICON_LOADED = 1 << 3;
static constexpr u8 SLOT_SOUND_PENDING = 1 << 4;
struct Slot {
NotifEntry data;
float yCurrent = 0.f;
float yTarget = 0.f;
float ySlideFrom = 0.f;
u64 slideStartNs = 0;
u8 flags = 0;
std::unique_ptr<u8[]> iconBuf;
};
Slot slots_[MAX_VISIBLE];
mutable std::mutex state_mutex_;
std::priority_queue<NotifEntry, std::vector<NotifEntry>, NotifCompare> pending_queue_;
std::atomic<bool> enabled_{true};
std::atomic<u32> generation_{0};
bool isStale() const {
return !enabled_.load(std::memory_order_acquire)
|| generation_.load(std::memory_order_acquire)
!= notificationGeneration.load(std::memory_order_acquire);
}
// Transition a slot's data into FadingOut state. Caller holds state_mutex_.
static void startFadeOut(NotifEntry& e, u64 now) {
e.state = PromptState::FadingOut;
e.stateStartNs = now;
}
// Parse alignment string to enum, with a sensible title-aware default.
static Alignment parseAlignment(std::string_view sv, bool hasTitle) {
if (!sv.empty()) {
if (sv[0] == 'l') return Alignment::Left;
if (sv[0] == 'r') return Alignment::Right;
return Alignment::Center;
}
return hasTitle ? Alignment::Left : Alignment::Center;
}
void evictSlot_NoLock(int i) {
if (!slots_[i].data.fileName.empty())
remove((ult::NOTIFICATIONS_PATH + slots_[i].data.fileName).c_str());
slots_[i] = Slot{};
}
void clearAll_NoLock() {
for (int i = 0; i < maxNotifications; ++i) slots_[i] = Slot{};
while (!pending_queue_.empty()) pending_queue_.pop();
}
[[nodiscard]] int findHitSlot_NoLock(s32 tx, s32 ty) const;
// Icon/text-area geometry, computed once and shared between
// getEffectiveHeight and drawSlot.
struct IconGeom {
s32 baseIconPad;
s32 iconColW;
bool hasIconCol;
s32 textAreaX;
s32 textAreaW;
};
static IconGeom computeIconGeom(const Slot& slot, s32 x = 0) {
IconGeom g;
g.baseIconPad = (NOTIF_HEIGHT - NOTIF_ICON_DIM) / 2;
g.iconColW = g.baseIconPad + NOTIF_ICON_DIM + g.baseIconPad;
g.hasIconCol = (slot.data.hasIcon && (slot.flags & SLOT_ICON_LOADED))
|| slot.data.iconPending;
g.textAreaW = g.hasIconCol ? NOTIF_WIDTH - g.iconColW : NOTIF_WIDTH;
g.textAreaX = g.hasIconCol ? x + g.iconColW : x;
return g;
}
// Height contributed by n additional wrapped lines beyond the first:
// n * (lineHeight + 3)
static s32 extraLinesHeight(s32 n, s32 lineHeight) {
return n * (lineHeight + 3);
}
static constexpr float easeInOut(float t) {
return (t < 0.5f) ? (2*t*t) : (-1 + (4 - 2*t)*t);
}
static constexpr Color applyAlpha(Color c, float a) {
c.a = static_cast<u8>(static_cast<float>(c.a) * a);
return c;
}
[[gnu::noinline]]
Lines getWrappedLines(const std::string& text, float pixelWidth,
size_t fontSize, u8 maxLines,
SplitType splitType = SplitType::Word) const;
[[gnu::noinline]]
s32 getEffectiveHeight(const Slot& slot) const;
[[gnu::noinline]]
void placeInSlot_NoLock(int idx, NotifEntry&& e, bool isShowNow,
bool skipFadeIn, bool suppressSound = false);
[[gnu::noinline]]
void repackSlots_NoLock(u64 now);
[[gnu::noinline]]
void applyEllipsis(Lines& lines, u8 maxLines, float pixelWidth,
size_t fontSize, gfx::Renderer* renderer) const;
[[gnu::noinline]]
void drawSlot(gfx::Renderer* renderer, const Slot& slot,
s32 baseY, float fadeAlpha, bool promptOnly);
#if IS_LAUNCHER_DIRECTIVE
[[gnu::noinline]]
void drawUltrahandLine(gfx::Renderer* renderer, const std::string& line,
s32 x, s32 y, u32 fontSize, float fadeAlpha,
Color textColor = notificationTextColor);
#endif
};
inline NotificationPrompt* notification = nullptr;
// GUI
/**
* @brief The top level Gui class
* @note The main menu and every sub menu are a separate Gui. Create your own Gui class that extends from this one to create your own menus
*
*/
class Gui {
public:
Gui() {
#if !IS_LAUNCHER_DIRECTIVE
{
#if INITIALIZE_IN_GUI_DIRECTIVE // for different project structures
// Load the bitmap file into memory
ult::loadWallpaperFileWhenSafe();
#endif
}
#endif
}
virtual ~Gui() {
if (this->m_topElement != nullptr)
delete this->m_topElement;
if (this->m_bottomElement != nullptr)
delete this->m_bottomElement;
}
/**
* @brief Creates all elements present in this Gui
* @note Implement this function and let it return a heap allocated element used as the top level element. This is usually some kind of frame e.g \ref OverlayFrame
*
* @return Top level element
*/
virtual elm::Element* createUI() = 0;
/**
* @brief Called once per frame to update values
*
*/
virtual void update() {}
/**
* @brief Called once per frame with the latest HID inputs
*
* @param keysDown Buttons pressed in the last frame
* @param keysHeld Buttons held down longer than one frame
* @param touchInput Last touch position
* @param leftJoyStick Left joystick position
* @param rightJoyStick Right joystick position
* @return Weather or not the input has been consumed
*/
virtual bool handleInput(u64 keysDown, u64 keysHeld, const HidTouchState &touchPos, HidAnalogStickState leftJoyStick, HidAnalogStickState rightJoyStick) {
return false;
}
/**
* @brief Gets the top level element
*
* @return Top level element
*/
elm::Element* getTopElement() {
return this->m_topElement;
}
/**
* @brief Gets the bottom level element
*
* @return Bottom level element
*/
elm::Element* getBottomElement() {
return this->m_bottomElement;
}
/**
* @brief Get the currently focused element
*
* @return Focused element
*/
elm::Element* getFocusedElement() {
return this->m_focusedElement;
}
/**
* @brief Requests focus to a element
* @note Use this function when focusing a element outside of a element's requestFocus function
*
* @param element Element to focus
* @param direction Focus direction
*/
inline void requestFocus(elm::Element *element, FocusDirection direction, bool shake = true) {
elm::Element *oldFocus = this->m_focusedElement;
if (element != nullptr) {
this->m_focusedElement = element->requestFocus(oldFocus, direction);
if (oldFocus != nullptr)
oldFocus->setFocused(false);
if (this->m_focusedElement != nullptr) {
this->m_focusedElement->setFocused(true);
}
}
if (shake && oldFocus == this->m_focusedElement && this->m_focusedElement != nullptr) {
if (!tsl::elm::isTableScrolling.load(std::memory_order_acquire)) {
this->m_focusedElement->shakeHighlight(direction);
}
}
}
/**
* @brief Removes focus from a element
*
* @param element Element to remove focus from. Pass nullptr to remove the focus unconditionally
*/
inline void removeFocus(elm::Element* element = nullptr) {
if (element == nullptr || element == this->m_focusedElement) {
if (this->m_focusedElement != nullptr) {
this->m_focusedElement->setFocused(false);
this->m_focusedElement = nullptr;
}
}
}
inline void restoreFocus() {
this->m_initialFocusSet = false;
}
protected:
constexpr static inline auto a = &gfx::Renderer::a;
constexpr static inline auto aWithOpacity = &gfx::Renderer::aWithOpacity;
private:
elm::Element *m_focusedElement = nullptr;
elm::Element *m_topElement = nullptr;
elm::Element *m_bottomElement = nullptr;
bool m_initialFocusSet = false;
friend class Overlay;
friend class gfx::Renderer;
/**
* @brief Draws the Gui
*
* @param renderer
*/
void draw(gfx::Renderer *renderer) {
if (this->m_topElement != nullptr)
this->m_topElement->draw(renderer);
}
inline bool initialFocusSet() {
return this->m_initialFocusSet;
}
inline void markInitialFocusSet() {
this->m_initialFocusSet = true;
}
};
// Overlay
/**
* @brief The top level Overlay class
* @note Every Tesla overlay should have exactly one Overlay class initializing services and loading the default Gui
*/
class Overlay {
protected:
/**
* @brief Constructor
* @note Called once when the Overlay gets loaded
*/
Overlay() {}
public:
/**
* @brief Deconstructor
* @note Called once when the Overlay exits
*
*/
virtual ~Overlay() {}
/**
* @brief Initializes services
* @note Called once at the start to initializes services. You have a sm session available during this call, no need to initialize sm yourself
*/
virtual void initServices() {}
/**
* @brief Exits services
* @note Make sure to exit all services you initialized in \ref Overlay::initServices() here to prevent leaking handles
*/
virtual void exitServices() {}
/**
* @brief Called before overlay changes from invisible to visible state
*
*/
virtual void onShow() {}
/**
* @brief Called before overlay changes from visible to invisible state
*
*/
virtual void onHide() {}
/**
* @brief Loads the default Gui
* @note This function should return the initial Gui to load using the \ref Gui::initially<T>(Args.. args) function
* e.g `return initially<GuiMain>();`
*
* @return Default Gui
*/
virtual std::unique_ptr<tsl::Gui> loadInitialGui() = 0;
/**
* @brief Gets a reference to the current Gui on top of the Gui stack
*
* @return Current Gui reference
*/
std::unique_ptr<tsl::Gui>& getCurrentGui() {
return this->m_guiStack.top();
}
/**
* @brief Shows the Gui
*
*/
void show() {
if (ult::useHapticFeedback) {
if (!ult::isHidden.load(std::memory_order_acquire)) {
triggerInitHaptics.store(true, std::memory_order_release);
}
}
// reinitialize audio for changes from handheld to docked and vise versa
if (!ult::limitedMemory && ult::useSoundEffects)
reloadIfDockedChangedNow.store(true, std::memory_order_release);
if (this->m_disableNextAnimation) {
this->m_animationCounter = MAX_ANIMATION_COUNTER;
this->m_disableNextAnimation = false;
}
else {
this->m_fadeInAnimationPlaying = true;
this->m_animationCounter = 0;
}
this->onShow();
ult::isHidden.store(false);
if (ult::useHapticFeedback) {
triggerRumbleClick.store(true, std::memory_order_release);
}
}
/**
* @brief Hides the Gui
*
*/
void hide(bool useNoFade = false) {
if (useNoFade) {
// Immediately hide overlay
ult::isHidden.store(true);
this->m_shouldHide = true;
return;
}
#if IS_STATUS_MONITOR_DIRECTIVE
if (FullMode && !deactivateOriginalFooter) {
if (this->m_disableNextAnimation) {
this->m_animationCounter = 0;
this->m_disableNextAnimation = false;
}
else {
this->m_fadeOutAnimationPlaying = true;
this->m_animationCounter = MAX_ANIMATION_COUNTER;
}
ult::isHidden.store(true);
this->onHide();
}
#else
if (this->m_disableNextAnimation) {
this->m_animationCounter = 0;
this->m_disableNextAnimation = false;
}
else {
this->m_fadeOutAnimationPlaying = true;
this->m_animationCounter = MAX_ANIMATION_COUNTER;
}
ult::isHidden.store(true);
this->onHide();
#endif
triggerRumbleClick.store(true, std::memory_order_release);
}
/**
* @brief Returns whether fade animation is playing
*
* @return whether fade animation is playing
*/
bool fadeAnimationPlaying() {
return this->m_fadeInAnimationPlaying || this->m_fadeOutAnimationPlaying;
}
/**
* @brief Closes the Gui
* @note This makes the Tesla overlay exit and return back to the Tesla-Menu
*
*/
void close(bool forceClose = false) {
if (!forceClose && notification && notification->isActive()) {
this->closeAfter();
this->hide(true);
return;
}
this->m_shouldClose = true;
}
/**
* @brief Closes the Gui
* @note This makes the Tesla overlay exit and return back to the Tesla-Menu
*
*/
void closeAfter() {
this->m_shouldCloseAfter = true;
}
/**
* @brief Gets the Overlay instance
*
* @return Overlay instance
*/
static inline Overlay* const get() {
return Overlay::s_overlayInstance;
}
/**
* @brief Creates the initial Gui of an Overlay and moves the object to the Gui stack
*
* @tparam T
* @tparam Args
* @param args
* @return constexpr std::unique_ptr<T>
*/
template<typename T, typename ... Args>
constexpr inline std::unique_ptr<T> initially(Args&&... args) {
return std::make_unique<T>(args...);
}
private:
using GuiPtr = std::unique_ptr<tsl::Gui>;
std::stack<GuiPtr, std::list<GuiPtr>> m_guiStack;
static inline Overlay *s_overlayInstance = nullptr;
bool m_fadeInAnimationPlaying = false, m_fadeOutAnimationPlaying = false;
u8 m_animationCounter = 0;
static constexpr int MAX_ANIMATION_COUNTER = 5; // Define the maximum animation counter value
bool m_shouldHide = false;
bool m_shouldClose = false;
bool m_shouldCloseAfter = false;
bool m_disableNextAnimation = false;
bool m_closeOnExit;
static inline std::atomic<bool> isNavigatingBackwards{false};
bool justNavigated = false;
/**
* @brief Initializes the Renderer
*
*/
void initScreen() {
gfx::Renderer::get().init();
}
/**
* @brief Exits the Renderer
*
*/
void exitScreen() {
gfx::Renderer::get().exit();
}
/**
* @brief Weather or not the Gui should get hidden
*
* @return should hide
*/
bool shouldHide() {
return this->m_shouldHide;
}
/**
* @brief Weather or not hte Gui should get closed
*
* @return should close
*/
bool shouldClose() {
return this->m_shouldClose;
}
/**
* @brief Weather or not hte Gui should get closed after
*
* @return should close after
*/
bool shouldCloseAfter() {
return this->m_shouldCloseAfter;
}
/**
* @brief Quadratic ease-in-out function
*
* @param t Normalized time (0 to 1)
* @return Eased value
*/
float calculateEaseInOut(float t) {
if (t < 0.5) {
return 2 * t * t;
} else {
return -1 + (4 - 2 * t) * t;
}
}
/**
* @brief Handles fade in and fade out animations of the Overlay
*
*/
void animationLoop() {
if (this->m_fadeInAnimationPlaying) {
if (this->m_animationCounter < MAX_ANIMATION_COUNTER) {
this->m_animationCounter++;
}
if (this->m_animationCounter >= MAX_ANIMATION_COUNTER) {
this->m_fadeInAnimationPlaying = false;
}
}
if (this->m_fadeOutAnimationPlaying) {
if (this->m_animationCounter > 0) {
this->m_animationCounter--;
}
if (this->m_animationCounter == 0) {
this->m_fadeOutAnimationPlaying = false;
this->m_shouldHide = true;
}
}
// Calculate and set the opacity using an easing function
gfx::Renderer::setOpacity(calculateEaseInOut(static_cast<float>(this->m_animationCounter) / MAX_ANIMATION_COUNTER));
}
/**
* @brief Overlay Main loop
*
*/
void loop(bool promptOnly = false) {
// Early exit check - avoid all work if shutting down
if (ult::launchingOverlay.load(std::memory_order_acquire)) {
return;
}
// CRITICAL: Initialize to TRUE because stacks are added in init()!
static std::atomic<bool> screenshotStacksAdded{true};
static std::atomic<bool> notificationCacheNeedsClearing{false};
auto& renderer = gfx::Renderer::get();
renderer.startFrame();
// Handle main UI rendering
if (!promptOnly) {
// In normal mode, ensure screenshots are enabled
// Only re-add if they were removed AND force-disable is not set
if (!screenshotStacksAdded.load(std::memory_order_acquire) &&
!screenshotsAreForceDisabled.load(std::memory_order_acquire)) {
if (!screenshotStacksAdded.exchange(true, std::memory_order_acq_rel)) {
renderer.addScreenshotStacks(false);
}
}
this->animationLoop();
this->getCurrentGui()->update();
this->getCurrentGui()->draw(&renderer);
} else {
// Prompt-only mode - temporarily remove screenshots
if (screenshotStacksAdded.load(std::memory_order_acquire) &&
!screenshotsAreDisabled.load(std::memory_order_acquire) &&
!screenshotsAreForceDisabled.load(std::memory_order_acquire)) {
if (screenshotStacksAdded.exchange(false, std::memory_order_acq_rel)) {
renderer.removeScreenshotStacks(false);
}
}
renderer.clearScreen();
}
// Notification handling — safe, consistent, and null-guarded
{
if (notification && notification->isActive()) {
#if IS_STATUS_MONITOR_DIRECTIVE
if (isRendering && !wasRendering) {
isRendering = false;
wasRendering = true;
leventSignal(&renderingStopEvent);
}
#endif
notification->update();
notification->draw(&renderer, promptOnly);
// Only set flag if it's not already set
notificationCacheNeedsClearing.exchange(true, std::memory_order_acq_rel);
} else if (notificationCacheNeedsClearing.exchange(false, std::memory_order_acq_rel)) {
tsl::gfx::FontManager::clearNotificationCache();
#if IS_STATUS_MONITOR_DIRECTIVE
if (wasRendering) {
pendingExit = false;
wasRendering = false;
isRendering = true;
leventClear(&renderingStopEvent);
}
#endif
}
}
renderer.endFrame();
}
// Calculate transition using ease-in-out curve instead of linear
float easeInOutCubic(float t) {
return t < 0.5f ? 4.0f * t * t * t : 1.0f - pow(-2.0f * t + 2.0f, 3.0f) / 2.0f;
}
void handleInput(u64 keysDown, u64 keysHeld, bool touchDetected, const HidTouchState &touchPos, HidAnalogStickState joyStickPosLeft, HidAnalogStickState joyStickPosRight) {
if (!ult::internalTouchReleased.load(std::memory_order_acquire) || ult::launchingOverlay.load(std::memory_order_acquire))
return;
static HidTouchState initialTouchPos = { 0 };
static HidTouchState oldTouchPos = { 0 };
static bool oldTouchDetected = false;
static elm::TouchEvent touchEvent, oldTouchEvent;
static u64 buttonPressTime_ns = 0, lastKeyEventTime_ns = 0, keyEventInterval_ns = 67000000ULL;
static bool singlePressHandled = false;
static bool notificationTouchConsumed = false;
static constexpr u64 CLICK_THRESHOLD_NS = 340000000ULL;
static bool hasScrolled = false;
static void* lastGuiPtr = nullptr;
static std::array<bool, 4> lastSimulatedTouch = {};
auto& currentGui = this->getCurrentGui();
if (!currentGui) {
elm::Element::setInputMode(InputMode::Controller);
oldTouchPos = { 0 };
initialTouchPos = { 0 };
touchEvent = elm::TouchEvent::None;
ult::stillTouching.store(false, std::memory_order_release);
ult::interruptedTouch.store(false, std::memory_order_release);
return;
}
auto currentFocus = currentGui->getFocusedElement();
// Focus color debounce — snap true immediately, delay false
{
static u64 focusLostTime_ns = 0;
static constexpr u64 UNFOCUS_DELAY_NS = 10'000'000ULL;
if (currentFocus) {
usingUnfocusedColor = true;
focusLostTime_ns = 0;
} else {
if (!bypassUnfocused) {
const u64 now = ult::nowNs();
if (focusLostTime_ns == 0) focusLostTime_ns = now;
if (now - focusLostTime_ns >= UNFOCUS_DELAY_NS) usingUnfocusedColor = false;
} else {
usingUnfocusedColor = true;
}
}
}
const bool interpreterIsRunning = ult::runningInterpreter.load(std::memory_order_acquire);
#if !IS_STATUS_MONITOR_DIRECTIVE
if (interpreterIsRunning) {
const struct { u64 key; FocusDirection dir; } shakes[] = {
{KEY_UP, FocusDirection::Up},
{KEY_DOWN, FocusDirection::Down},
{KEY_LEFT, FocusDirection::Left},
{KEY_RIGHT, FocusDirection::Right},
};
for (auto& s : shakes) {
if (keysDown & s.key && !(keysHeld & ~s.key & ALL_KEYS_MASK)) {
currentFocus->shakeHighlight(s.dir);
return;
}
}
}
#endif
#if IS_STATUS_MONITOR_DIRECTIVE
if (FullMode && !deactivateOriginalFooter) {
if ((keysDown & ALL_KEYS_MASK) && ult::stillTouching && ult::currentForeground.load(std::memory_order_acquire)) {
triggerWallFeedback();
return;
}
if (ult::simulatedSelect.exchange(false, std::memory_order_acq_rel)) keysDown |= KEY_A;
if (ult::simulatedBack.exchange(false, std::memory_order_acq_rel)) keysDown |= KEY_B;
if (!overrideBackButton) {
if (keysDown & KEY_B && !(keysHeld & ~KEY_B & ALL_KEYS_MASK)) {
if (!currentGui->handleInput(KEY_B,0,{},{},{})) {
this->goBack();
if (this->m_guiStack.size() >= 1) triggerExitFeedback();
}
return;
}
}
} else {
ult::simulatedSelect.exchange(false, std::memory_order_acq_rel);
ult::simulatedBack.exchange(false, std::memory_order_acq_rel);
}
#else
if (ult::simulatedSelect.exchange(false, std::memory_order_acq_rel)) keysDown |= KEY_A;
if (ult::simulatedBack.exchange(false, std::memory_order_acq_rel)) keysDown |= KEY_B;
if ((keysDown & ALL_KEYS_MASK) && ult::stillTouching && ult::currentForeground.load(std::memory_order_acquire)) {
triggerWallFeedback();
return;
}
if (!overrideBackButton) {
if (keysDown & KEY_B && !(keysHeld & ~KEY_B & ALL_KEYS_MASK)) {
if (!currentGui->handleInput(KEY_B,0,{},{},{})) {
this->goBack();
if (this->m_guiStack.size() >= 1 && !interpreterIsRunning) triggerExitFeedback();
}
return;
}
} else {
#if IS_LAUNCHER_DIRECTIVE
if (keysDown & KEY_B && !(keysHeld & ~KEY_B & ALL_KEYS_MASK)) {
if (this->m_guiStack.size() >= 1 && !interpreterIsRunning) triggerExitFeedback();
}
#endif
}
#endif
if (currentGui.get() != lastGuiPtr) {
hasScrolled = false;
oldTouchEvent = elm::TouchEvent::None;
oldTouchDetected = false;
oldTouchPos = { 0 };
initialTouchPos = { 0 };
lastGuiPtr = currentGui.get();
}
auto topElement = currentGui->getTopElement();
const u64 currentTime_ns = ult::nowNs();
if (!currentFocus && !ult::simulatedBack.load(std::memory_order_acquire) &&
!ult::stillTouching.load(std::memory_order_acquire) && !oldTouchDetected && !interpreterIsRunning) {
if (!topElement) return;
if (!currentGui->initialFocusSet() && !(keysDown & (KEY_UP | KEY_DOWN | KEY_LEFT | KEY_RIGHT))) {
currentGui->requestFocus(topElement, FocusDirection::None);
currentGui->markInitialFocusSet();
}
}
if (isNavigatingBackwards.load(std::memory_order_acquire) && !currentFocus && topElement &&
keysDown & (KEY_UP | KEY_DOWN | KEY_LEFT | KEY_RIGHT)) {
currentGui->requestFocus(topElement, FocusDirection::None);
currentGui->markInitialFocusSet();
isNavigatingBackwards.store(false, std::memory_order_release);
buttonPressTime_ns = lastKeyEventTime_ns = currentTime_ns;
singlePressHandled = false;
}
if (!currentFocus && !touchDetected && (!oldTouchDetected || oldTouchEvent == elm::TouchEvent::Scroll)) {
if (!isNavigatingBackwards.load(std::memory_order_acquire) &&
!ult::shortTouchAndRelease.load(std::memory_order_acquire) &&
!ult::longTouchAndRelease.load(std::memory_order_acquire) &&
!ult::simulatedSelect.load(std::memory_order_acquire) &&
!ult::simulatedBack.load(std::memory_order_acquire) &&
!ult::simulatedNextPage.load(std::memory_order_acquire) && topElement) {
if (oldTouchEvent == elm::TouchEvent::Scroll) hasScrolled = true;
if (!hasScrolled) {
currentGui->removeFocus();
currentGui->requestFocus(topElement, FocusDirection::None);
}
} else if (ult::longTouchAndRelease.exchange(false, std::memory_order_acq_rel) ||
ult::shortTouchAndRelease.exchange(false, std::memory_order_acq_rel)) {
hasScrolled = true;
}
}
bool handled = false;
for (elm::Element* p = currentFocus; !handled && p; p = p->getParent())
handled = p->onClick(keysDown) || p->handleInput(keysDown, keysHeld, touchPos, joyStickPosLeft, joyStickPosRight);
if (currentGui != this->getCurrentGui()) return;
handled |= currentGui->handleInput(keysDown, keysHeld, touchPos, joyStickPosLeft, joyStickPosRight);
// Directional key release tracking
{
static bool lastDirectionPressed = true;
const bool directionPressed = (keysHeld & (KEY_UP | KEY_DOWN | KEY_LEFT | KEY_RIGHT)) != 0;
if (!directionPressed && lastDirectionPressed)
tsl::elm::s_directionalKeyReleased.store(true, std::memory_order_release);
else if (directionPressed && lastDirectionPressed)
tsl::elm::s_directionalKeyReleased.store(false, std::memory_order_release);
lastDirectionPressed = directionPressed;
}
const float currentScrollVelocity = tsl::elm::s_currentScrollVelocity.load(std::memory_order_acquire);
const u64 velMask = currentScrollVelocity != 0.0f ? (KEY_A | KEY_UP) : KEY_UP;
const bool singleArrowKeyPress =
((keysHeld & KEY_UP) != 0) + ((keysHeld & KEY_DOWN) != 0) +
((keysHeld & KEY_LEFT) != 0) + ((keysHeld & KEY_RIGHT) != 0) == 1 &&
!(keysHeld & ~(velMask | KEY_DOWN | KEY_LEFT | KEY_RIGHT) & ALL_KEYS_MASK);
if (hasScrolled) {
if (singleArrowKeyPress) {
buttonPressTime_ns = lastKeyEventTime_ns = currentTime_ns;
hasScrolled = false;
isNavigatingBackwards.store(false, std::memory_order_release);
}
} else {
if (!touchDetected && !oldTouchDetected && !handled && currentFocus &&
!ult::stillTouching.load(std::memory_order_acquire) && !interpreterIsRunning) {
static bool shouldShake = true;
if (singleArrowKeyPress) {
if (keysDown) {
buttonPressTime_ns = lastKeyEventTime_ns = currentTime_ns;
singlePressHandled = false;
if (keysHeld & KEY_UP && !(keysHeld & ~KEY_UP & ALL_KEYS_MASK)) currentGui->requestFocus(topElement, FocusDirection::Up, shouldShake);
else if (keysHeld & KEY_DOWN && !(keysHeld & ~KEY_DOWN & ALL_KEYS_MASK)) currentGui->requestFocus(currentFocus ? currentFocus->getParent() : topElement, FocusDirection::Down, shouldShake);
else if (keysHeld & KEY_LEFT && !(keysHeld & ~KEY_LEFT & ALL_KEYS_MASK)) currentGui->requestFocus(currentFocus ? currentFocus->getParent() : topElement, FocusDirection::Left, shouldShake);
else if (keysHeld & KEY_RIGHT && !(keysHeld & ~KEY_RIGHT & ALL_KEYS_MASK)) currentGui->requestFocus(currentFocus ? currentFocus->getParent() : topElement, FocusDirection::Right, shouldShake);
}
if (keysHeld & ~KEY_DOWN & ~KEY_UP & ~KEY_LEFT & ~KEY_RIGHT & ALL_KEYS_MASK)
buttonPressTime_ns = currentTime_ns;
const u64 durationSincePress_ns = currentTime_ns - buttonPressTime_ns;
if (!singlePressHandled && durationSincePress_ns >= CLICK_THRESHOLD_NS)
singlePressHandled = true;
// Compute repeat interval with acceleration
{
const bool tableScroll = tsl::elm::isTableScrolling.load(std::memory_order_acquire);
const u64 tp = tableScroll ? 200000000ULL : 2000000000ULL;
const u64 ini = tableScroll ? 33000000ULL : 67000000ULL;
const u64 sht = tableScroll ? 5000000ULL : 10000000ULL;
const float t = durationSincePress_ns >= tp ? 1.0f : (float)durationSincePress_ns / (float)tp;
keyEventInterval_ns = (u64)((1.0f - t) * ini + t * sht);
}
if (singlePressHandled && (currentTime_ns - lastKeyEventTime_ns) >= keyEventInterval_ns) {
lastKeyEventTime_ns = currentTime_ns;
const u64 upMask = currentScrollVelocity != 0.0f ? (KEY_A | KEY_UP) : KEY_UP;
const u64 downMask = currentScrollVelocity != 0.0f ? (KEY_A | KEY_DOWN) : KEY_DOWN;
if (keysHeld & KEY_UP && !(keysHeld & ~upMask & ALL_KEYS_MASK)) currentGui->requestFocus(topElement, FocusDirection::Up, false);
else if (keysHeld & KEY_DOWN && !(keysHeld & ~downMask & ALL_KEYS_MASK)) currentGui->requestFocus(currentFocus->getParent(), FocusDirection::Down, false);
else if (keysHeld & KEY_LEFT && !(keysHeld & ~KEY_LEFT & ALL_KEYS_MASK)) currentGui->requestFocus(currentFocus->getParent(), FocusDirection::Left, false);
else if (keysHeld & KEY_RIGHT && !(keysHeld & ~KEY_RIGHT & ALL_KEYS_MASK)) currentGui->requestFocus(currentFocus->getParent(), FocusDirection::Right, false);
}
#if !IS_STATUS_MONITOR_DIRECTIVE
} else {
buttonPressTime_ns = lastKeyEventTime_ns = currentTime_ns;
#endif
}
}
}
#if !IS_STATUS_MONITOR_DIRECTIVE
if (!touchDetected && !interpreterIsRunning && topElement) {
#else
if (!disableJumpTo && !touchDetected && !interpreterIsRunning && topElement) {
#endif
static constexpr u64 INITIAL_HOLD_THRESHOLD_NS = 400000000ULL;
static constexpr u64 HOLD_THRESHOLD_NS = 300000000ULL;
static constexpr u64 RAPID_CLICK_WINDOW_NS = 500000000ULL;
static constexpr u64 RAPID_MODE_TIMEOUT_NS = 1000000000ULL;
static constexpr u64 ACCELERATION_POINT_NS = 1500000000ULL;
static constexpr u64 INITIAL_INTERVAL_NS = 67000000ULL;
static constexpr u64 FAST_INTERVAL_NS = 10000000ULL;
const bool lKeyPressed = (keysHeld & KEY_L);
const bool rKeyPressed = (keysHeld & KEY_R);
const bool zlKeyPressed = (keysHeld & KEY_ZL);
const bool zrKeyPressed = (keysHeld & KEY_ZR);
const bool notlKeyPressed = (keysHeld & ~KEY_L & ALL_KEYS_MASK);
const bool notrKeyPressed = (keysHeld & ~KEY_R & ALL_KEYS_MASK);
const bool notzlKeyPressed = (keysHeld & ~KEY_ZL & ALL_KEYS_MASK);
const bool notzrKeyPressed = (keysHeld & ~KEY_ZR & ALL_KEYS_MASK);
struct JumpButtonState { bool keyWasPressed = false, wasIsolated = false; u64 pressStart_ns = 0; };
static JumpButtonState lState, rState;
auto handleJumpButton = [&](JumpButtonState& s, bool keyPressed, bool notKeyPressed, std::atomic<bool>& jumpSignal) {
if (keyPressed) {
if (!s.keyWasPressed) { s.pressStart_ns = currentTime_ns; s.wasIsolated = !notKeyPressed; }
if (notKeyPressed) s.wasIsolated = false; // another key pressed during hold — cancel
s.keyWasPressed = true;
} else {
if (s.keyWasPressed && s.wasIsolated && !notKeyPressed &&
currentTime_ns - s.pressStart_ns < INITIAL_HOLD_THRESHOLD_NS) {
jumpSignal.store(true, std::memory_order_release);
currentGui->requestFocus(topElement, FocusDirection::None);
}
s.keyWasPressed = s.wasIsolated = false;
}
};
handleJumpButton(lState, lKeyPressed, notlKeyPressed, jumpToTop);
handleJumpButton(rState, rKeyPressed, notrKeyPressed, jumpToBottom);
struct SkipButtonState {
u64 lastClickTime_ns = 0, firstClickPressStart_ns = 0, buttonPressStart_ns = 0, lastHoldTrigger_ns = 0;
bool keyWasPressed = false, wasIsolated = false, inRapidClickMode = false, holdTriggered = false;
};
static SkipButtonState zlState, zrState;
auto handleSkipButton = [&](SkipButtonState& s, bool keyPressed, bool notKeyPressed, std::atomic<bool>& skipSignal) {
if (s.inRapidClickMode && (currentTime_ns - s.lastClickTime_ns) > RAPID_MODE_TIMEOUT_NS)
s.inRapidClickMode = false;
if (keyPressed) {
if (!s.keyWasPressed) {
s.wasIsolated = !notKeyPressed;
if (!s.inRapidClickMode) s.firstClickPressStart_ns = currentTime_ns;
if (currentTime_ns - s.lastClickTime_ns <= RAPID_CLICK_WINDOW_NS) s.inRapidClickMode = true;
if (s.inRapidClickMode && s.wasIsolated) {
skipSignal.store(true, std::memory_order_release);
currentGui->requestFocus(topElement, FocusDirection::None);
s.lastClickTime_ns = currentTime_ns;
}
s.buttonPressStart_ns = s.lastHoldTrigger_ns = currentTime_ns;
s.holdTriggered = false;
}
if (notKeyPressed) s.wasIsolated = false; // another key pressed during hold — cancel
if (s.inRapidClickMode && s.wasIsolated) {
const u64 holdDuration = currentTime_ns - s.buttonPressStart_ns;
if (holdDuration >= HOLD_THRESHOLD_NS) {
const float t = holdDuration >= ACCELERATION_POINT_NS ? 1.0f : (float)holdDuration / ACCELERATION_POINT_NS;
const u64 interval = (u64)((1.0f - t) * INITIAL_INTERVAL_NS + t * FAST_INTERVAL_NS);
if (!s.holdTriggered || (currentTime_ns - s.lastHoldTrigger_ns) >= interval) {
skipSignal.store(true, std::memory_order_release);
currentGui->requestFocus(topElement, FocusDirection::None);
s.holdTriggered = true;
s.lastHoldTrigger_ns = s.lastClickTime_ns = currentTime_ns;
}
}
}
s.keyWasPressed = true;
} else {
if (s.keyWasPressed && !s.inRapidClickMode && s.wasIsolated && !notKeyPressed &&
currentTime_ns - s.firstClickPressStart_ns < INITIAL_HOLD_THRESHOLD_NS) {
skipSignal.store(true, std::memory_order_release);
currentGui->requestFocus(topElement, FocusDirection::None);
s.lastClickTime_ns = currentTime_ns;
s.inRapidClickMode = true;
}
s.keyWasPressed = s.wasIsolated = false;
}
};
handleSkipButton(zlState, zlKeyPressed, notzlKeyPressed, skipUp);
handleSkipButton(zrState, zrKeyPressed, notzrKeyPressed, skipDown);
}
// Notification touch consume
if (!oldTouchDetected && touchDetected) {
if (notification && notification->hitTest(static_cast<s32>(touchPos.x), static_cast<s32>(touchPos.y))) {
notification->dismissAt(static_cast<s32>(touchPos.x), static_cast<s32>(touchPos.y));
notificationTouchConsumed = true;
}
}
if (!touchDetected && oldTouchDetected) notificationTouchConsumed = false;
if (notificationTouchConsumed) {
oldTouchDetected = touchDetected;
oldTouchPos = touchPos;
return;
}
if (!touchDetected && oldTouchDetected && currentGui && topElement)
topElement->onTouch(elm::TouchEvent::Release, oldTouchPos.x, oldTouchPos.y, oldTouchPos.x, oldTouchPos.y, initialTouchPos.x, initialTouchPos.y);
// Footer touch regions
const float edgePadding = ult::halfGap.load(std::memory_order_acquire) - 5;
const float backLeftEdge = edgePadding + ult::layerEdge;
const float backRightEdge = backLeftEdge + ult::backWidth.load(std::memory_order_acquire);
const float selectLeftEdge = backRightEdge;
const float selectRightEdge = selectLeftEdge + ult::selectWidth.load(std::memory_order_acquire);
const bool noClickable = ult::noClickableItems.load(std::memory_order_acquire);
const float nextPageLeftEdge = noClickable ? backRightEdge : selectRightEdge;
const float nextPageRightEdge = nextPageLeftEdge + ult::nextPageWidth.load(std::memory_order_acquire);
const float menuRightEdge = 245.0f + ult::layerEdge - 13;
const u32 footerY = cfg::FramebufferHeight - 73U + 1;
const bool backTouched = (touchPos.x >= backLeftEdge && touchPos.x < backRightEdge && touchPos.y > footerY) &&
(initialTouchPos.x >= backLeftEdge && initialTouchPos.x < backRightEdge && initialTouchPos.y > footerY);
const bool selectTouched = !noClickable &&
(touchPos.x >= selectLeftEdge && touchPos.x < selectRightEdge && touchPos.y > footerY) &&
(initialTouchPos.x >= selectLeftEdge && initialTouchPos.x < selectRightEdge && initialTouchPos.y > footerY);
const bool nextPageTouched = ult::hasNextPageButton.load(std::memory_order_acquire) &&
(touchPos.x >= nextPageLeftEdge && touchPos.x < nextPageRightEdge && touchPos.y > footerY) &&
(initialTouchPos.x >= nextPageLeftEdge && initialTouchPos.x < nextPageRightEdge && initialTouchPos.y > footerY);
const bool menuTouched = (touchPos.x > ult::layerEdge+7U && touchPos.x <= menuRightEdge && touchPos.y > 10U && touchPos.y <= 83U) &&
(initialTouchPos.x > ult::layerEdge+7U && initialTouchPos.x <= menuRightEdge && initialTouchPos.y > 10U && initialTouchPos.y <= 83U);
bool shouldTriggerRumble = false;
auto checkTouched = [&](bool touched, std::atomic<bool>& state) {
if (touched != state.exchange(touched, std::memory_order_acq_rel) && touched)
shouldTriggerRumble = true;
};
checkTouched(backTouched, ult::touchingBack);
if (usingUnfocusedColor) checkTouched(selectTouched, ult::touchingSelect);
checkTouched(nextPageTouched, ult::touchingNextPage);
if (menuTouched != ult::touchingMenu.exchange(menuTouched, std::memory_order_acq_rel)) {
if (menuTouched && (ult::inMainMenu.load(std::memory_order_acquire) ||
(ult::inHiddenMode.load(std::memory_order_acquire) &&
!ult::inSettingsMenu.load(std::memory_order_acquire) &&
!ult::inSubSettingsMenu.load(std::memory_order_acquire))))
shouldTriggerRumble = true;
}
if (shouldTriggerRumble) triggerNavigationFeedback();
if (touchDetected) {
lastSimulatedTouch = {backTouched, selectTouched, nextPageTouched, menuTouched};
//const bool touchInFooter = (touchPos.y > static_cast<u32>(cfg::FramebufferHeight - 73U + 1));
ult::interruptedTouch.store(!(touchPos.y > static_cast<u32>(cfg::FramebufferHeight - 73U + 1)) && (keysHeld & ALL_KEYS_MASK) != 0, std::memory_order_release);
const u32 xd = std::abs(static_cast<s32>(initialTouchPos.x) - static_cast<s32>(touchPos.x));
const u32 yd = std::abs(static_cast<s32>(initialTouchPos.y) - static_cast<s32>(touchPos.y));
if (xd*xd + yd*yd > 1000) {
elm::Element::setInputMode(InputMode::TouchScroll);
touchEvent = elm::TouchEvent::Scroll;
} else if (touchEvent != elm::TouchEvent::Scroll) {
touchEvent = elm::TouchEvent::Hold;
}
if (!oldTouchDetected) {
initialTouchPos = touchPos;
elm::Element::setInputMode(InputMode::Touch);
if (!interpreterIsRunning) {
ult::touchInBounds = (initialTouchPos.y <= footerY && initialTouchPos.y > 73U &&
initialTouchPos.x <= ult::layerEdge + cfg::FramebufferWidth - 30U &&
initialTouchPos.x > 40U + ult::layerEdge);
if (ult::touchInBounds) currentGui->removeFocus();
}
touchEvent = elm::TouchEvent::Touch;
}
if (currentGui && topElement && !interpreterIsRunning) {
if (touchPos.x > 40U + ult::layerEdge && touchPos.x <= cfg::FramebufferWidth - 30U + ult::layerEdge &&
touchPos.y > 73U && touchPos.y <= footerY)
currentGui->removeFocus();
topElement->onTouch(touchEvent, touchPos.x, touchPos.y, oldTouchPos.x, oldTouchPos.y, initialTouchPos.x, initialTouchPos.y);
}
oldTouchPos = touchPos;
if ((touchPos.x < ult::layerEdge || touchPos.x > cfg::FramebufferWidth + ult::layerEdge) &&
tsl::elm::Element::getInputMode() == tsl::InputMode::Touch) {
oldTouchPos = { 0 };
initialTouchPos = { 0 };
#if IS_STATUS_MONITOR_DIRECTIVE
if (FullMode && !deactivateOriginalFooter) this->hide();
#else
if (!disableHiding) this->hide();
#endif
}
ult::stillTouching.store(true, std::memory_order_release);
} else {
for (int i = 0; i < 4; ++i) {
if (!lastSimulatedTouch[i]) continue;
if (!ult::interruptedTouch.load(std::memory_order_acquire) && !interpreterIsRunning) {
switch (i) {
case 0: ult::simulatedBack.store(true, std::memory_order_release); break;
case 1: ult::simulatedSelect.store(true, std::memory_order_release); break;
case 2: ult::simulatedNextPage.store(true, std::memory_order_release); break;
case 3: ult::simulatedMenu.store(true, std::memory_order_release); break;
}
} else if (interpreterIsRunning) {
switch (i) {
case 0: this->hide(); break;
case 1: ult::externalAbortCommands.store(true, std::memory_order_release); break;
}
}
}
lastSimulatedTouch.fill(false);
elm::Element::setInputMode(InputMode::Controller);
oldTouchPos = { 0 };
initialTouchPos = { 0 };
touchEvent = elm::TouchEvent::None;
ult::stillTouching.store(false, std::memory_order_release);
ult::interruptedTouch.store(false, std::memory_order_release);
}
oldTouchDetected = touchDetected;
oldTouchEvent = touchEvent;
}
/**
* @brief Clears the screen
*
*/
void clearScreen() {
auto& renderer = gfx::Renderer::get();
renderer.startFrame();
renderer.clearScreen();
renderer.endFrame();
}
/**
* @brief Reset hide and close flags that were previously set by \ref Overlay::close() or \ref Overlay::hide()
*
*/
void resetFlags() {
this->m_shouldHide = false;
this->m_shouldClose = false;
this->m_shouldCloseAfter = false;
}
/**
* @brief Disables the next animation that would play
*
*/
void disableNextAnimation() {
this->m_disableNextAnimation = true;
}
/**
* @brief Changes to a different Gui
*
* @param gui Gui to change to
* @return Reference to the Gui
*/
std::unique_ptr<tsl::Gui>& changeTo(std::unique_ptr<tsl::Gui>&& gui, bool clearGlyphCache = false) {
if (this->m_guiStack.top() != nullptr && this->m_guiStack.top()->m_focusedElement != nullptr)
this->m_guiStack.top()->m_focusedElement->resetClickAnimation();
isNavigatingBackwards.store(false, std::memory_order_release);
// cache frame for forward rendering using external list method (to be implemented)
// Create the top element of the new Gui
gui->m_topElement = gui->createUI();
// Push the new Gui onto the stack
this->m_guiStack.push(std::move(gui));
return this->m_guiStack.top();
}
/**
* @brief Creates a new Gui and changes to it
*
* @tparam G Gui to create
* @tparam Args Arguments to pass to the Gui
* @param args Arguments to pass to the Gui
* @return Reference to the newly created Gui
*/
// Template version without clearGlyphCache (for backward compatibility)
template<typename G, typename ...Args>
std::unique_ptr<tsl::Gui>& changeTo(Args&&... args) {
return this->changeTo(std::make_unique<G>(std::forward<Args>(args)...), false);
}
/**
* @brief Swaps to a different Gui
*
* @param gui Gui to change to
* @return Reference to the Gui
*/
std::unique_ptr<tsl::Gui>& swapTo(std::unique_ptr<tsl::Gui>&& gui, u32 count = 1) {
isNavigatingBackwards.store(true, std::memory_order_release);
// Clamp count to available stack size to prevent underflow
const u32 actualCount = std::min(count, static_cast<u32>(this->m_guiStack.size()));
if (actualCount > 1) {
// Pop the specified number of GUIs
for (u32 i = 0; i < actualCount; ++i) {
this->m_guiStack.pop();
}
} else {
this->m_guiStack.pop();
}
if (this->m_guiStack.top() != nullptr && this->m_guiStack.top()->m_focusedElement != nullptr)
this->m_guiStack.top()->m_focusedElement->resetClickAnimation();
isNavigatingBackwards.store(false, std::memory_order_release);
// Create the top element of the new Gui
gui->m_topElement = gui->createUI();
// Push the new Gui onto the stack
this->m_guiStack.push(std::move(gui));
return this->m_guiStack.top();
}
/**
* @brief Creates a new Gui and changes to it
*
* @tparam G Gui to create
* @tparam Args Arguments to pass to the Gui
* @param args Arguments to pass to the Gui
* @return Reference to the newly created Gui
*/
// Template version without clearGlyphCache (for backward compatibility)
template<typename G, typename ...Args>
std::unique_ptr<tsl::Gui>& swapTo(SwapDepth depth, Args&&... args) {
return this->swapTo(std::make_unique<G>(std::forward<Args>(args)...), depth.value);
}
template<typename G, typename ...Args>
std::unique_ptr<tsl::Gui>& swapTo(Args&&... args) {
return this->swapTo(std::make_unique<G>(std::forward<Args>(args)...), 1);
}
/**
* @brief Pops the top Gui(s) from the stack and goes back count number of times
* @param count Number of Guis to pop from the stack (default: 1)
* @note The Overlay gets closed once there are no more Guis on the stack
*/
void goBack(u32 count = 1) {
if (ult::stillTouching && ult::currentForeground.load(std::memory_order_acquire)) {
triggerWallFeedback();
return;
}
// If there is exactly one GUI and an active notification, handle that first
if (this->m_guiStack.size() == 1 && notification && notification->isActive()) {
this->close();
return;
}
isNavigatingBackwards.store(true, std::memory_order_release);
// Clamp count to available stack size to prevent underflow
const u32 actualCount = std::min(count, static_cast<u32>(this->m_guiStack.size()));
// Special case: if we don't close on exit and popping everything would leave us with 0 or 1 GUI
if (!this->m_closeOnExit && this->m_guiStack.size() <= actualCount) {
this->hide();
return;
}
// Pop the specified number of GUIs
for (u32 i = 0; i < actualCount && !this->m_guiStack.empty(); ++i) {
this->m_guiStack.pop();
}
// Close overlay if stack is empty
if (this->m_guiStack.empty()) {
this->close();
}
}
void pop(u32 count = 1) {
if (ult::stillTouching && ult::currentForeground.load(std::memory_order_acquire)) {
triggerWallFeedback();
return;
}
isNavigatingBackwards.store(true, std::memory_order_release);
// Clamp count to available stack size to prevent underflow
const u32 actualCount = std::min(count, static_cast<u32>(this->m_guiStack.size()));
if (actualCount > 1) {
// Pop the specified number of GUIs
for (u32 i = 0; i < actualCount; ++i) {
this->m_guiStack.pop();
}
} else {
this->m_guiStack.pop();
}
}
template<typename G, typename ...Args>
friend std::unique_ptr<tsl::Gui>& changeTo(Args&&... args);
template<typename G, typename ...Args>
friend std::unique_ptr<tsl::Gui>& swapTo(Args&&... args);
template<typename G, typename ...Args>
friend std::unique_ptr<tsl::Gui>& swapTo(SwapDepth depth, Args&&... args);
friend void goBack(u32 count);
friend void pop(u32 count);
template<typename, tsl::impl::LaunchFlags>
friend int loop(int argc, char** argv);
friend class tsl::Gui;
};
namespace impl {
static constexpr const char* TESLA_CONFIG_FILE = "/config/tesla/config.ini";
static constexpr const char* ULTRAHAND_CONFIG_FILE = "/config/ultrahand/config.ini";
/**
* @brief Data shared between the different ult::renderThreads
*
*/
struct SharedThreadData {
std::atomic<bool> running = false;
Event comboEvent = { 0 };
std::atomic<bool> overlayOpen = false;
std::mutex dataMutex;
u64 keysDown = 0;
u64 keysDownPending = 0;
u64 keysHeld = 0;
HidTouchScreenState touchState = { 0 };
HidAnalogStickState joyStickPosLeft = { 0 }, joyStickPosRight = { 0 };
};
/**
* @brief Extract values from Tesla settings file
*
*/
void parseOverlaySettings();
/**
* @brief Update and save launch combo keys
*
* @param keys the new combo keys
*/
static void updateCombo(u64 keys) {
tsl::cfg::launchCombo = keys;
const std::string comboStr = tsl::hlp::keysToComboString(keys);
ult::setIniFileValue(ult::ULTRAHAND_CONFIG_INI_PATH, ult::ULTRAHAND_PROJECT_NAME, ult::KEY_COMBO_STR, comboStr);
ult::setIniFileValue(ult::TESLA_CONFIG_INI_PATH, ult::TESLA_STR, ult::KEY_COMBO_STR, comboStr);
}
static auto currentUnderscanPixels = std::make_pair(0, 0);
/**
* @brief Background event polling loop thread
*
* @param args Used to pass in a pointer to a \ref SharedThreadData struct
*/
static void backgroundEventPoller(void *args) {
requiresLNY2 = amsVersionAtLeast(1,10,0); // Detect if using HOS 21+
// Initialize the audio service
if (ult::useSoundEffects && !ult::limitedMemory) {
ult::Audio::initialize();
}
tsl::hlp::loadEntryKeyCombos();
ult::launchingOverlay.store(false, std::memory_order_release);
SharedThreadData *shData = static_cast<SharedThreadData*>(args);
const auto fireLaunch = [&]() __attribute__((noinline)) {
ult::launchingOverlay.store(true, std::memory_order_release);
tsl::Overlay::get()->close();
eventFire(&shData->comboEvent);
launchComboHasTriggered.store(true, std::memory_order_release);
};
// To prevent focus glitchout, close the overlay immediately when the home button gets pressed
Event homeButtonPressEvent = {};
hidsysAcquireHomeButtonEventHandle(&homeButtonPressEvent, false);
eventClear(&homeButtonPressEvent);
tsl::hlp::ScopeGuard homeButtonEventGuard([&] { eventClose(&homeButtonPressEvent); });
// To prevent focus glitchout, close the overlay immediately when the power button gets pressed
Event powerButtonPressEvent = {};
hidsysAcquireSleepButtonEventHandle(&powerButtonPressEvent, false);
eventClear(&powerButtonPressEvent);
tsl::hlp::ScopeGuard powerButtonEventGuard([&] { eventClose(&powerButtonPressEvent); });
// For handling screenshots color alpha
Event captureButtonPressEvent = {};
for (int i = 0; i < 2; i++) {
hidsysAcquireCaptureButtonEventHandle(&captureButtonPressEvent, false);
eventClear(&captureButtonPressEvent);
}
tsl::hlp::ScopeGuard captureButtonEventGuard([&] { eventClose(&captureButtonPressEvent); });
// Allow only Player 1 and handheld mode
HidNpadIdType id_list[2] = { HidNpadIdType_No1, HidNpadIdType_Handheld };
// Configure HID system to only listen to these IDs
hidSetSupportedNpadIdType(id_list, 2);
// Configure input for up to 2 supported controllers (P1 + Handheld)
padConfigureInput(2, HidNpadStyleSet_NpadStandard | HidNpadStyleTag_NpadSystemExt);
// Initialize separate pad states for both controllers
auto pad_p1_ptr = std::make_unique<PadState>();
auto pad_handheld_ptr = std::make_unique<PadState>();
PadState& pad_p1 = *pad_p1_ptr;
PadState& pad_handheld = *pad_handheld_ptr;
padInitialize(&pad_p1, HidNpadIdType_No1);
padInitialize(&pad_handheld, HidNpadIdType_Handheld);
// Touch screen init
hidInitializeTouchScreen();
// Clear any stale input from both controllers
padUpdate(&pad_p1);
padUpdate(&pad_handheld);
//ult::initHaptics(); // initialize rumble
enum WaiterObject {
WaiterObject_HomeButton,
WaiterObject_PowerButton,
WaiterObject_CaptureButton,
WaiterObject_Count
};
// Construct waiter
Waiter objects[3] = {
[WaiterObject_HomeButton] = waiterForEvent(&homeButtonPressEvent),
[WaiterObject_PowerButton] = waiterForEvent(&powerButtonPressEvent),
[WaiterObject_CaptureButton] = waiterForEvent(&captureButtonPressEvent),
};
u64 currentTouchTick = 0;
auto lastTouchX = 0;
auto lastTouchY = 0;
// Preset touch boundaries
constexpr int SWIPE_RIGHT_BOUND = 16; // 16 + 80
constexpr int SWIPE_LEFT_BOUND = (1280 - 16);
constexpr u64 TOUCH_THRESHOLD_NS = 150'000'000ULL; // 150ms in nanoseconds
constexpr u64 FAST_SWAP_THRESHOLD_NS = 150'000'000ULL;
// Global underscan monitoring - run at most once every 300ms
auto lastUnderscanPixels = std::make_pair(0, 0);
bool firstUnderscanCheck = true;
u64 lastUnderscanCheckNs = 0; // store last execution in nanoseconds
constexpr u64 UNDERSCAN_INTERVAL_NS = 300'000'000ULL; // 300ms in ns
s32 idx;
Result rc;
std::string currentTitleID;
u64 lastPollTick = 0;
u64 resetStartTick = armGetSystemTick();
const u64 startNs = armTicksToNs(resetStartTick);
ult::lastTitleID = ult::getTitleIdAsString();
// Notification variables
u64 lastNotifCheck = 0;
u64 minusHoldStartTick = 0;
bool minusHoldArmed = false;
while (shData->running.load(std::memory_order_acquire)) {
u64 nowTick = armGetSystemTick();
u64 nowNs = armTicksToNs(nowTick);
// Scan for input changes from both controllers
padUpdate(&pad_p1);
padUpdate(&pad_handheld);
// Read in HID values
{
// Poll Title ID every 1 seconds
if (!ult::resetForegroundCheck.load(std::memory_order_acquire)) {
const u64 elapsedNs = armTicksToNs(nowTick - lastPollTick);
if (elapsedNs >= 1'000'000'000ULL) {
lastPollTick = nowTick;
currentTitleID = ult::getTitleIdAsString();
if (currentTitleID != ult::lastTitleID) {
ult::lastTitleID = currentTitleID;
if (currentTitleID != ult::NULL_STR) {
ult::resetForegroundCheck.store(true, std::memory_order_release);
resetStartTick = nowTick;
}
}
}
}
// If a reset is scheduled, trigger after 3.5s delay
if (ult::resetForegroundCheck.load(std::memory_order_acquire)) {
const u64 resetElapsedNs = armTicksToNs(nowTick - resetStartTick);
if (resetElapsedNs >= 3'500'000'000ULL) {
if (shData->overlayOpen && ult::currentForeground.load(std::memory_order_acquire)) {
#if IS_STATUS_MONITOR_DIRECTIVE
if (!isValidOverlayMode())
hlp::requestForeground(true, false);
#else
hlp::requestForeground(true, false);
#endif
}
ult::resetForegroundCheck.store(false, std::memory_order_release);
}
}
if (firstUnderscanCheck || (nowNs - lastUnderscanCheckNs) >= UNDERSCAN_INTERVAL_NS) {
currentUnderscanPixels = tsl::gfx::getUnderscanPixels();
if (firstUnderscanCheck || currentUnderscanPixels != lastUnderscanPixels) {
// Update layer dimensions without destroying state
tsl::gfx::Renderer::get().updateLayerSize();
lastUnderscanPixels = currentUnderscanPixels;
firstUnderscanCheck = false;
}
lastUnderscanCheckNs = nowNs;
}
// Process notification files every 300ms
{
std::lock_guard<std::mutex> jsonLock(notificationJsonMutex);
if (armTicksToNs(nowTick - lastNotifCheck) >= 300'000'000ULL) {
lastNotifCheck = nowTick;
DIR* dir = opendir(ult::NOTIFICATIONS_PATH.c_str());
if (dir) {
if (ult::useNotifications) {
static u32 seenGeneration = UINT32_MAX;
const u32 curGen = notificationGeneration.load(std::memory_order_acquire);
const bool firstPoll = (seenGeneration != curGen);
if (firstPoll) seenGeneration = curGen;
const std::string& notifPath = ult::NOTIFICATIONS_PATH;
if (!firstPoll && notification && notification->activeCount() >= maxNotifications) {
closedir(dir);
} else {
// ── Single-pass: stat + JSON read + top-N insertion ─────────────
// No candidates vector — filenames never heap-copied unless they
// beat the current worst slot. Safe on small heap threads.
struct NotifData {
std::string fname, text, title;
struct timespec mtime;
int priority, fontSize;
int duration = 0;
bool showTime;
std::string alignment;
bool splitChar = false;
char timestamp[10] = {};
};
static NotifData topSlots[NotificationPrompt::MAX_VISIBLE];
static int topCount = 0;
topCount = 0;
const int activeNow = notification ? notification->activeCount() : 0;
const int slotsWanted = firstPoll
? (maxNotifications - activeNow)
: 1;
auto isBetter = [](const NotifData& a, const NotifData& b) noexcept {
if (a.priority != b.priority) return a.priority > b.priority;
if (a.mtime.tv_sec != b.mtime.tv_sec) return a.mtime.tv_sec < b.mtime.tv_sec;
return a.mtime.tv_nsec < b.mtime.tv_nsec;
};
static std::string fullPath;
struct dirent* entry;
while ((entry = readdir(dir)) != nullptr) {
if (entry->d_type != DT_REG) continue;
const char* fname = entry->d_name;
const size_t len = strlen(fname);
if (len <= 7 || strcmp(fname + len - 7, ".notify") != 0)
continue;
if (notification && notification->hasActiveFile(fname))
continue;
// ── Flag check: suppress & delete if {APP_NAME}.flag exists ──────
{
const char* dash = strchr(fname, '-');
if (dash) {
static std::string flagPath;
flagPath = ult::NOTIFICATIONS_FLAGS_PATH;
flagPath.append(fname, dash - fname); // extract APP_NAME
flagPath += ".flag";
if (ult::isFile(flagPath)) {
fullPath = notifPath;
fullPath += fname;
remove(fullPath.c_str());
continue;
}
}
}
// ── End flag check ────────────────────────────────────────────────
fullPath = notifPath;
fullPath += fname;
struct stat fileStat;
struct timespec mtime = {0, 0};
char timestampBuf[10] = {};
if (stat(fullPath.c_str(), &fileStat) == 0) {
mtime = fileStat.st_mtim;
ult::formatTimestamp(mtime.tv_sec, timestampBuf, sizeof(timestampBuf));
}
std::unique_ptr<ult::json_t, ult::JsonDeleter> r(
ult::readJsonFromFile(fullPath), ult::JsonDeleter());
if (!r) continue;
cJSON* cr = reinterpret_cast<cJSON*>(r.get());
const cJSON* textObj = cJSON_GetObjectItemCaseSensitive(cr, "text");
if (!cJSON_IsString(textObj) || !textObj->valuestring || !textObj->valuestring[0])
continue;
const cJSON* priorityObj = cJSON_GetObjectItemCaseSensitive(cr, "priority");
NotifData nd;
nd.priority = cJSON_IsNumber(priorityObj) ? (int)priorityObj->valuedouble : 20;
nd.mtime = mtime;
// Prune before copying strings — skip heap allocs for non-qualifiers.
if (topCount == slotsWanted) {
if (!isBetter(nd, topSlots[topCount - 1])) continue;
}
const cJSON* titleObj = cJSON_GetObjectItemCaseSensitive(cr, "title");
const cJSON* fontSizeObj = cJSON_GetObjectItemCaseSensitive(cr, "font_size");
const cJSON* durationObj = cJSON_GetObjectItemCaseSensitive(cr, "duration");
const cJSON* showTimeObj = cJSON_GetObjectItemCaseSensitive(cr, "show_time");
const cJSON* alignmentObj = cJSON_GetObjectItemCaseSensitive(cr, "alignment");
const cJSON* splitTypeObj = cJSON_GetObjectItemCaseSensitive(cr, "split_type");
nd.showTime = !(cJSON_IsString(showTimeObj) && showTimeObj->valuestring && strcmp(showTimeObj->valuestring, ult::FALSE_STR.c_str()) == 0);
nd.fname = fname;
nd.text = textObj->valuestring;
nd.title = (cJSON_IsString(titleObj) && titleObj->valuestring) ? titleObj->valuestring : "";
nd.fontSize = cJSON_IsNumber(fontSizeObj) ? std::clamp((int)fontSizeObj->valuedouble, 8, 48)
: (nd.title.empty() ? 26 : 24);
nd.duration = cJSON_IsNumber(durationObj)
? ((int)durationObj->valuedouble == 0 ? -1 : std::clamp((int)durationObj->valuedouble, 500, 30000))
: 0;
const bool alignmentExplicit = cJSON_IsString(alignmentObj) && alignmentObj->valuestring && alignmentObj->valuestring[0];
nd.alignment = alignmentExplicit ? alignmentObj->valuestring
: (nd.title.empty() ? "" : ult::LEFT_STR);
nd.splitChar = cJSON_IsString(splitTypeObj) && splitTypeObj->valuestring
&& strcmp(splitTypeObj->valuestring, ult::CHAR_STR.c_str()) == 0;
int pos = topCount;
while (pos > 0 && isBetter(nd, topSlots[pos - 1])) --pos;
if (pos < slotsWanted) {
if (topCount < slotsWanted) ++topCount;
for (int i = topCount - 1; i > pos; --i)
topSlots[i] = std::move(topSlots[i - 1]);
topSlots[pos] = std::move(nd);
}
}
closedir(dir);
// ── Dispatch ────────────────────────────────────────────────────
if (notification) {
for (int i = 0; i < topCount; ++i) {
if (notification->activeCount() >= maxNotifications) break;
const NotifData& nd = topSlots[i];
// Resume: stagger expiry so earlier slots fade first.
// Slot 0 of N loses (N-1) seconds, slot 1 loses (N-2), …, last loses 0.
const int baseDuration = nd.duration > 0 ? nd.duration : 4000;
const u32 duration = nd.duration == -1 ? 0u
: (firstPoll && topCount > 1)
? static_cast<u32>(std::max(500, baseDuration - (topCount - 1 - i) * 200))
: static_cast<u32>(baseDuration);
notification->show(nd.text, nd.fontSize, nd.priority,
nd.fname, nd.title, duration,
false, firstPoll, nd.showTime,
nd.alignment,
nd.splitChar ? ult::CHAR_STR : ult::WORD_STR,
nd.timestamp);
}
}
} // end dispatch branch
} else {
// Notifications disabled: delete all .notify files.
struct dirent* entry;
static std::string fullPath;
while ((entry = readdir(dir)) != nullptr) {
if (entry->d_type != DT_REG) continue;
const char* fname = entry->d_name;
const size_t len = strlen(fname);
if (len > 7 && strcmp(fname + len - 7, ".notify") == 0) {
fullPath.clear();
fullPath = ult::NOTIFICATIONS_PATH;
fullPath.append(fname, len);
remove(fullPath.c_str());
}
}
closedir(dir);
}
}
}
}
// Combine inputs from both controllers
const u64 kDown_p1 = padGetButtonsDown(&pad_p1);
const u64 kHeld_p1 = padGetButtons(&pad_p1);
const u64 kDown_handheld = padGetButtonsDown(&pad_handheld);
const u64 kHeld_handheld = padGetButtons(&pad_handheld);
// For joysticks, prioritize handheld if available, otherwise use P1
const HidAnalogStickState leftStick_handheld = padGetStickPos(&pad_handheld, 0);
const HidAnalogStickState rightStick_handheld = padGetStickPos(&pad_handheld, 1);
// Check if handheld has any stick input (not at center position)
const bool handheldHasInput = (leftStick_handheld.x != 0 || leftStick_handheld.y != 0 ||
rightStick_handheld.x != 0 || rightStick_handheld.y != 0);
// Read touch before acquiring lock (static = BSS, not stack)
static HidTouchScreenState newTouchState;
newTouchState = { 0 };
const bool hasTouchNow = hidGetTouchScreenStates(&newTouchState, 1) > 0;
const HidTouchState& currentTouch = newTouchState.touches[0];
// Swipe detection (uses only local variables, safe outside lock)
if (hasTouchNow) {
const u64 elapsedTime_ns = armTicksToNs(nowTick - currentTouchTick);
if (ult::useSwipeToOpen && elapsedTime_ns <= TOUCH_THRESHOLD_NS) {
if ((lastTouchX != 0 && lastTouchY != 0) && (currentTouch.x != 0 || currentTouch.y != 0)) {
if (ult::layerEdge == 0 && currentTouch.x > SWIPE_RIGHT_BOUND + 84 && lastTouchX <= SWIPE_RIGHT_BOUND) {
eventFire(&shData->comboEvent);
mainComboHasTriggered.store(true, std::memory_order_release);
}
else if (ult::layerEdge > 0 && currentTouch.x < SWIPE_LEFT_BOUND - 84 && lastTouchX >= SWIPE_LEFT_BOUND) {
eventFire(&shData->comboEvent);
mainComboHasTriggered.store(true, std::memory_order_release);
}
}
}
if (currentTouch.x == 0 && currentTouch.y == 0) {
ult::internalTouchReleased.store(true, std::memory_order_release);
lastTouchX = 0;
lastTouchY = 0;
} else if ((lastTouchX == 0 && lastTouchY == 0) && (currentTouch.x != 0 || currentTouch.y != 0)) {
currentTouchTick = nowTick;
lastTouchX = currentTouch.x;
lastTouchY = currentTouch.y;
}
if (!shData->overlayOpen)
ult::internalTouchReleased.store(false, std::memory_order_release);
} else {
ult::internalTouchReleased.store(true, std::memory_order_release);
lastTouchX = 0;
lastTouchY = 0;
}
// Write all shared input state under the mutex
{
std::lock_guard<std::mutex> lock(shData->dataMutex);
shData->keysDown = kDown_p1 | kDown_handheld;
shData->keysHeld = kHeld_p1 | kHeld_handheld;
if (handheldHasInput) {
shData->joyStickPosLeft = leftStick_handheld;
shData->joyStickPosRight = rightStick_handheld;
} else {
shData->joyStickPosLeft = padGetStickPos(&pad_p1, 0);
shData->joyStickPosRight = padGetStickPos(&pad_p1, 1);
}
shData->touchState = hasTouchNow ? newTouchState : HidTouchScreenState{ 0 };
}
#if IS_STATUS_MONITOR_DIRECTIVE
if (triggerExitNow) {
ult::launchingOverlay.store(true, std::memory_order_release);
ult::setIniFileValue(
ult::ULTRAHAND_CONFIG_INI_PATH,
ult::ULTRAHAND_PROJECT_NAME,
ult::IN_OVERLAY_STR,
ult::FALSE_STR
);
tsl::setNextOverlay(
ult::OVERLAY_PATH + "ovlmenu.ovl"
);
tsl::Overlay::get()->close();
break;
}
#endif
// KEY_MINUS: dismiss topmost notification, consume key
// Fires whether overlay is open or hidden.
// Skipped if MINUS is (part of) a launch combo, or if
// other buttons are also held (which means it's mid-combo).
if ((shData->keysDown & KEY_MINUS)
&& !(shData->keysHeld & ~KEY_MINUS & ALL_KEYS_MASK)
&& tsl::cfg::launchCombo != KEY_MINUS
&& tsl::cfg::launchCombo2 != KEY_MINUS
&& notification && notification->isActive()) {
notification->dismissFront();
shData->keysDown &= ~KEY_MINUS;
shData->keysHeld &= ~KEY_MINUS;
}
// KEY_MINUS: hold 3s to toggle notifications
if (ult::useNotificationsHotkey && tsl::cfg::launchCombo != KEY_MINUS
&& tsl::cfg::launchCombo2 != KEY_MINUS) {
const bool minusAlone = (shData->keysHeld & KEY_MINUS)
&& !(shData->keysHeld & ~KEY_MINUS & ALL_KEYS_MASK);
if (minusAlone) {
if (!minusHoldArmed) {
minusHoldArmed = true;
minusHoldStartTick = nowTick;
} else if (armTicksToNs(nowTick - minusHoldStartTick) >= 3'000'000'000ULL) {
minusHoldArmed = false;
ult::useNotifications = !ult::useNotifications;
ult::setIniFileValue(
ult::ULTRAHAND_CONFIG_INI_PATH,
ult::ULTRAHAND_PROJECT_NAME,
"notifications",
ult::useNotifications ? ult::TRUE_STR : ult::FALSE_STR
);
if (ult::useNotifications) {
if (!ult::isFile(ult::NOTIFICATIONS_FLAG_FILEPATH)) {
if (FILE* f = std::fopen(ult::NOTIFICATIONS_FLAG_FILEPATH.c_str(), "w"))
std::fclose(f);
}
if (notification)
notification->show(ult::NOTIFY_HEADER+"API notifications enabled.", 22, 0);
} else {
ult::deleteFileOrDirectory(ult::NOTIFICATIONS_FLAG_FILEPATH);
if (notification)
notification->show(ult::NOTIFY_HEADER+"API notifications disabled.", 22, 0);
}
shData->keysDown &= ~KEY_MINUS;
shData->keysHeld &= ~KEY_MINUS;
}
} else {
minusHoldArmed = false;
}
}
// Check main launch combo first (highest priority)
if ((((shData->keysHeld & tsl::cfg::launchCombo) == tsl::cfg::launchCombo) && shData->keysDown & tsl::cfg::launchCombo)) {
#if IS_LAUNCHER_DIRECTIVE
if (ult::updateMenuCombos) {
ult::setIniFileValue(ult::ULTRAHAND_CONFIG_INI_PATH, ult::ULTRAHAND_PROJECT_NAME, ult::KEY_COMBO_STR , ult::ULTRAHAND_COMBO_STR);
ult::setIniFileValue(ult::TESLA_CONFIG_INI_PATH, ult::TESLA_STR, ult::KEY_COMBO_STR , ult::ULTRAHAND_COMBO_STR);
ult::updateMenuCombos = false;
}
#endif
#if IS_STATUS_MONITOR_DIRECTIVE
isRendering = false;
leventSignal(&renderingStopEvent); // always — wakes frame limiter immediately
#endif
if (shData->overlayOpen) {
if (!disableHiding) {
#if IS_STATUS_MONITOR_DIRECTIVE
if (!isValidOverlayMode()) { // only guard the hide
#endif
tsl::Overlay::get()->hide();
shData->overlayOpen = false;
#if IS_STATUS_MONITOR_DIRECTIVE
}
#endif
}
}
else {
eventFire(&shData->comboEvent);
mainComboHasTriggered.store(true, std::memory_order_release);
}
}
#if IS_LAUNCHER_DIRECTIVE
else if (ult::updateMenuCombos && (((shData->keysHeld & tsl::cfg::launchCombo2) == tsl::cfg::launchCombo2) && shData->keysDown & tsl::cfg::launchCombo2)) {
std::swap(tsl::cfg::launchCombo, tsl::cfg::launchCombo2); // Swap the two launch combos
ult::setIniFileValue(ult::ULTRAHAND_CONFIG_INI_PATH, ult::ULTRAHAND_PROJECT_NAME, ult::KEY_COMBO_STR , ult::TESLA_COMBO_STR);
ult::setIniFileValue(ult::TESLA_CONFIG_INI_PATH, ult::TESLA_STR, ult::KEY_COMBO_STR , ult::TESLA_COMBO_STR);
eventFire(&shData->comboEvent);
mainComboHasTriggered.store(true, std::memory_order_release);
ult::updateMenuCombos = false;
}
else if (ult::overlayLaunchRequested.load(std::memory_order_acquire) && !ult::runningInterpreter.load(std::memory_order_acquire) && ult::settingsInitialized.load(std::memory_order_acquire) && (nowNs - startNs) >= FAST_SWAP_THRESHOLD_NS) {
std::string requestedPath, requestedArgs;
// Get the request data safely
{
std::lock_guard<std::mutex> lock(ult::overlayLaunchMutex);
requestedPath = ult::requestedOverlayPath;
requestedArgs = ult::requestedOverlayArgs;
ult::overlayLaunchRequested.store(false, std::memory_order_release);
}
if (!requestedPath.empty()) {
const std::string overlayFileName = ult::getNameFromPath(requestedPath);
// Set overlay state for ovlmenu.ovl
// OPTIMIZED: Batch INI file writes
{
ult::setIniFileValue(ult::ULTRAHAND_CONFIG_INI_PATH, ult::ULTRAHAND_PROJECT_NAME, ult::IN_OVERLAY_STR, ult::TRUE_STR);
ult::setIniFileValue(ult::ULTRAHAND_CONFIG_INI_PATH, ult::ULTRAHAND_PROJECT_NAME, "to_packages", ult::TRUE_STR);
}
// Reset navigation state variables (these control slide navigation)
ult::allowSlide.store(false, std::memory_order_release);
ult::unlockedSlide.store(false, std::memory_order_release);
// Launch the overlay using the same mechanism as key combos
tsl::setNextOverlay(requestedPath, requestedArgs+" --direct");
fireLaunch();
return;
}
}
#endif
// Check overlay key combos (only when overlay is not open, keys are pressed, and not conflicting with main combos)
else if (shData->keysDown != 0 && ult::useLaunchCombos) {
if (shData->keysHeld != tsl::cfg::launchCombo) {
// Lookup both path and optional mode launch args
const auto comboInfo = tsl::hlp::getEntryForKeyCombo(shData->keysHeld);
const std::string& overlayPath = comboInfo.path;
#if IS_LAUNCHER_DIRECTIVE
if (!overlayPath.empty() && (shData->keysHeld) && !ult::runningInterpreter.load(std::memory_order_acquire) && ult::settingsInitialized.load(std::memory_order_acquire) && (armTicksToNs(nowTick) - startNs) >= FAST_SWAP_THRESHOLD_NS) {
#else
if (!overlayPath.empty() && (shData->keysHeld) && (nowNs - startNs) >= FAST_SWAP_THRESHOLD_NS) {
#endif
const std::string& modeArg = comboInfo.launchArg;
const std::string overlayFileName = ult::getNameFromPath(overlayPath);
// Check HOS21 support before doing anything
if (requiresLNY2 && !usingLNY2(overlayPath)) {
// Skip launch if not supported
const auto forceSupportStatus = ult::parseValueFromIniSection(
ult::OVERLAYS_INI_FILEPATH, overlayFileName, "force_support");
if (forceSupportStatus != ult::TRUE_STR) {
if (tsl::notification) {
tsl::notification->showNow(ult::NOTIFY_HEADER+ult::INCOMPATIBLE_WARNING, 22);
}
continue;
}
}
// hideHidden check
if (hideHidden) {
const auto hideStatus = ult::parseValueFromIniSection(
ult::OVERLAYS_INI_FILEPATH, overlayFileName, ult::HIDE_STR);
if (hideStatus == ult::TRUE_STR) {
shData->keysDownPending |= shData->keysDown;
continue;
}
}
#if IS_STATUS_MONITOR_DIRECTIVE
isRendering = false;
leventSignal(&renderingStopEvent);
#endif
#if !IS_LAUNCHER_DIRECTIVE
if (lastOverlayFilename == overlayFileName && lastOverlayMode == modeArg) {
#else
if (lastOverlayFilename == overlayFileName && lastOverlayMode == modeArg && lastOverlayMode.find("--package") != std::string::npos) {
#endif
ult::setIniFileValue(
ult::ULTRAHAND_CONFIG_INI_PATH,
ult::ULTRAHAND_PROJECT_NAME,
ult::IN_OVERLAY_STR,
ult::TRUE_STR
);
tsl::setNextOverlay(ult::OVERLAY_PATH + "ovlmenu.ovl", "--direct --comboReturn");
fireLaunch();
return;
}
// Compose launch args
std::string finalArgs;
if (!modeArg.empty()) {
finalArgs = modeArg;
} else {
// Only check overlay-specific launch args for non-ovlmenu entries
if (overlayFileName.compare("ovlmenu.ovl") != 0) {
// OPTIMIZED: Single INI read for both values
const std::string useArgs = ult::parseValueFromIniSection(ult::OVERLAYS_INI_FILEPATH, overlayFileName, ult::USE_LAUNCH_ARGS_STR);
const std::string launchArgs = ult::parseValueFromIniSection(ult::OVERLAYS_INI_FILEPATH, overlayFileName, ult::LAUNCH_ARGS_STR);
if (useArgs == ult::TRUE_STR) {
finalArgs = launchArgs;
ult::removeQuotes(finalArgs);
}
}
}
if (finalArgs.empty()) {
finalArgs = "--direct";
} else {
finalArgs += " --direct";
}
if (overlayFileName.compare("ovlmenu.ovl") == 0) {
finalArgs += " --comboReturn";
ult::setIniFileValue(ult::ULTRAHAND_CONFIG_INI_PATH, ult::ULTRAHAND_PROJECT_NAME, ult::IN_OVERLAY_STR, ult::TRUE_STR);
}
tsl::setNextOverlay(overlayPath, finalArgs);
fireLaunch();
return;
}
}
}
//#endif
shData->keysDownPending |= shData->keysDown;
}
//20 ms
//s32 idx = 0;
rc = waitObjects(&idx, objects, WaiterObject_Count, 20'000'000ul);
if (R_SUCCEEDED(rc)) {
#if IS_STATUS_MONITOR_DIRECTIVE
if (idx == WaiterObject_HomeButton || idx == WaiterObject_PowerButton) { // Changed condition to exclude capture button
if (shData->overlayOpen && !isValidOverlayMode()) {
tsl::Overlay::get()->hide();
shData->overlayOpen = false;
}
}
#else
if (idx == WaiterObject_HomeButton || idx == WaiterObject_PowerButton) { // Changed condition to exclude capture button
if (shData->overlayOpen) {
tsl::Overlay::get()->hide();
shData->overlayOpen = false;
}
}
#endif
switch (idx) {
case WaiterObject_HomeButton:
eventClear(&homeButtonPressEvent);
break;
case WaiterObject_PowerButton:
eventClear(&powerButtonPressEvent);
// Block feedback thread from touching HID during reinit
hidReinitInProgress.store(true, std::memory_order_seq_cst);
svcSleepThread(20'000'000ULL); // 20ms — let feedback thread finish its current iteration
// Perform any necessary cleanup
hidExit();
// Reinitialize resources
ASSERT_FATAL(hidInitialize()); // Reinitialize HID to reset states
// Reinitialize both controllers
padInitialize(&pad_p1, HidNpadIdType_No1);
padInitialize(&pad_handheld, HidNpadIdType_Handheld);
hidInitializeTouchScreen();
// Update both controllers
padUpdate(&pad_p1);
padUpdate(&pad_handheld);
// Clear shared input state so wake doesn't see phantom held keys
{
std::lock_guard<std::mutex> lock(shData->dataMutex);
shData->keysDown = 0;
shData->keysHeld = 0;
shData->keysDownPending = 0;
shData->touchState = { 0 };
}
triggerInitHaptics.store(true, std::memory_order_release);
hidReinitInProgress.store(false, std::memory_order_seq_cst);
break;
case WaiterObject_CaptureButton:
if (screenshotsAreDisabled) {
eventClear(&captureButtonPressEvent);
break;
}
#if IS_STATUS_MONITOR_DIRECTIVE
const bool inOverlayMode = isValidOverlayMode();
if (inOverlayMode) {
delayUpdate = true;
isRendering = false;
leventSignal(&renderingStopEvent);
}
#endif
ult::disableTransparency = true;
eventClear(&captureButtonPressEvent);
svcSleepThread(1'500'000'000);
ult::disableTransparency = false;
#if IS_STATUS_MONITOR_DIRECTIVE
if (inOverlayMode) {
if (notification && notification->isActive()) {
// Notification is still animating — don't re-enable the frame limiter yet.
// Restore wasRendering so the notification draw loop handles re-enabling when done.
wasRendering = true;
} else {
isRendering = true;
leventClear(&renderingStopEvent);
}
delayUpdate = false;
}
#endif
break;
}
} else if (rc != KERNELRESULT(TimedOut)) {
ASSERT_FATAL(rc);
}
}
//hidExit();
}
/**
* @brief Background event polling loop thread
*
* @param args Used to pass in a pointer to a \ref SharedThreadData struct
*/
static void backgroundFeedbackPoller(void *args) {
while (!feedbackPollerStop.load(std::memory_order_acquire)) {
if (ult::launchingOverlay.load(std::memory_order_acquire))
break;
const u64 nowNs = ult::nowNs();
// --- Haptics ---
if (ult::useHapticFeedback && !disableHaptics.load(std::memory_order_acquire)
&& !hidReinitInProgress.load(std::memory_order_acquire)) {
if (triggerInitHaptics.exchange(false, std::memory_order_acq_rel)) {
ult::initHaptics();
} else {
static u64 lastHapticsCheckNs = 0;
if ((nowNs - lastHapticsCheckNs) >= 300'000'000ULL) {
lastHapticsCheckNs = nowNs;
ult::checkAndReinitHaptics();
}
}
if (triggerRumbleDoubleClick.exchange(false, std::memory_order_acq_rel)) {
triggerRumbleClick.store(false, std::memory_order_release);
ult::rumbleDoubleClick();
} else if (triggerRumbleClick.exchange(false, std::memory_order_acq_rel)) {
ult::rumbleClick();
}
// Must be called every loop to advance timing state
ult::processRumbleStop(nowNs);
ult::processRumbleDoubleClick(nowNs);
} else {
triggerRumbleClick.store(false, std::memory_order_release);
triggerRumbleDoubleClick.store(false, std::memory_order_release);
}
// --- Sound ---
if (!ult::limitedMemory) {
if (!ult::useSoundEffects || disableSound.load(std::memory_order_acquire)) {
triggerNavigationSound.store(false, std::memory_order_release);
triggerEnterSound.store(false, std::memory_order_release);
triggerExitSound.store(false, std::memory_order_release);
triggerWallSound.store(false, std::memory_order_release);
triggerOnSound.store(false, std::memory_order_release);
triggerOffSound.store(false, std::memory_order_release);
triggerSettingsSound.store(false, std::memory_order_release);
triggerMoveSound.store(false, std::memory_order_release);
triggerNotificationSound.store(false, std::memory_order_release);
} else {
if (reloadIfDockedChangedNow.exchange(false, std::memory_order_acq_rel))
ult::Audio::reloadIfDockedChanged();
if (reloadSoundCacheNow.exchange(false, std::memory_order_acq_rel))
ult::Audio::reloadAllSounds();
if (triggerNavigationSound.exchange(false, std::memory_order_acq_rel))
ult::Audio::playNavigateSound();
else if (triggerEnterSound.exchange(false, std::memory_order_acq_rel))
ult::Audio::playEnterSound();
else if (triggerExitSound.exchange(false, std::memory_order_acq_rel))
ult::Audio::playExitSound();
else if (triggerWallSound.exchange(false, std::memory_order_acq_rel))
ult::Audio::playWallSound();
else if (triggerOnSound.exchange(false, std::memory_order_acq_rel))
ult::Audio::playOnSound();
else if (triggerOffSound.exchange(false, std::memory_order_acq_rel))
ult::Audio::playOffSound();
else if (triggerSettingsSound.exchange(false, std::memory_order_acq_rel))
ult::Audio::playSettingsSound();
else if (triggerMoveSound.exchange(false, std::memory_order_acq_rel))
ult::Audio::playMoveSound();
else if (triggerNotificationSound.exchange(false, std::memory_order_acq_rel) && !ult::silenceNotifications)
ult::Audio::playNotificationSound();
}
}
svcSleepThread((ult::useSoundEffects || ult::useHapticFeedback) ? 16'000'000ULL : 160'000'000ULL);
}
}
}
/**
* @brief Creates a new Gui and changes to it
*
* @tparam G Gui to create
* @tparam Args Arguments to pass to the Gui
* @param args Arguments to pass to the Gui
* @return Reference to the newly created Gui
*/
template<typename G, typename ...Args>
std::unique_ptr<tsl::Gui>& changeTo(Args&&... args) {
return Overlay::get()->changeTo<G, Args...>(std::forward<Args>(args)...);
}
template<typename G, typename ...Args>
std::unique_ptr<tsl::Gui>& swapTo(Args&&... args) {
return Overlay::get()->swapTo<G, Args...>(std::forward<Args>(args)...);
}
template<typename G, typename ...Args>
std::unique_ptr<tsl::Gui>& swapTo(SwapDepth depth, Args&&... args) {
return Overlay::get()->swapTo<G, Args...>(depth, std::forward<Args>(args)...);
}
/**
* @brief Pops the top Gui from the stack and goes back to the last one
* @note The Overlay gets closed once there are no more Guis on the stack
*/
inline void goBack(u32 count) {
Overlay::get()->goBack(count);
}
inline void pop(u32 count) {
Overlay::get()->pop(count);
}
/**
* @brief Shifts focus to a specific UI element
*
* Requests focus on the provided element without directional navigation.
* Uses FocusDirection::None to set focus directly on the target element,
* typically centering it in the viewport without triggering navigation effects.
*
* Useful for jumping to specific items programmatically (e.g., after search,
* restoring saved position, or responding to external events).
*
* @param element The element to receive focus
*/
inline void shiftItemFocus(elm::Element* element) {
if (auto& currentGui = Overlay::get()->getCurrentGui()) {
currentGui->requestFocus(element, FocusDirection::None);
}
}
inline std::mutex setNextOverlayMutex;
inline std::string nextOverlayName;
__attribute__((noinline)) inline void setNextOverlay(const std::string& ovlPath, std::string origArgs) {
std::lock_guard lk(setNextOverlayMutex);
char buffer[1024];
char* p = buffer;
char* bufferEnd = buffer + sizeof(buffer) - 1;
const std::string filenameStr = ult::getNameFromPath(ovlPath);
nextOverlayName = filenameStr;
const char* filename = filenameStr.c_str();
while (*filename && p < bufferEnd) *p++ = *filename++;
if (p < bufferEnd) *p++ = ' ';
const char* src = origArgs.c_str();
const char* end = src + origArgs.length();
bool hasSkipCombo = false;
while (src < end && p < bufferEnd) {
while (src < end && *src == ' ' && p < bufferEnd) *p++ = *src++;
if (src >= end || p >= bufferEnd) break;
if (src[0] == '-' && src[1] == '-') {
if (strncmp(src, "--skipCombo", 11) == 0 &&
(src[11] == ' ' || src[11] == '\0')) {
hasSkipCombo = true;
while (src < end && *src != ' ' && p < bufferEnd) *p++ = *src++;
}
else if (strncmp(src, "--foregroundFix", 15) == 0) {
src += 15;
while (src < end && *src == ' ') src++;
if (src < end && (*src == '0' || *src == '1')) src++;
}
else if (strncmp(src, "--lastTitleID", 13) == 0) {
src += 13;
while (src < end && *src == ' ') src++;
while (src < end && *src != ' ' && *src != '\0') src++;
}
else {
while (src < end && *src != ' ' && p < bufferEnd) *p++ = *src++;
}
}
else {
while (src < end && *src != ' ' && p < bufferEnd) *p++ = *src++;
}
}
if (!hasSkipCombo && (p + 12) < bufferEnd) {
memcpy(p, " --skipCombo", 12); p += 12;
}
if ((p + 17) < bufferEnd) {
memcpy(p, " --foregroundFix ", 17); p += 17;
if (p < bufferEnd) {
*p++ = (ult::resetForegroundCheck.load(std::memory_order_acquire) ||
ult::lastTitleID != ult::getTitleIdAsString()) ? '1' : '0';
}
}
if ((p + 15 + (ptrdiff_t)ult::lastTitleID.length()) < bufferEnd) {
memcpy(p, " --lastTitleID ", 15); p += 15;
const char* titleId = ult::lastTitleID.c_str();
while (*titleId && p < bufferEnd) *p++ = *titleId++;
}
if (p >= bufferEnd) p = bufferEnd;
*p = '\0';
envSetNextLoad(ovlPath.c_str(), buffer);
}
struct option_entry {
const char* name;
u8 len;
u8 action;
};
static constexpr struct option_entry options[] = {
{"direct", 6, 1},
{"skipCombo", 9, 2},
{"lastTitleID", 11, 3},
{"foregroundFix", 13, 4},
{"package", 7, 5},
{"lastSelectedItem", 16, 6},
{"comboReturn", 11, 7} // new option
};
/**
* @brief libtesla's main function
* @note Call it directly from main passing in argc and argv and returning it e.g `return tsl::loop<OverlayTest>(argc, argv);`
*
* @tparam TOverlay Your overlay class
* @tparam launchFlags \ref LaunchFlags
* @param argc argc
* @param argv argv
* @return int result
*/
template<typename TOverlay, impl::LaunchFlags launchFlags>
static inline int loop(int argc, char** argv) {
static_assert(std::is_base_of_v<tsl::Overlay, TOverlay>, "tsl::loop expects a type derived from tsl::Overlay");
#if IS_STATUS_MONITOR_DIRECTIVE
leventClear(&renderingStopEvent);
// Status monitor will load heap settings directly in main, so bypass here in loop
#else
ult::currentHeapSize = ult::getCurrentHeapSize();
ult::expandedMemory = ult::currentHeapSize >= ult::OverlayHeapSize::Size_8MB;
ult::limitedMemory = ult::currentHeapSize == ult::OverlayHeapSize::Size_4MB;
// Initialize buffer sizes based on expanded memory setting
if (ult::expandedMemory) {
ult::furtherExpandedMemory = ult::currentHeapSize > ult::OverlayHeapSize::Size_8MB;
if (!ult::furtherExpandedMemory) {
ult::loaderTitle += "+";
ult::COPY_BUFFER_SIZE = 262144;
ult::HEX_BUFFER_SIZE = 8192;
ult::UNZIP_READ_BUFFER = 262144;
ult::UNZIP_WRITE_BUFFER = 131072;
ult::DOWNLOAD_READ_BUFFER = 131072;
ult::DOWNLOAD_WRITE_BUFFER = 131072;
} else {
ult::loaderTitle += "×";
ult::COPY_BUFFER_SIZE = 262144*2;
ult::HEX_BUFFER_SIZE = 8192;
ult::UNZIP_READ_BUFFER = 262144*2;
ult::UNZIP_WRITE_BUFFER = 131072*4;
ult::DOWNLOAD_READ_BUFFER = 131072*4;
ult::DOWNLOAD_WRITE_BUFFER = 131072*4;
}
} else if (ult::limitedMemory) {
ult::loaderTitle += "-";
ult::DOWNLOAD_READ_BUFFER = 16*1024;
ult::UNZIP_READ_BUFFER = 16*1024;
}
#endif
if (argc > 0) {
lastOverlayFilename = ult::getNameFromPath(argv[0]);
lastOverlayMode.clear();
bool skip;
for (u8 arg = 1; arg < argc; arg++) {
const char* s = argv[arg];
skip = false;
if (arg > 1) {
const char* prev = argv[arg - 1];
if (prev[0] == '-' && prev[1] == '-') {
if (strcmp(prev, "--lastTitleID") == 0 || strcmp(prev, "--foregroundFix") == 0) {
skip = true;
}
}
}
if (!skip && s[0] == '-' && s[1] == '-') {
if (strcmp(s, "--direct") == 0 ||
strcmp(s, "--skipCombo") == 0 ||
strcmp(s, "--lastTitleID") == 0 ||
strcmp(s, "--foregroundFix") == 0) {
skip = true;
}
}
if (!skip) {
if (strcmp(s, "--package") == 0) {
lastOverlayMode = "--package";
arg++;
if (arg < argc) {
lastOverlayMode += " ";
lastOverlayMode += argv[arg];
arg++;
while (arg < argc && argv[arg][0] != '-') {
lastOverlayMode += " ";
lastOverlayMode += argv[arg];
arg++;
}
}
} else {
lastOverlayMode = s;
}
break;
}
}
}
bool skipCombo = false;
#if IS_LAUNCHER_DIRECTIVE
bool comboReturn = false;
bool directMode = true;
#else
bool directMode = false;
#endif
bool usingPackageLauncher = false;
for (u8 arg = 0; arg < argc; arg++) {
const char* s = argv[arg];
if (s[0] != '-' || s[1] != '-') continue;
const char* opt = s + 2;
for (u8 i = 0; i < 7; i++) { // now 6 instead of 5
if (memcmp(opt, options[i].name, options[i].len) == 0 && opt[options[i].len] == '\0') {
switch (options[i].action) {
case 1: // direct
directMode = true;
jumpItemName = "";
jumpItemValue = "";
jumpItemExactMatch.store(true, std::memory_order_release);
break;
case 2: // skipCombo
skipCombo = true;
ult::firstBoot = false;
break;
case 3: // lastTitleID
if (++arg < argc) {
const char* providedID = argv[arg];
if (ult::getTitleIdAsString() != providedID) {
ult::resetForegroundCheck.store(true, std::memory_order_release);
}
}
break;
case 4: // foregroundFix
if (++arg < argc) {
ult::resetForegroundCheck.store(
ult::resetForegroundCheck.load(std::memory_order_acquire) ||
(argv[arg][0] == '1'), std::memory_order_release);
}
break;
case 5: // package
usingPackageLauncher = true;
break;
case 6: // lastSelectedItem
#if IS_STATUS_MONITOR_DIRECTIVE
lastMode = "returning";
#endif
break;
case 7: // comboReturn
#if IS_LAUNCHER_DIRECTIVE
comboReturn = true;
#endif
break;
}
}
}
}
impl::SharedThreadData shData;
shData.running.store(true, std::memory_order_release);
auto& overlay = tsl::Overlay::s_overlayInstance;
overlay = new TOverlay();
overlay->m_closeOnExit = (u8(launchFlags) & u8(impl::LaunchFlags::CloseOnExit)) == u8(impl::LaunchFlags::CloseOnExit);
// Parse Tesla settings
impl::parseOverlaySettings();
// Initialize overlay services & screen
tsl::hlp::doWithSmSession([&overlay]{
overlay->initServices();
});
overlay->initScreen();
eventCreate(&shData.comboEvent, false);
Thread backgroundFeedbackThread;
threadCreate(&backgroundFeedbackThread, impl::backgroundFeedbackPoller, nullptr, nullptr, 0x1000, 0x2c, -2);
threadStart(&backgroundFeedbackThread);
Thread backgroundEventThread;
threadCreate(&backgroundEventThread, impl::backgroundEventPoller, &shData, nullptr, 0x2000, 0x2c, -2);
threadStart(&backgroundEventThread);
bool shouldFireEvent = false;
#if IS_LAUNCHER_DIRECTIVE
{
auto configData = ult::getParsedDataFromIniFile(ult::ULTRAHAND_CONFIG_INI_PATH);
bool needsUpdate = false;
// Get reference to project section (create if missing)
auto& project = configData[ult::ULTRAHAND_PROJECT_NAME];
// Determine current overlay state
bool inOverlay = true;
auto it = project.find(ult::IN_OVERLAY_STR);
if (it != project.end()) {
inOverlay = (it->second != ult::FALSE_STR);
}
// Only update the overlay key once, for either firstBoot or skipCombo
if (ult::firstBoot || (inOverlay && skipCombo)) {
project[ult::IN_OVERLAY_STR] = ult::FALSE_STR;
needsUpdate = true;
if (inOverlay && skipCombo) {
shouldFireEvent = true;
}
}
// Write INI only if we changed something
if (needsUpdate) {
ult::saveIniFileData(ult::ULTRAHAND_CONFIG_INI_PATH, configData);
}
// Fire event if needed
if (shouldFireEvent) {
eventFire(&shData.comboEvent);
} else {
lastOverlayFilename = "";
}
}
#else
{
auto configData = ult::getParsedDataFromIniFile(ult::ULTRAHAND_CONFIG_INI_PATH);
auto projectIt = configData.find(ult::ULTRAHAND_PROJECT_NAME);
if (projectIt != configData.end()) {
auto& project = projectIt->second;
auto overlayIt = project.find(ult::IN_OVERLAY_STR);
const bool inOverlay = (overlayIt == project.end() ||
overlayIt->second != ult::FALSE_STR);
if (inOverlay && directMode) {
project[ult::IN_OVERLAY_STR] = ult::FALSE_STR;
ult::saveIniFileData(ult::ULTRAHAND_CONFIG_INI_PATH, configData);
}
}
}
if (skipCombo) {
eventFire(&shData.comboEvent);
shouldFireEvent = true;
}
#endif
overlay->changeTo(overlay->loadInitialGui());
overlay->disableNextAnimation();
{
const Handle handles[2] = { shData.comboEvent.revent, notificationEvent.revent };
s32 index = -1;
bool exitAfterPrompt = false;
bool comboBreakout = false;
bool firstLoop = !ult::firstBoot;
auto exitLaunching = [&]() __attribute__((noinline)) {
shData.running.store(false, std::memory_order_release);
shData.overlayOpen.store(false, std::memory_order_release);
};
while (shData.running.load(std::memory_order_acquire)) {
// Early exit if launching new overlay
if (ult::launchingOverlay.load(std::memory_order_acquire)) {
exitLaunching(); break;
}
// Wait for events only if no active notification
if (!(notification && notification->isActive())) {
svcWaitSynchronization(&index, handles, 2, UINT64_MAX);
}
eventClear(&notificationEvent);
eventClear(&shData.comboEvent);
if ((notification && notification->isActive() && !firstLoop) || index == 1) {
comboBreakout = false;
while (shData.running.load(std::memory_order_acquire)) {
{
if (ult::launchingOverlay.load(std::memory_order_acquire)) {
exitLaunching(); break;
}
overlay->loop(true); // Draw prompts while hidden
// ── Notification touch-dismiss while overlay is hidden ──────────────
{
static bool hiddenTouchWasDown = false;
bool touchNow = false;
HidTouchState tp = {};
{
std::scoped_lock lock(shData.dataMutex);
touchNow = shData.touchState.count > 0;
if (touchNow)
tp = shData.touchState.touches[0];
}
// dataMutex released — safe to call notification methods
// which internally acquire state_mutex_
if (!hiddenTouchWasDown && touchNow) {
if (notification && notification->hitTest(
static_cast<s32>(tp.x), static_cast<s32>(tp.y))) {
notification->dismissAt(
static_cast<s32>(tp.x), static_cast<s32>(tp.y));
}
}
hiddenTouchWasDown = touchNow;
}
// ───────────────────────────────────────────────────────────────────
}
if (mainComboHasTriggered.exchange(false, std::memory_order_acq_rel)) {
comboBreakout = true;
exitAfterPrompt = false;
break;
}
if (launchComboHasTriggered.load(std::memory_order_acquire)) {
exitAfterPrompt = true;
usingPackageLauncher = false;
directMode = false;
break;
}
if (!(notification && notification->isActive())) {
break;
}
}
if (!comboBreakout || !shData.running.load(std::memory_order_acquire)) {
{
if (!ult::launchingOverlay.load(std::memory_order_acquire)) {
overlay->clearScreen();
}
}
if (exitAfterPrompt) {
std::scoped_lock lock(shData.dataMutex);
exitAfterPrompt = false;
exitLaunching();
ult::launchingOverlay.store(true, std::memory_order_release);
launchComboHasTriggered.store(true, std::memory_order_release); // for isolating sound effect
if (usingPackageLauncher || directMode) {
tsl::setNextOverlay(ult::OVERLAY_PATH + "ovlmenu.ovl");
}
hlp::requestForeground(false);
break;
}
continue;
}
}
{
if (ult::launchingOverlay.load(std::memory_order_acquire)) {
exitLaunching(); break;
}
firstLoop = false;
shData.overlayOpen.store(true, std::memory_order_release);
#if IS_STATUS_MONITOR_DIRECTIVE
if (!isValidOverlayMode())
hlp::requestForeground(true);
#else
hlp::requestForeground(true);
#endif
overlay->show();
if (!comboBreakout && !(notification && notification->isActive()))
overlay->clearScreen();
{
std::scoped_lock lock(shData.dataMutex);
// Clear derived states that the overlay loop will use
shData.keysDown = 0;
shData.keysHeld = 0;
// Clear any queued pending keys so nothing gets processed one frame late
shData.keysDownPending = 0;
}
}
while (shData.running.load(std::memory_order_acquire)) {
{
if (ult::launchingOverlay.load(std::memory_order_acquire)) {
shData.running.store(false, std::memory_order_release);
shData.overlayOpen.store(false, std::memory_order_release);
break;
}
overlay->loop();
{
std::scoped_lock lock(shData.dataMutex);
if (!overlay->fadeAnimationPlaying()) {
overlay->handleInput(shData.keysDownPending, shData.keysHeld, shData.touchState.count, shData.touchState.touches[0], shData.joyStickPosLeft, shData.joyStickPosRight);
}
shData.keysDownPending = 0;
}
#if IS_LAUNCHER_DIRECTIVE
if (shouldFireEvent) {
shouldFireEvent = false;
if (!comboReturn) {
triggerExitFeedback();
}
}
#else
if (!directMode && shouldFireEvent) {
shouldFireEvent = false;
#if IS_STATUS_MONITOR_DIRECTIVE
if (lastMode.compare("returning") == 0) {
triggerExitFeedback();
} else {
triggerEnterFeedback();
}
#else
triggerEnterFeedback();
#endif
}
#endif
}
#if IS_STATUS_MONITOR_DIRECTIVE
if (pendingExit && wasRendering) {
pendingExit = false;
if (!(notification && notification->isActive())) {
wasRendering = false;
isRendering = true;
leventClear(&renderingStopEvent);
}
}
#endif
if (overlay->shouldHide()) {
if (overlay->shouldCloseAfter()) {
if (!directMode) {
shData.running.store(false, std::memory_order_release);
shData.overlayOpen.store(false, std::memory_order_release);
break;
} else {
exitAfterPrompt = true;
#if IS_STATUS_MONITOR_DIRECTIVE
pendingExit = true;
#endif
}
}
break;
}
if (overlay->shouldClose()) {
shData.running.store(false, std::memory_order_release);
shData.overlayOpen.store(false, std::memory_order_release);
break;
}
}
if (shData.running.load(std::memory_order_acquire)) {
if (!(notification && notification->isActive()))
overlay->clearScreen();
overlay->resetFlags();
hlp::requestForeground(false);
shData.overlayOpen.store(false, std::memory_order_release);
mainComboHasTriggered.store(false, std::memory_order_release);
eventClear(&shData.comboEvent);
}
}
// Ensure background thread is fully stopped before overlay cleanup
shData.running.store(false, std::memory_order_release);
feedbackPollerStop.store(true, std::memory_order_release);
threadWaitForExit(&backgroundEventThread);
threadClose(&backgroundEventThread);
threadWaitForExit(&backgroundFeedbackThread);
threadClose(&backgroundFeedbackThread);
// Cleanup overlay resources
hlp::requestForeground(false);
overlay->exitScreen();
overlay->exitServices();
delete overlay;
eventClose(&shData.comboEvent);
if (directMode && !launchComboHasTriggered.load(std::memory_order_acquire)) {
if (!disableSound.load(std::memory_order_acquire) && ult::useSoundEffects)
ult::Audio::playExitSound();
if (ult::useHapticFeedback) {
ult::rumbleDoubleClickStandalone();
}
}
return 0;
}
}
}
#ifdef TESLA_INIT_IMPL
namespace tsl::cfg {
u16 LayerWidth = 0;
u16 LayerHeight = 0;
u16 LayerPosX = 0;
u16 LayerPosY = 0;
u16 FramebufferWidth = 0;
u16 FramebufferHeight = 0;
u64 launchCombo = KEY_ZL | KEY_ZR | KEY_DDOWN;
u64 launchCombo2 = KEY_L | KEY_DDOWN | KEY_RSTICK;
}
extern "C" void __libnx_init_time(void);
extern "C" {
u32 __nx_applet_type = AppletType_None;
u32 __nx_fs_num_sessions = 1;
u32 __nx_nv_transfermem_size = 0x15000;
ViLayerFlags __nx_vi_stray_layer_flags = (ViLayerFlags)0;
/**
* @brief libtesla service initializing function to override libnx's
*
*/
void __appInit(void) {
ASSERT_FATAL(smInitialize()); // needed to prevent issues with powering device into sleep
ASSERT_FATAL(fsInitialize());
ASSERT_FATAL(hidInitialize()); // Controller inputs and Touch
if (hosversionAtLeast(16,0,0)) {
ASSERT_FATAL(plInitialize(PlServiceType_User)); // Font data. Use pl:u for 16.0.0+
} else {
ASSERT_FATAL(plInitialize(PlServiceType_System)); // Use pl:s for 15.0.1 and below to prevent qlaunch/overlaydisp session exhaustion
}
ASSERT_FATAL(pmdmntInitialize()); // PID querying
ASSERT_FATAL(hidsysInitialize()); // Focus control
ASSERT_FATAL(setsysInitialize()); // Settings querying
// Time initializations
if R_SUCCEEDED(timeInitialize()) {
__libnx_init_time();
timeExit();
}
#if USING_WIDGET_DIRECTIVE
ult::powerInit();
i2cInitialize();
#endif
fsdevMountSdmc();
splInitialize();
spsmInitialize();
#if IS_STATUS_MONITOR_DIRECTIVE
Service *plSrv = plGetServiceSession();
Service plClone;
ASSERT_FATAL(serviceClone(plSrv, &plClone));
serviceClose(plSrv);
*plSrv = plClone;
#endif
eventCreate(&tsl::notificationEvent, false);
tsl::notification = new tsl::NotificationPrompt();
}
/**
* @brief libtesla service exiting function to override libnx's
*
*/
void __appExit(void) {
delete tsl::notification;
eventClose(&tsl::notificationEvent);
if (!ult::limitedMemory)
ult::Audio::exit();
spsmExit();
splExit();
fsdevUnmountAll();
#if USING_WIDGET_DIRECTIVE
i2cExit();
ult::powerExit();
#endif
fsExit();
hidExit();
plExit();
pmdmntExit();
hidsysExit();
setsysExit();
smExit();
// Final cleanup
tsl::gfx::FontManager::cleanup();
}
}
#endif