Files
Horizon-OC/Source/hoc-clk/overlay/lib/libultrahand/libtesla/source/tesla.cpp
2026-04-01 16:08:42 -04:00

1535 lines
65 KiB
C++

/********************************************************************************
* Custom Fork Information
*
* File: tesla.cpp
* 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
********************************************************************************/
#include <tesla.hpp>
// ---------------------------------------------------------------------------
// usingLNY2 — defined here, not in the header.
// 69-line file I/O + malloc body: inlining it into every TU would bloat every
// object file that includes tesla.hpp. Called from exactly one place.
// ---------------------------------------------------------------------------
bool usingLNY2(const std::string& filePath) {
FILE* file = fopen(filePath.c_str(), "rb");
if (!file)
return false;
// --- Get file size ---
fseek(file, 0, SEEK_END);
const long fileSize = ftell(file);
if (fileSize < (long)(sizeof(NroStart) + sizeof(NroHeader))) {
fclose(file);
return false;
}
const size_t fileSz = (size_t)fileSize;
fseek(file, 0, SEEK_SET);
// --- Read front chunk (header + MOD0 area) ---
constexpr size_t FRONT_READ_SIZE = 8192;
const size_t frontReadSize = (fileSz < FRONT_READ_SIZE) ? fileSz : FRONT_READ_SIZE;
uint8_t* frontBuf = (uint8_t*)malloc(frontReadSize);
if (!frontBuf) {
fclose(file);
return false;
}
if (fread(frontBuf, 1, frontReadSize, file) != frontReadSize) {
free(frontBuf);
fclose(file);
return false;
}
// --- Extract offsets directly (no NroHeader copy needed) ---
const uint32_t mod0_rel = *reinterpret_cast<const uint32_t*>(frontBuf + 0x4);
const uint32_t text_offset = *reinterpret_cast<const uint32_t*>(frontBuf + 0x20);
bool isNew = false;
// --- MOD0 detection ---
if (text_offset < fileSz && mod0_rel != 0 && text_offset <= fileSz - mod0_rel) {
const uint32_t mod0_offset = text_offset + mod0_rel;
// --- MOD0 is inside front buffer ---
if (mod0_offset <= frontReadSize - 60) {
const uint8_t* mod0_ptr = frontBuf + mod0_offset;
if (memcmp(mod0_ptr, "MOD0", 4) == 0 &&
memcmp(mod0_ptr + 52, "LNY2", 4) == 0) {
isNew = (*reinterpret_cast<const uint32_t*>(mod0_ptr + 56) >= 1);
}
}
// --- MOD0 must be read separately ---
else if (mod0_offset <= fileSz - 60) {
uint8_t mod0Buf[60];
fseek(file, mod0_offset, SEEK_SET);
if (fread(mod0Buf, 1, 60, file) == 60) {
if (memcmp(mod0Buf, "MOD0", 4) == 0 &&
memcmp(mod0Buf + 52, "LNY2", 4) == 0) {
isNew = (*reinterpret_cast<const uint32_t*>(mod0Buf + 56) >= 1);
}
}
}
}
free(frontBuf);
fclose(file);
return isNew;
}
namespace tsl {
// ---------------------------------------------------------------------------
// Theme color variables
// ---------------------------------------------------------------------------
Color logoColor1;
Color logoColor2;
size_t defaultBackgroundAlpha = 0;
Color defaultBackgroundColor;
Color defaultTextColor;
Color notificationTextColor;
Color notificationTitleColor;
Color notificationTimeColor;
Color headerTextColor;
Color headerSeparatorColor;
Color starColor;
Color selectionStarColor;
Color buttonColor;
Color bottomTextColor;
Color bottomSeparatorColor;
Color unfocusedColor;
Color topSeparatorColor;
Color defaultOverlayColor;
Color defaultPackageColor;
Color defaultScriptColor;
Color clockColor;
Color temperatureColor;
Color batteryColor;
Color batteryChargingColor;
Color batteryLowColor;
size_t widgetBackdropAlpha = 0;
Color widgetBackdropColor;
Color overlayTextColor;
Color ultOverlayTextColor;
Color packageTextColor;
Color ultPackageTextColor;
Color bannerVersionTextColor;
Color overlayVersionTextColor;
Color ultOverlayVersionTextColor;
Color packageVersionTextColor;
Color ultPackageVersionTextColor;
Color onTextColor;
Color offTextColor;
//#if IS_LAUNCHER_DIRECTIVE
Color dynamicLogoRGB1;
Color dynamicLogoRGB2;
//#endif
bool invertBGClickColor = false;
size_t selectionBGAlpha = 0;
Color selectionBGColor;
Color highlightColor1;
Color highlightColor2;
Color highlightColor3;
Color highlightColor4;
// Has a meaningful default — initialized to ColorHighlight
Color s_highlightColor = tsl::style::color::ColorHighlight;
size_t clickAlpha = 0;
Color clickColor;
size_t progressAlpha = 0;
Color progressColor;
Color scrollBarColor;
Color scrollBarWallColor;
size_t separatorAlpha = 0;
Color separatorColor;
// Constant — initialized once here via RGB888()
const Color edgeSeparatorColor = RGB888("303030");
Color textSeparatorColor;
Color selectedTextColor;
Color selectedValueTextColor;
Color inprogressTextColor;
Color invalidTextColor;
Color clickTextColor;
size_t tableBGAlpha = 0;
Color tableBGColor;
Color sectionTextColor;
Color infoTextColor;
Color warningTextColor;
Color healthyRamTextColor;
Color neutralRamTextColor;
Color badRamTextColor;
Color trackBarSliderColor;
Color trackBarSliderBorderColor;
Color trackBarSliderMalleableColor;
Color trackBarFullColor;
Color trackBarEmptyColor;
// Prepare a map of default settings
constexpr ThemeDefault defaultThemeSettings[] = {
// Must stay sorted alphabetically for binary search
{"bad_ram_text_color", "FF0000"},
{"banner_version_text_color", "AAAAAA"},
{"battery_charging_color", "00FF00"},
{"battery_color", "FFFF45"},
{"battery_low_color", "FF0000"},
{"bg_alpha", "13"},
{"bg_color", "000000"},
{"bottom_button_color", "FFFFFF"},
{"bottom_separator_color", "FFFFFF"},
{"bottom_text_color", "FFFFFF"},
{"unfocused_color", "666666"},
{"click_alpha", "7"},
{"click_color", "3E25F7"},
{"click_text_color", "FFFFFF"},
{"clock_color", "FFFFFF"},
{"default_overlay_color", "FFFFFF"},
{"default_package_color", "FFFFFF"},
{"default_script_color", "FF33FF"},
{"dynamic_logo_color_1", "00E669"},
{"dynamic_logo_color_2", "8080EA"},
{"header_separator_color", "FFFFFF"},
{"header_text_color", "FFFFFF"},
{"healthy_ram_text_color", "00FF00"},
{"highlight_color_1", "2288CC"},
{"highlight_color_2", "88FFFF"},
{"highlight_color_3", "FFFF45"},
{"highlight_color_4", "F7253E"},
{"inprogress_text_color", "FFFF45"},
{"invalid_text_color", "FF0000"},
{"invert_bg_click_color", "false"},
{"logo_color_1", "FFFFFF"},
{"logo_color_2", "FF0000"},
{"neutral_ram_text_color", "FFAA00"},
{"notification_text_color", "FFFFFF"},
{"notification_title_color", "FFFFFF"},
{"notification_time_color", "AAAAAA"},
{"off_text_color", "AAAAAA"},
{"on_text_color", "00FFDD"},
{"overlay_text_color", "FFFFFF"},
{"overlay_version_text_color", "AAAAAA"},
{"package_text_color", "FFFFFF"},
{"package_version_text_color", "AAAAAA"},
{"progress_alpha", "7"},
{"progress_color", "253EF7"},
{"scrollbar_color", "555555"},
{"scrollbar_wall_color", "AAAAAA"},
{"selection_bg_alpha", "11"},
{"selection_bg_color", "000000"},
{"selection_star_color", "FFFFFF"},
{"selection_text_color", "9ED0FF"},
{"selection_value_text_color", "FF7777"},
{"separator_alpha", "15"},
{"separator_color", "404040"},
{"star_color", "FFFFFF"},
{"table_bg_alpha", "14"},
{"table_bg_color", "2C2C2C"},
{"table_info_text_color", "9ED0FF"},
{"table_section_text_color", "FFFFFF"},
{"temperature_color", "FFFFFF"},
{"text_color", "FFFFFF"},
{"text_separator_color", "404040"},
{"top_separator_color", "404040"},
{"trackbar_empty_color", "404040"},
{"trackbar_full_color", "00FFDD"},
{"trackbar_slider_border_color", "505050"},
{"trackbar_slider_color", "606060"},
{"trackbar_slider_malleable_color", "A0A0A0"},
{"ult_overlay_text_color", "9ED0FF"},
{"ult_overlay_version_text_color", "00FFDD"},
{"ult_package_text_color", "9ED0FF"},
{"ult_package_version_text_color", "00FFDD"},
{"warning_text_color", "FF7777"},
{"widget_backdrop_alpha", "15"},
{"widget_backdrop_color", "000000"},
};
const size_t defaultThemeSettingsCount = sizeof(defaultThemeSettings) / sizeof(defaultThemeSettings[0]);
const char* getThemeDefault(const char* key) {
size_t lo = 0, hi = defaultThemeSettingsCount;
while (lo < hi) {
const size_t mid = (lo + hi) / 2;
const int cmp = strcmp(defaultThemeSettings[mid].key, key);
if (cmp == 0) return defaultThemeSettings[mid].value;
if (cmp < 0) lo = mid + 1;
else hi = mid;
}
return "";
}
bool isValidHexColor(std::string_view s) {
if (s.size() != 6) return false;
for (char c : s) if (!isxdigit(c)) return false;
return true;
}
// ---------------------------------------------------------------------------
// initializeThemeVars
// Reads theme INI and populates all color vars above.
// Falls through to getThemeDefault() for any missing key so colors are always
// valid even when no theme file exists.
// ---------------------------------------------------------------------------
void initializeThemeVars() {
auto themeData = ult::getParsedDataFromIniFile(ult::THEME_CONFIG_INI_PATH);
auto getValue = [&](const char* key) -> const char* {
auto sectionIt = themeData.find(ult::THEME_STR);
if (sectionIt != themeData.end()) {
auto it = sectionIt->second.find(key);
if (it != sectionIt->second.end()) return it->second.c_str();
}
return getThemeDefault(key);
};
auto getColor = [&](const char* key, size_t alpha = 15) {
return RGB888(getValue(key), alpha);
};
auto getAlpha = [&](const char* key) {
return ult::stoi(getValue(key));
};
logoColor1 = getColor("logo_color_1");
logoColor2 = getColor("logo_color_2");
//#if IS_LAUNCHER_DIRECTIVE
dynamicLogoRGB1 = getColor("dynamic_logo_color_1");
dynamicLogoRGB2 = getColor("dynamic_logo_color_2");
//#endif
defaultBackgroundAlpha = getAlpha("bg_alpha");
defaultBackgroundColor = getColor("bg_color", defaultBackgroundAlpha);
defaultTextColor = getColor("text_color");
notificationTextColor = getColor("notification_text_color");
notificationTitleColor = getColor("notification_title_color");
notificationTimeColor = getColor("notification_time_color");
headerTextColor = getColor("header_text_color");
headerSeparatorColor = getColor("header_separator_color");
starColor = getColor("star_color");
selectionStarColor = getColor("selection_star_color");
buttonColor = getColor("bottom_button_color");
bottomTextColor = getColor("bottom_text_color");
bottomSeparatorColor = getColor("bottom_separator_color");
unfocusedColor = getColor("unfocused_color");
topSeparatorColor = getColor("top_separator_color");
defaultOverlayColor = getColor("default_overlay_color");
defaultPackageColor = getColor("default_package_color");
defaultScriptColor = getColor("default_script_color");
clockColor = getColor("clock_color");
temperatureColor = getColor("temperature_color");
batteryColor = getColor("battery_color");
batteryChargingColor = getColor("battery_charging_color");
batteryLowColor = getColor("battery_low_color");
widgetBackdropAlpha = getAlpha("widget_backdrop_alpha");
widgetBackdropColor = getColor("widget_backdrop_color", widgetBackdropAlpha);
overlayTextColor = getColor("overlay_text_color");
ultOverlayTextColor = getColor("ult_overlay_text_color");
packageTextColor = getColor("package_text_color");
ultPackageTextColor = getColor("ult_package_text_color");
bannerVersionTextColor = getColor("banner_version_text_color");
overlayVersionTextColor = getColor("overlay_version_text_color");
ultOverlayVersionTextColor = getColor("ult_overlay_version_text_color");
packageVersionTextColor = getColor("package_version_text_color");
ultPackageVersionTextColor = getColor("ult_package_version_text_color");
onTextColor = getColor("on_text_color");
offTextColor = getColor("off_text_color");
invertBGClickColor = (getValue("invert_bg_click_color") == ult::TRUE_STR);
selectionBGAlpha = getAlpha("selection_bg_alpha");
selectionBGColor = getColor("selection_bg_color", selectionBGAlpha);
highlightColor1 = getColor("highlight_color_1");
highlightColor2 = getColor("highlight_color_2");
highlightColor3 = getColor("highlight_color_3");
highlightColor4 = getColor("highlight_color_4");
clickAlpha = getAlpha("click_alpha");
clickColor = getColor("click_color", clickAlpha);
progressAlpha = getAlpha("progress_alpha");
progressColor = getColor("progress_color", progressAlpha);
scrollBarColor = getColor("scrollbar_color");
scrollBarWallColor = getColor("scrollbar_wall_color");
separatorAlpha = getAlpha("separator_alpha");
separatorColor = getColor("separator_color", separatorAlpha);
textSeparatorColor = getColor("text_separator_color");
selectedTextColor = getColor("selection_text_color");
selectedValueTextColor = getColor("selection_value_text_color");
inprogressTextColor = getColor("inprogress_text_color");
invalidTextColor = getColor("invalid_text_color");
clickTextColor = getColor("click_text_color");
tableBGAlpha = getAlpha("table_bg_alpha");
tableBGColor = getColor("table_bg_color", tableBGAlpha);
sectionTextColor = getColor("table_section_text_color");
infoTextColor = getColor("table_info_text_color");
warningTextColor = getColor("warning_text_color");
healthyRamTextColor = getColor("healthy_ram_text_color");
neutralRamTextColor = getColor("neutral_ram_text_color");
badRamTextColor = getColor("bad_ram_text_color");
trackBarSliderColor = getColor("trackbar_slider_color");
trackBarSliderBorderColor = getColor("trackbar_slider_border_color");
trackBarSliderMalleableColor = getColor("trackbar_slider_malleable_color");
trackBarFullColor = getColor("trackbar_full_color");
trackBarEmptyColor = getColor("trackbar_empty_color");
}
void initializeTheme(const std::string& themeIniPath) {
auto themeData = ult::getParsedDataFromIniFile(themeIniPath);
auto& themeSection = themeData[ult::THEME_STR];
bool needsUpdate = false;
const bool hasThemeSection = ult::isFile(themeIniPath) && (themeData.count(ult::THEME_STR) > 0);
for (size_t i = 0; i < tsl::defaultThemeSettingsCount; ++i) {
const auto& setting = tsl::defaultThemeSettings[i];
if (!hasThemeSection || themeSection.count(setting.key) == 0) {
themeSection[setting.key] = setting.value;
needsUpdate = true;
}
}
if (needsUpdate)
ult::saveIniFileData(themeIniPath, themeData);
if (!ult::isDirectory(ult::THEMES_PATH))
ult::createDirectory(ult::THEMES_PATH);
}
namespace gfx {
// Updated thread-safe calculateStringWidth function
float calculateStringWidth(const std::string& originalString, const float fontSize, const bool monospace) {
if (originalString.empty() || !FontManager::isInitialized()) {
return 0.0f;
}
// 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;
}
}
// CRITICAL: Use the same data types as drawString
s32 maxWidth = 0;
s32 currentLineWidth = 0;
ssize_t codepointWidth;
u32 currCharacter = 0;
// Convert fontSize to u32 to match drawString behavior
const u32 fontSizeInt = static_cast<u32>(fontSize);
auto itStrEnd = text.cend();
auto itStr = text.cbegin();
// Fast ASCII check
bool isAsciiOnly = true;
for (unsigned char c : text) {
if (c > 127) {
isAsciiOnly = false;
break;
}
}
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;
}
itStr += codepointWidth;
// Handle newlines
if (currCharacter == '\n') {
maxWidth = std::max(currentLineWidth, maxWidth);
currentLineWidth = 0;
continue;
}
// Use u32 fontSize to match drawString - now thread-safe
std::shared_ptr<FontManager::Glyph> glyph = FontManager::getOrCreateGlyph(currCharacter, monospace, fontSizeInt);
if (!glyph) continue;
// CRITICAL: Use the same calculation as drawString
currentLineWidth += static_cast<s32>(glyph->xAdvance * glyph->currFontSize);
}
// Final width calculation
maxWidth = std::max(currentLineWidth, maxWidth);
return static_cast<float>(maxWidth);
}
}
namespace hlp {
/**
* @brief Toggles focus between the Tesla overlay and the rest of the system
*
* @param enabled Focus Tesla?
*/
void requestForeground(bool enabled, bool updateGlobalFlag) {
if (updateGlobalFlag)
ult::currentForeground.store(enabled, std::memory_order_release);
u64 applicationAruid = 0, appletAruid = 0;
for (u64 programId = 0x0100000000001000UL; programId < 0x0100000000001020UL; programId++) {
pmdmntGetProcessId(&appletAruid, programId);
if (appletAruid != 0)
hidsysEnableAppletToGetInput(!enabled, appletAruid);
}
pmdmntGetApplicationProcessId(&applicationAruid);
hidsysEnableAppletToGetInput(!enabled, applicationAruid);
hidsysEnableAppletToGetInput(true, 0);
}
namespace ini {
/**
* @brief Unparses ini data into a string
*
* @param iniData Ini data
* @return Ini string
*/
std::string unparseIni(const IniData &iniData) {
std::string result;
bool addSectionGap = false;
for (const auto &section : iniData) {
if (addSectionGap) {
result += '\n';
}
result += '[' + section.first + "]\n";
for (const auto &keyValue : section.second) {
result += keyValue.first + '=' + keyValue.second + '\n';
}
addSectionGap = true;
}
return result;
}
}
/**
* @brief Encodes key codes into a combo string
*
* @param keys Key codes
* @return Combo string
*/
std::string keysToComboString(u64 keys) {
if (keys == 0) return ""; // Early return for empty input
std::string result;
bool first = true;
for (const auto &keyInfo : ult::KEYS_INFO) {
if (keys & keyInfo.key) {
if (!first) {
result += "+";
}
result += keyInfo.name;
first = false;
}
}
return result;
}
// Function to load key combo mappings from both overlays.ini and packages.ini
void loadEntryKeyCombos() {
std::lock_guard<std::mutex> lock(comboMutex);
ult::g_entryCombos.clear();
// Load overlay combos from overlays.ini
auto overlayData = ult::getParsedDataFromIniFile(ult::OVERLAYS_INI_FILEPATH);
std::string fullPath;
u64 keys;
std::vector<std::string> modeList, comboList;
for (auto& [fileName, settings] : overlayData) {
fullPath = ult::OVERLAY_PATH + fileName;
// 1) main key_combo
if (auto it = settings.find(ult::KEY_COMBO_STR); it != settings.end() && !it->second.empty()) {
keys = hlp::comboStringToKeys(it->second);
if (keys) ult::g_entryCombos[keys] = { fullPath, "" };
}
// 2) per-mode combos
auto modesIt = settings.find("mode_args");
auto argsIt = settings.find("mode_combos");
if (modesIt != settings.end()) {
modeList = ult::splitIniList(modesIt->second);
comboList = (argsIt != settings.end())
? ult::splitIniList(argsIt->second)
: std::vector<std::string>();
if (comboList.size() < modeList.size())
comboList.resize(modeList.size());
for (size_t i = 0; i < modeList.size(); ++i) {
const std::string& comboStr = comboList[i];
if (comboStr.empty()) continue;
keys = hlp::comboStringToKeys(comboStr);
if (!keys) continue;
// launchArg is the *mode* (i.e. modeList[i])
ult::g_entryCombos[keys] = { fullPath, modeList[i] };
}
}
}
// Load package combos from packages.ini
auto packageData = ult::getParsedDataFromIniFile(ult::PACKAGES_INI_FILEPATH);
for (auto& [packageName, settings] : packageData) {
// Only handle main key_combo for packages (no modes for packages)
if (auto it = settings.find(ult::KEY_COMBO_STR); it != settings.end() && !it->second.empty()) {
keys = hlp::comboStringToKeys(it->second);
if (keys) ult::g_entryCombos[keys] = { ult::OVERLAY_PATH + "ovlmenu.ovl", "--package " + packageName};
}
}
}
}
namespace impl {
/**
* @brief Extract values from Tesla settings file
*
*/
void parseOverlaySettings() {
const auto section = ult::getKeyValuePairsFromSection(
ult::ULTRAHAND_CONFIG_INI_PATH, ult::ULTRAHAND_PROJECT_NAME);
auto getBool = [&](const char* key, bool def = false) -> bool {
auto it = section.find(key);
if (it == section.end() || it->second.empty()) return def;
return it->second != ult::FALSE_STR;
};
auto getStr = [&](const char* key, const char* def = "") -> std::string {
auto it = section.find(key);
return (it != section.end() && !it->second.empty()) ? it->second : def;
};
// Key combo — ultrahand first, tesla as fallback
u64 decodedKeys = hlp::comboStringToKeys(getStr(ult::KEY_COMBO_STR.c_str()));
if (!decodedKeys)
decodedKeys = hlp::comboStringToKeys(
ult::parseValueFromIniSection(ult::TESLA_CONFIG_INI_PATH, ult::TESLA_STR, ult::KEY_COMBO_STR));
if (decodedKeys) tsl::cfg::launchCombo = decodedKeys;
// Datetime format
ult::datetimeFormat = getStr("datetime_format", ult::DEFAULT_DT_FORMAT.c_str());
ult::removeQuotes(ult::datetimeFormat);
if (ult::datetimeFormat.empty()) ult::datetimeFormat = ult::DEFAULT_DT_FORMAT;
// Language
std::string lang = getStr(ult::DEFAULT_LANG_STR.c_str(), "en");
if (lang.empty()) lang = "en";
#ifdef UI_OVERRIDE_PATH
{
std::string UI_PATH = UI_OVERRIDE_PATH;
ult::preprocessPath(UI_PATH);
ult::createDirectory(UI_PATH);
const std::string langOverride = UI_PATH + "lang/" + lang + ".json";
if (ult::isFile(UI_PATH + "theme.ini")) ult::THEME_CONFIG_INI_PATH = UI_PATH + "theme.ini";
if (ult::isFile(UI_PATH + "wallpaper.rgba")) ult::WALLPAPER_PATH = UI_PATH + "wallpaper.rgba";
if (ult::isFile(langOverride)) ult::loadTranslationsFromJSON(langOverride);
}
#endif
// Behavior flags — shared across all overlays
ult::useLaunchCombos = getBool("launch_combos", true);
ult::useNotifications = getBool("notifications", true);
ult::useNotificationsHotkey = getBool("notifications_hotkey", true);
ult::silenceNotifications = getBool("silence_notifications");
ult::useSoundEffects = getBool("sound_effects", true);
ult::useHapticFeedback = getBool("haptic_feedback");
ult::useSwipeToOpen = getBool("swipe_to_open", true);
ult::useOpaqueScreenshots = getBool("opaque_screenshots", true);
// max_notifications — default to 3 so it's always clamped correctly
{
const std::string maxStr = getStr("max_notifications", "3");
const int cap = ult::limitedMemory ? 4 : tsl::NotificationPrompt::MAX_VISIBLE;
tsl::maxNotifications = std::max(1, std::min(ult::stoi(maxStr), cap));
}
// Widget / display flags — shared
ult::hideClock = getBool("hide_clock");
ult::hideBattery = getBool("hide_battery", true);
ult::hidePCBTemp = getBool("hide_pcb_temp", true);
ult::hideSOCTemp = getBool("hide_soc_temp", true);
ult::dynamicWidgetColors = getBool("dynamic_widget_colors", true);
ult::hideWidgetBackdrop = getBool("hide_widget_backdrop");
ult::centerWidgetAlignment = getBool("center_widget_alignment", true);
ult::extendedWidgetBackdrop = getBool("extended_widget_backdrop");
ult::useDynamicLogo = getBool("dynamic_logo", true);
ult::useSelectionBG = getBool("selection_bg", true);
ult::useSelectionText = getBool("selection_text");
ult::useSelectionValue = getBool("selection_value");
#if IS_LAUNCHER_DIRECTIVE
// Launcher-only vars that live in ult:: or global scope — readable here
hideHidden = getBool("hide_hidden");
ult::usePageSwap = getBool("page_swap");
ult::useRightAlignment = getBool("right_alignment");
#endif
// Notifications flag file
if (ult::useNotifications) {
if (!ult::isFile(ult::NOTIFICATIONS_FLAG_FILEPATH))
if (FILE* f = fopen(ult::NOTIFICATIONS_FLAG_FILEPATH.c_str(), "w")) fclose(f);
} else {
ult::deleteFileOrDirectory(ult::NOTIFICATIONS_FLAG_FILEPATH);
}
// Theme and language
const std::string langFile = ult::LANG_PATH + lang + ".json";
if (ult::isFile(langFile)) ult::parseLanguage(langFile);
else ult::reinitializeLangVars();
tsl::initializeTheme();
tsl::initializeThemeVars();
}
}
// Max notifications cap (max value of 4 on limited memory, 8 otherwise)
int maxNotifications = 3;
// Returns true for scripts where every character is a standalone word unit
static bool isWordPerCharScript(u32 cp) {
return (cp >= 0x1100 && cp <= 0x11FF) // Hangul Jamo
|| (cp >= 0x2E80 && cp <= 0x2FFF) // CJK radicals, Kangxi
|| (cp >= 0x3000 && cp <= 0x303F) // CJK symbols & punctuation
|| (cp >= 0x3040 && cp <= 0x30FF) // Hiragana + Katakana
|| (cp >= 0x3100 && cp <= 0x318F) // Bopomofo + Hangul compat jamo
|| (cp >= 0x3200 && cp <= 0x33FF) // Enclosed/compat CJK
|| (cp >= 0x3400 && cp <= 0x4DBF) // CJK extension A
|| (cp >= 0x4E00 && cp <= 0x9FFF) // CJK unified ideographs
|| (cp >= 0xA000 && cp <= 0xA4FF) // Yi
|| (cp >= 0xA960 && cp <= 0xA97F) // Hangul Jamo extended A
|| (cp >= 0xAC00 && cp <= 0xD7FF) // Hangul syllables + Jamo extended B
|| (cp >= 0xF900 && cp <= 0xFAFF) // CJK compatibility ideographs
|| (cp >= 0xFE30 && cp <= 0xFE4F) // CJK compatibility forms
|| (cp >= 0x20000 && cp <= 0x2A6DF) // CJK extension B
|| (cp >= 0x2A700 && cp <= 0x2CEAF) // CJK extensions C/D/E
|| (cp >= 0x2CEB0 && cp <= 0x2EBEF) // CJK extension F
|| (cp >= 0x30000 && cp <= 0x3134F); // CJK extension G
}
static bool isLineStartForbidden(u32 cp) {
switch (cp) {
case 0x3001: case 0x3002: case 0xFF0C: case 0xFF0E:
case 0xFF1A: case 0xFF1B: case 0xFF01: case 0xFF1F:
case 0x30FB: case 0xFF65: case 0xFF09: case 0x3015:
case 0x3011: case 0x3009: case 0x300B: case 0x3003:
case 0x300D: case 0x300F: case 0x2026: case 0x2014:
case 0xFF5D: case 0x30FC:
return true;
default:
return false;
}
}
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
) {
if (wrappingMode == "none" || (wrappingMode != "char" && wrappingMode != ult::WORD_STR))
return { text };
std::vector<std::string> wrappedLines;
bool firstLine = true;
std::string currentLine;
auto getLeadingSpaces = [&]() -> size_t {
if (wrappedLines.empty()) return 0;
const std::string& s = wrappedLines.back();
size_t i = 0;
while (i < s.size() && s[i] == ' ') ++i;
return i;
};
auto pushLine = [&](const std::string& line) {
if (useIndent && !firstLine) {
wrappedLines.push_back(
wrappedLines.back().substr(0, getLeadingSpaces()) + indent + line
);
} else {
wrappedLines.push_back(line);
}
};
auto currentMaxWidth = [&]() -> float {
if (firstLine) return maxWidth;
if (!useIndent) return maxWidth - indentWidth;
const size_t spaces = getLeadingSpaces();
const float gapWidth = tsl::gfx::calculateStringWidth(
wrappedLines.empty() ? "" : wrappedLines.back().substr(0, spaces),
fontSize, false
);
return maxWidth - gapWidth - indentWidth;
};
// Shared helper: split a UTF-8 range [begin, end) character-by-character
// with simple trailing-hyphen logic. Used by word mode when a single word
// is too wide to fit on any line. The char-mode path is handled separately
// because it needs script-aware needsHyphen and isLineStartForbidden logic
// that diverges significantly from this simpler version.
auto splitLongWord = [&](std::string::const_iterator begin,
std::string::const_iterator end) {
auto it = begin;
while (it != end) {
u32 cp;
const ssize_t cw = decode_utf8(&cp, reinterpret_cast<const u8*>(&(*it)));
if (cw <= 0) break;
const std::string charStr(it, it + cw);
if (tsl::gfx::calculateStringWidth(currentLine + charStr, fontSize, false) > currentMaxWidth()
&& !currentLine.empty()) {
const bool moreRemain = (it + cw) != end;
pushLine(moreRemain ? currentLine + '-' : currentLine);
currentLine = charStr;
firstLine = false;
} else {
currentLine += charStr;
}
it += cw;
}
};
if (wrappingMode == "char") {
static constexpr char hyphen = '-';
u32 prevCharacter = 0;
u32 prevPrevCharacter = 0;
auto itStr = text.cbegin();
const auto itStrEnd = text.cend();
while (itStr != itStrEnd) {
u32 currCharacter;
const ssize_t codepointWidth = decode_utf8(&currCharacter, reinterpret_cast<const u8*>(&(*itStr)));
if (codepointWidth <= 0) break;
std::string charStr(itStr, itStr + codepointWidth);
const bool overflows = tsl::gfx::calculateStringWidth(currentLine + charStr, fontSize, false) > currentMaxWidth();
if (overflows && !currentLine.empty()) {
if (isLineStartForbidden(currCharacter)) {
pushLine(currentLine + charStr);
currentLine.clear();
} else {
const bool needsHyphen = !useIndent
&& (prevCharacter != ' ')
&& (currCharacter != ' ')
&& !isWordPerCharScript(prevCharacter)
&& !isWordPerCharScript(currCharacter);
if (needsHyphen) {
std::string withHyphen = currentLine + hyphen;
if (tsl::gfx::calculateStringWidth(withHyphen, fontSize, false) > currentMaxWidth()) {
// Back off one codepoint to make room for the hyphen
auto it = currentLine.end();
while (it != currentLine.begin()) {
--it;
if ((*it & 0xC0) != 0x80) break;
}
charStr = std::string(it, currentLine.end()) + charStr;
currentLine.erase(it, currentLine.end());
withHyphen = (prevPrevCharacter != 0 && prevPrevCharacter != ' ')
? currentLine + hyphen
: currentLine;
}
pushLine(withHyphen);
} else {
pushLine(currentLine);
}
currentLine = (currCharacter == ' ') ? std::string{} : charStr;
}
firstLine = false;
} else if (!overflows) {
currentLine += charStr;
} else {
// overflows but currentLine is empty — start fresh with this char
currentLine = charStr;
firstLine = false;
}
prevPrevCharacter = prevCharacter;
prevCharacter = currCharacter;
itStr += codepointWidth;
}
} else { // word mode
ult::StringStream stream(text);
std::string currentWord;
std::string testLine;
while (stream.getline(currentWord, ' ')) {
u32 firstCp = 0;
decode_utf8(&firstCp, reinterpret_cast<const u8*>(currentWord.c_str()));
if (isWordPerCharScript(firstCp)) {
// CJK and similar scripts: every character is its own word unit
auto itW = currentWord.cbegin();
const auto itWEnd = currentWord.cend();
bool firstChar = true;
while (itW != itWEnd) {
u32 cp;
const ssize_t w = decode_utf8(&cp, reinterpret_cast<const u8*>(&(*itW)));
if (w <= 0) break;
std::string charStr(itW, itW + w);
testLine = currentLine;
if (firstChar && !testLine.empty()) testLine.push_back(' ');
testLine += charStr;
if (tsl::gfx::calculateStringWidth(testLine, fontSize, false) > currentMaxWidth()) {
if (!currentLine.empty()) {
pushLine(isLineStartForbidden(cp) ? currentLine + charStr : currentLine);
currentLine = isLineStartForbidden(cp) ? std::string{} : charStr;
} else {
currentLine = charStr;
}
firstLine = false;
} else {
currentLine = testLine;
}
firstChar = false;
itW += w;
}
} else {
testLine = currentLine;
if (!testLine.empty()) testLine.push_back(' ');
testLine += currentWord;
if (tsl::gfx::calculateStringWidth(testLine, fontSize, false) > currentMaxWidth()) {
if (!currentLine.empty()) {
pushLine(currentLine);
currentLine.clear();
firstLine = false;
}
// Word is too wide for any single line — split char-by-char with hyphenation
splitLongWord(currentWord.cbegin(), currentWord.cend());
} else {
currentLine.swap(testLine);
}
}
}
}
if (!currentLine.empty())
pushLine(currentLine);
return wrappedLines;
}
// ── NotificationPrompt out-of-line definitions ───────────────────────────────
static const std::string s_emptyStr;
bool NotificationPrompt::hasActiveFile(std::string_view fname) const {
if (fname.empty()) return false;
if (isStale()) return false;
std::lock_guard<std::mutex> lg(state_mutex_);
for (int i = 0; i < maxNotifications; ++i)
if ((slots_[i].flags & SLOT_ACTIVE) && slots_[i].data.fileName == fname) return true;
return false;
}
int NotificationPrompt::findHitSlot_NoLock(s32 tx, s32 ty) const {
// In windowed mode the layer is offset in touch space; layerEdge already
// accounts for X. Subtract layerEdgeY to bring ty into framebuffer-local space.
ty -= layerEdgeY;
const s32 sx = (ult::useRightAlignment
? static_cast<s32>(tsl::cfg::FramebufferWidth) - NOTIF_WIDTH
: 0) + static_cast<s32>(ult::layerEdge);
for (int i = 0; i < maxNotifications; ++i) {
const Slot& slot = slots_[i];
if (!(slot.flags & SLOT_ACTIVE) || slot.data.state == PromptState::Inactive) continue;
const s32 sy = static_cast<s32>(slot.yCurrent);
const s32 sh = getEffectiveHeight(slot);
if (tx >= sx && tx < sx + NOTIF_WIDTH &&
ty >= sy && ty < sy + sh)
return i;
}
return -1;
}
void NotificationPrompt::draw(gfx::Renderer* renderer, bool promptOnly) {
if (isStale()) return;
if (!enabled_.load(std::memory_order_acquire)) return;
std::lock_guard<std::mutex> lg(state_mutex_);
const u64 now = ult::nowNs();
for (int i = 0; i < maxNotifications; ++i) {
const Slot& slot = slots_[i];
if (!(slot.flags & SLOT_ACTIVE) || slot.data.text.empty() ||
slot.data.state == PromptState::Inactive) continue;
float fadeAlpha = 1.0f;
if (slot.data.state != PromptState::Visible) {
const float t = std::min(1.0f,
static_cast<float>((now - slot.data.stateStartNs) / 1'000'000ULL) / FADE_DURATION_MS);
fadeAlpha = easeInOut(slot.data.state == PromptState::FadingIn ? t : 1.0f - t);
}
drawSlot(renderer, slot, static_cast<s32>(slot.yCurrent), fadeAlpha, promptOnly);
}
}
void NotificationPrompt::update() {
std::lock_guard<std::mutex> lg(state_mutex_);
if (isStale()) { clearAll_NoLock(); return; }
if (ult::launchingOverlay.load(std::memory_order_acquire)) return;
const u64 now = ult::nowNs();
bool anyCleared = false;
for (int i = 0; i < maxNotifications; ++i) {
Slot& slot = slots_[i];
if (!(slot.flags & SLOT_ACTIVE)) continue;
const bool outOfBounds = static_cast<s32>(slot.yTarget) + getEffectiveHeight(slot)
> static_cast<s32>(tsl::cfg::FramebufferHeight);
if (outOfBounds) {
slot.data.stateStartNs = now;
if (slot.data.durationMs != 0)
slot.data.expireNs = now + static_cast<u64>(slot.data.durationMs) * 1'000'000ULL
+ FADE_DURATION_MS * 1'000'000ULL;
} else if (slot.flags & SLOT_SOUND_PENDING) {
slot.flags &= ~SLOT_SOUND_PENDING;
triggerNotificationSound.store(true, std::memory_order_release);
}
if (slot.data.iconPending) {
slot.data.iconPending = false;
const std::string& fname = slot.data.fileName;
if (!fname.empty()) {
std::string base = fname;
const size_t dot = base.rfind('.');
if (dot != std::string::npos) base.erase(dot);
const size_t dash = base.rfind('-');
if (dash != std::string::npos) base.erase(dash);
const std::string iconPath = ult::NOTIFICATIONS_ICONS_PATH + base + ".rgba";
slot.iconBuf = std::make_unique<u8[]>(NOTIF_ICON_BYTES);
if (ult::loadRGBA8888toRGBA4444(iconPath, slot.iconBuf.get(),
NOTIF_ICON_DIM * NOTIF_ICON_DIM * 4)) {
slot.flags |= SLOT_ICON_LOADED;
slot.data.hasIcon = true;
} else {
slot.iconBuf.reset();
}
}
}
if (slot.flags & SLOT_SLIDING) {
const float st = std::min(1.0f,
static_cast<float>((now - slot.slideStartNs) / 1'000'000ULL) / SLIDE_DURATION_MS);
slot.yCurrent = slot.ySlideFrom + (slot.yTarget - slot.ySlideFrom) * easeInOut(st);
if (st >= 1.0f) { slot.yCurrent = slot.yTarget; slot.flags &= ~SLOT_SLIDING; }
}
const u64 elapsedMs = slot.data.stateStartNs == 0 ? 0
: (now - slot.data.stateStartNs) / 1'000'000ULL;
if (!outOfBounds) switch (slot.data.state) {
case PromptState::FadingIn:
if (elapsedMs >= FADE_DURATION_MS) {
slot.data.state = PromptState::Visible;
slot.data.stateStartNs = now;
}
break;
case PromptState::Visible:
if (now >= slot.data.expireNs)
startFadeOut(slot.data, now);
break;
case PromptState::FadingOut:
if (elapsedMs >= FADE_DURATION_MS) {
evictSlot_NoLock(i);
anyCleared = true;
}
break;
default: break;
}
}
if (anyCleared) repackSlots_NoLock(now);
while (!pending_queue_.empty()) {
int freeSlot = -1;
for (int i = 0; i < maxNotifications; ++i)
if (!(slots_[i].flags & SLOT_ACTIVE)) { freeSlot = i; break; }
if (freeSlot < 0) break;
NotifEntry next = pending_queue_.top();
pending_queue_.pop();
placeInSlot_NoLock(freeSlot, std::move(next), false, false);
}
}
bool NotificationPrompt::isActive() const {
if (isStale()) return false;
std::lock_guard<std::mutex> lg(state_mutex_);
for (int i = 0; i < maxNotifications; ++i)
if (slots_[i].flags & SLOT_ACTIVE) return true;
return !pending_queue_.empty();
}
int NotificationPrompt::activeCount() const {
if (isStale()) return 0;
std::lock_guard<std::mutex> lg(state_mutex_);
int count = 0;
for (int i = 0; i < maxNotifications; ++i)
if (slots_[i].flags & SLOT_ACTIVE) ++count;
return count;
}
void NotificationPrompt::shutdown() {
enabled_.store(false, std::memory_order_release);
notificationGeneration.fetch_add(1, std::memory_order_acq_rel);
generation_.store(notificationGeneration.load(std::memory_order_acquire),
std::memory_order_release);
std::lock_guard<std::mutex> lg(state_mutex_);
clearAll_NoLock();
}
bool NotificationPrompt::hitTest(s32 tx, s32 ty) const {
if (isStale()) return false;
std::lock_guard<std::mutex> lg(state_mutex_);
return findHitSlot_NoLock(tx, ty) >= 0;
}
bool NotificationPrompt::dismissAt(s32 tx, s32 ty) {
if (isStale()) return false;
std::lock_guard<std::mutex> lg(state_mutex_);
const int idx = findHitSlot_NoLock(tx, ty);
if (idx < 0) return false;
Slot& slot = slots_[idx];
if (slot.data.state != PromptState::FadingOut)
startFadeOut(slot.data, ult::nowNs());
return true;
}
bool NotificationPrompt::dismissFront() {
if (isStale()) return false;
std::lock_guard<std::mutex> lg(state_mutex_);
int best = -1;
float bestY = 1.0e9f;
for (int i = 0; i < maxNotifications; ++i) {
const Slot& slot = slots_[i];
if (!(slot.flags & SLOT_ACTIVE) || slot.data.state == PromptState::FadingOut) continue;
if (slot.yCurrent < bestY) { bestY = slot.yCurrent; best = i; }
}
if (best < 0) return false;
startFadeOut(slots_[best].data, ult::nowNs());
return true;
}
NotificationPrompt::Lines
NotificationPrompt::getWrappedLines(const std::string& text, float pixelWidth,
size_t fontSize, u8 maxLines,
SplitType splitType) const {
const std::string& stStr = (splitType == SplitType::Char) ? ult::CHAR_STR : ult::WORD_STR;
Lines split;
{
size_t start = 0;
while (start < text.size() && split.count < maxLines) {
size_t pos = text.find('\n', start);
const size_t pos2 = text.find("\\n", start);
if (pos2 != std::string::npos && (pos == std::string::npos || pos2 < pos)) pos = pos2;
if (pos == std::string::npos) { split.buf[split.count++] = text.substr(start); break; }
split.buf[split.count++] = text.substr(start, pos - start);
start = pos + (text[pos] == '\n' ? 1 : 2);
}
}
Lines result;
for (u8 si = 0; si < split.count && result.count < maxLines; ++si) {
const auto wrapped = wrapText(split.buf[si], pixelWidth,
stStr, /*useIndent=*/false, s_emptyStr, 0.f, fontSize);
for (const auto& wl : wrapped) {
if (result.count >= maxLines) break;
result.buf[result.count++] = wl;
}
}
if (result.count == 0 && split.count > 0)
result.buf[result.count++] = "";
return result;
}
s32 NotificationPrompt::getEffectiveHeight(const Slot& slot) const {
if (slot.data.text.empty()) return NOTIF_HEIGHT;
const IconGeom g = computeIconGeom(slot);
const bool hasTitleLayout = !slot.data.title.empty();
// Determine wrap width and line cap for the two layout modes
float wrapW;
u8 wrapMax;
if (hasTitleLayout) {
const s32 taw = g.hasIconCol ? g.textAreaW : (NOTIF_WIDTH - 2 * (g.baseIconPad + 2));
wrapW = g.hasIconCol ? static_cast<float>(taw - g.baseIconPad - 2)
: static_cast<float>(taw);
wrapMax = 4;
} else {
wrapW = static_cast<float>(g.textAreaW - 4);
wrapMax = 9;
}
const Lines lines = getWrappedLines(slot.data.text, wrapW, slot.data.fontSize, wrapMax,
slot.data.splitType);
if (lines.count <= 1) return NOTIF_HEIGHT;
const auto fm = tsl::gfx::FontManager::getFontMetricsForCharacter('A', slot.data.fontSize);
return NOTIF_HEIGHT + extraLinesHeight(static_cast<s32>(lines.count - 1), fm.lineHeight);
}
void NotificationPrompt::placeInSlot_NoLock(int idx, NotifEntry&& e,
bool isShowNow, bool skipFadeIn,
bool suppressSound) {
const u64 now = ult::nowNs();
float ty = 0.f;
for (int i = 0; i < idx; ++i)
if (slots_[i].flags & SLOT_ACTIVE) ty += static_cast<float>(getEffectiveHeight(slots_[i]));
Slot& slot = slots_[idx];
slot.data = std::move(e);
slot.data.state = skipFadeIn ? PromptState::Visible : PromptState::FadingIn;
slot.data.stateStartNs = now;
slot.data.expireNs = (slot.data.durationMs == 0) ? UINT64_MAX
: now
+ (skipFadeIn ? 0ULL : FADE_DURATION_MS * 1'000'000ULL)
+ slot.data.durationMs * 1'000'000ULL;
slot.data.hasIcon = false;
slot.data.iconPending = !slot.data.fileName.empty();
slot.yTarget = ty;
slot.yCurrent = ty;
slot.iconBuf.reset();
const bool placedInBounds = (ty + static_cast<float>(NOTIF_HEIGHT) <= static_cast<float>(tsl::cfg::FramebufferHeight));
slot.flags = SLOT_ACTIVE
| (isShowNow ? SLOT_SHOW_NOW : 0)
| ((suppressSound && placedInBounds) ? 0 : SLOT_SOUND_PENDING);
}
void NotificationPrompt::repackSlots_NoLock(u64 now) {
int order[MAX_VISIBLE];
int count = 0;
for (int i = 0; i < maxNotifications; ++i)
if ((slots_[i].flags & SLOT_ACTIVE) && (slots_[i].flags & SLOT_SHOW_NOW)) { order[count++] = i; break; }
for (int i = 0; i < maxNotifications && count < maxNotifications; ++i)
if ((slots_[i].flags & SLOT_ACTIVE) && !(slots_[i].flags & SLOT_SHOW_NOW)) order[count++] = i;
float y = 0.f;
for (int i = 0; i < count; ++i) {
Slot& s = slots_[order[i]];
if (s.yTarget != y || s.yCurrent != y) {
s.ySlideFrom = s.yCurrent;
s.yTarget = y;
s.slideStartNs = now;
s.flags |= SLOT_SLIDING;
}
y += static_cast<float>(getEffectiveHeight(s));
}
for (int i = 0; i < count; ++i) {
if (order[i] == i) continue;
slots_[i] = std::move(slots_[order[i]]);
}
for (int i = count; i < maxNotifications; ++i)
slots_[i] = Slot{};
}
void NotificationPrompt::applyEllipsis(Lines& lines, u8 maxLines,
float pixelWidth, size_t fontSize,
gfx::Renderer* renderer) const {
if (maxLines == 0 || lines.count <= maxLines) return;
std::string overflow = lines.buf[maxLines];
lines.count = maxLines;
std::string& last = lines.buf[maxLines - 1];
last += ' ';
last += overflow;
while (!last.empty()) {
last += "...";
if (static_cast<float>(renderer->getNotificationTextDimensions(last, false, fontSize).first) <= pixelWidth)
return;
last.resize(last.size() - 4);
}
last = "...";
}
void NotificationPrompt::drawSlot(gfx::Renderer* renderer, const Slot& slot,
s32 baseY, float fadeAlpha, bool promptOnly) {
// Precompute all faded colours once — avoids applyAlpha+a2 overhead inside loops
auto fc = [renderer, fadeAlpha](Color c) {
return renderer->a2(applyAlpha(c, fadeAlpha));
};
const Color fadedBgColor = fc(defaultBackgroundColor);
const Color fadedTextColor = fc(notificationTextColor);
const Color fadedTitleColor = fc(notificationTitleColor);
const Color fadeTimeColor = fc(notificationTimeColor);
const Color fadedEdgeColor = fc(edgeSeparatorColor);
const Color fadedSeparatorColor = fc(separatorColor);
// ── Layout geometry ──────────────────────────────────────────────────────
const s32 x = ult::useRightAlignment
? (tsl::cfg::FramebufferWidth - NOTIF_WIDTH) : 0;
const IconGeom g = computeIconGeom(slot, x);
const float innerWf = static_cast<float>(g.textAreaW - g.baseIconPad - 2);
const s32 titleTextAreaX = g.hasIconCol ? g.textAreaX : x + (g.baseIconPad + 2);
const s32 titleTextAreaW = g.hasIconCol ? g.textAreaW : NOTIF_WIDTH - 2 * (g.baseIconPad + 2);
const float titleInnerWf = g.hasIconCol ? innerWf : static_cast<float>(titleTextAreaW);
const u8 fontSize = slot.data.fontSize;
const Alignment alignment = slot.data.alignment;
const bool hasTitleIconLayout = !slot.data.text.empty() && !slot.data.title.empty();
// ── Line-wrap (single pass shared by height calculation and rendering) ───
// Lines are wrapped and ellipsised here so effectiveHeight reflects the
// actual rendered line count, not the pre-ellipsis maximum.
Lines lines;
s32 effectiveHeight = NOTIF_HEIGHT;
if (hasTitleIconLayout) {
lines = getWrappedLines(slot.data.text, titleInnerWf, fontSize, 5, slot.data.splitType);
applyEllipsis(lines, 4, titleInnerWf, fontSize, renderer);
if (lines.count > 1) {
const auto fm = tsl::gfx::FontManager::getFontMetricsForCharacter('A', fontSize);
effectiveHeight += extraLinesHeight(static_cast<s32>(lines.count - 1), fm.lineHeight);
}
} else if (!slot.data.text.empty() && g.textAreaW > 0) {
lines = getWrappedLines(slot.data.text, static_cast<float>(g.textAreaW - 4),
fontSize, 9, slot.data.splitType);
applyEllipsis(lines, 8, static_cast<float>(g.textAreaW - 4), fontSize, renderer);
if (lines.count > 1) {
const auto fm = tsl::gfx::FontManager::getFontMetricsForCharacter('A', fontSize);
effectiveHeight += extraLinesHeight(static_cast<s32>(lines.count - 1), fm.lineHeight);
}
}
if (baseY + effectiveHeight > static_cast<s32>(tsl::cfg::FramebufferHeight)) return;
// ── Background ───────────────────────────────────────────────────────────
renderer->enableScissoring(static_cast<u32>(std::max(0, x)),
static_cast<u32>(std::max(0, baseY)),
static_cast<u32>(NOTIF_WIDTH),
static_cast<u32>(effectiveHeight));
#if IS_STATUS_MONITOR_DIRECTIVE
renderer->drawRect(x, baseY, NOTIF_WIDTH, effectiveHeight, fadedBgColor);
#else
if (!promptOnly && ult::expandedMemory)
renderer->drawRectMultiThreaded(x, baseY, NOTIF_WIDTH, effectiveHeight, fadedBgColor);
else
renderer->drawRect(x, baseY, NOTIF_WIDTH, effectiveHeight, fadedBgColor);
#endif
// ── Icon ─────────────────────────────────────────────────────────────────
if (g.hasIconCol && slot.data.hasIcon && (slot.flags & SLOT_ICON_LOADED) && slot.iconBuf) {
const s32 dstX = x + g.baseIconPad;
const s32 iconVertPad = (effectiveHeight - NOTIF_ICON_DIM) / 2;
s32 drawH = NOTIF_ICON_DIM;
if (dstX + NOTIF_ICON_DIM > static_cast<s32>(tsl::cfg::FramebufferWidth))
drawH = 0;
if (iconVertPad + baseY + drawH > static_cast<s32>(tsl::cfg::FramebufferHeight))
drawH = static_cast<s32>(tsl::cfg::FramebufferHeight) - iconVertPad - baseY;
if (drawH > 0)
renderer->drawBitmapRGBA4444(
static_cast<u32>(dstX), static_cast<u32>(iconVertPad + baseY),
static_cast<u32>(NOTIF_ICON_DIM), static_cast<u32>(drawH),
slot.iconBuf.get(), fadeAlpha);
}
// ── Text ─────────────────────────────────────────────────────────────────
if (!slot.data.text.empty() && g.textAreaW > 0) {
if (hasTitleIconLayout) {
const auto titleFm = tsl::gfx::FontManager::getFontMetricsForCharacter('A', TITLE_FONT);
const auto messageFm = tsl::gfx::FontManager::getFontMetricsForCharacter('A', fontSize);
const s32 innerW = static_cast<s32>(titleInnerWf);
const s32 lineCount = static_cast<s32>(lines.count);
const s32 lineStep = messageFm.lineHeight + 3;
static constexpr s32 LINE_GAP = 4;
// blockH = title row + gap + all message lines with inter-line spacing
const s32 blockH = titleFm.lineHeight + LINE_GAP
+ (lineCount > 0
? lineCount * messageFm.lineHeight
+ (lineCount - 1) * 3
: 0);
const s32 originY = (effectiveHeight - blockH) / 2 + baseY;
const s32 titleY = originY + titleFm.ascent - 3 + 1;
const s32 messageY = originY + titleFm.lineHeight + LINE_GAP + messageFm.ascent + 1+2;
#if IS_LAUNCHER_DIRECTIVE
if (slot.data.title.find(ult::CAPITAL_ULTRAHAND_PROJECT_NAME) != std::string::npos)
drawUltrahandLine(renderer, slot.data.title, titleTextAreaX, titleY, TITLE_FONT, fadeAlpha, notificationTitleColor);
else {
#endif
renderer->drawNotificationString(slot.data.title, false,
titleTextAreaX, titleY, TITLE_FONT, fadedTitleColor,
0, true, &fadedSeparatorColor, &s_dividerSpecialChars);
#if IS_LAUNCHER_DIRECTIVE
}
#endif
if (slot.data.showTime && slot.data.timestamp[0] != '\0') {
const s32 tsW = renderer->getNotificationTextDimensions(
slot.data.timestamp, false, TITLE_FONT).first;
const s32 tsRightEdge = x + NOTIF_WIDTH - g.baseIconPad - 2;
const s32 tsX = tsRightEdge - tsW;
if (tsX > titleTextAreaX)
renderer->drawNotificationString(slot.data.timestamp, false,
tsX, titleY, TITLE_FONT, fadeTimeColor);
}
for (s32 li = 0; li < lineCount; ++li) {
s32 msgX;
if (alignment == Alignment::Left) {
msgX = titleTextAreaX;
} else {
const s32 lw = renderer->getNotificationTextDimensions(lines[li], false, fontSize).first;
msgX = (alignment == Alignment::Right)
? titleTextAreaX + innerW - lw
: titleTextAreaX + (innerW - lw) / 2;
}
renderer->drawNotificationString(lines[li], false, msgX,
messageY + li * lineStep, fontSize, fadedTextColor,
0, true, &fadedSeparatorColor, &s_dividerSpecialChars);
}
} else {
const auto fm = tsl::gfx::FontManager::getFontMetricsForCharacter('A', fontSize);
const s32 lineCount = static_cast<s32>(lines.count);
// total pixel height of the text block including inter-line spacing
const s32 totalTextH = lineCount > 0
? lineCount * fm.lineHeight + (lineCount - 1) * 3
: 0;
const s32 startY = (effectiveHeight - totalTextH) / 2 + fm.ascent + baseY;
const s32 padAdjust = g.hasIconCol ? g.baseIconPad + 2 : 0;
const s32 lineStep = fm.lineHeight + (lineCount > 1 ? 3 : 0);
const bool needsLineWidth = (alignment != Alignment::Left);
auto alignedX = [&](s32 contentW) -> s32 {
if (alignment == Alignment::Left) return g.textAreaX + 2;
if (alignment == Alignment::Right) return g.textAreaX + g.textAreaW - contentW - padAdjust - 2;
return g.textAreaX + (g.textAreaW - contentW - padAdjust) / 2;
};
for (s32 li = 0; li < lineCount; ++li) {
const std::string& line = lines[li];
const s32 lineY = startY + li * lineStep;
#if IS_LAUNCHER_DIRECTIVE
const size_t up = line.find(ult::CAPITAL_ULTRAHAND_PROJECT_NAME);
if (up != std::string::npos) {
const std::string before = line.substr(0, up);
const std::string after = line.substr(up + ult::CAPITAL_ULTRAHAND_PROJECT_NAME.length());
const std::string& hand = ult::SPLIT_PROJECT_NAME_2;
const s32 bw = before.empty() ? 0 : renderer->getNotificationTextDimensions(before, false, fontSize).first;
const s32 aw = after.empty() ? 0 : renderer->getNotificationTextDimensions(after, false, fontSize).first;
const s32 hw = renderer->getNotificationTextDimensions(hand, false, fontSize).first;
const s32 uw = tsl::elm::calculateUltraTextWidth(renderer, fontSize, true);
drawUltrahandLine(renderer, line, alignedX(bw + uw + hw + aw), lineY, fontSize, fadeAlpha);
} else
#endif
{
const s32 lw = needsLineWidth
? renderer->getNotificationTextDimensions(line, false, fontSize).first
: 0;
renderer->drawNotificationString(line, false,
alignedX(lw), lineY, fontSize, fadedTextColor,
0, true, &fadedSeparatorColor, &s_dividerSpecialChars);
}
}
}
}
// ── Border ───────────────────────────────────────────────────────────────
const s32 edgeX = ult::useRightAlignment ? x : x + NOTIF_WIDTH - 1;
renderer->drawRect(edgeX, baseY, 1, effectiveHeight, fadedEdgeColor);
renderer->drawRect(x, baseY + effectiveHeight - 1, NOTIF_WIDTH, 1, fadedEdgeColor);
renderer->disableScissoring();
}
#if IS_LAUNCHER_DIRECTIVE
void NotificationPrompt::drawUltrahandLine(gfx::Renderer* renderer, const std::string& line,
s32 x, s32 y, u32 fontSize, float fadeAlpha,
Color textColor) {
auto fc = [&](Color c) { return applyAlpha(c, fadeAlpha); };
const size_t up = line.find(ult::CAPITAL_ULTRAHAND_PROJECT_NAME);
if (up == std::string::npos) {
const Color fadedSep = applyAlpha(separatorColor, fadeAlpha);
renderer->drawNotificationString(line, false, x, y, fontSize, fc(textColor),
0, true, &fadedSep, &s_dividerSpecialChars);
return;
}
const std::string before = line.substr(0, up);
const std::string& hand = ult::SPLIT_PROJECT_NAME_2;
const std::string after = line.substr(up + ult::CAPITAL_ULTRAHAND_PROJECT_NAME.length());
s32 bw = 0, hw = 0;
if (!before.empty()) bw = renderer->getNotificationTextDimensions(before, false, fontSize).first;
hw = renderer->getNotificationTextDimensions(hand, false, fontSize).first;
s32 curX = x;
if (!before.empty()) {
renderer->drawNotificationString(before, false, curX, y, fontSize, fc(textColor));
curX += bw;
}
curX = tsl::elm::drawDynamicUltraText(renderer, curX, y, fontSize, fc(logoColor1), true);
renderer->drawNotificationString(hand, false, curX, y, fontSize, fc(logoColor2));
curX += hw;
if (!after.empty())
renderer->drawNotificationString(after, false, curX, y, fontSize, fc(textColor));
}
#endif
} // namespace tsl