/******************************************************************************** * 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 // --------------------------------------------------------------------------- // 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(frontBuf + 0x4); const uint32_t text_offset = *reinterpret_cast(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(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(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 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(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(*itStr); codepointWidth = 1; } else { codepointWidth = decode_utf8(&currCharacter, reinterpret_cast(&(*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 glyph = FontManager::getOrCreateGlyph(currCharacter, monospace, fontSizeInt); if (!glyph) continue; // CRITICAL: Use the same calculation as drawString currentLineWidth += static_cast(glyph->xAdvance * glyph->currFontSize); } // Final width calculation maxWidth = std::max(currentLineWidth, maxWidth); return static_cast(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 §ion : 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 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 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(); 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 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 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(&(*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(&(*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(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(&(*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 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 { const s32 sx = (ult::useRightAlignment ? static_cast(tsl::cfg::FramebufferWidth) - NOTIF_WIDTH : 0) + static_cast(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(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 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((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(slot.yCurrent), fadeAlpha, promptOnly); } } void NotificationPrompt::update() { std::lock_guard 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(slot.yTarget) + getEffectiveHeight(slot) > static_cast(tsl::cfg::FramebufferHeight); if (outOfBounds) { slot.data.stateStartNs = now; if (slot.data.durationMs != 0) slot.data.expireNs = now + static_cast(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(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((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 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 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 lg(state_mutex_); clearAll_NoLock(); } bool NotificationPrompt::hitTest(s32 tx, s32 ty) const { if (isStale()) return false; std::lock_guard lg(state_mutex_); return findHitSlot_NoLock(tx, ty) >= 0; } bool NotificationPrompt::dismissAt(s32 tx, s32 ty) { if (isStale()) return false; std::lock_guard 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 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(taw - g.baseIconPad - 2) : static_cast(taw); wrapMax = 4; } else { wrapW = static_cast(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(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(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(NOTIF_HEIGHT) <= static_cast(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(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(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(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(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(lines.count - 1), fm.lineHeight); } } else if (!slot.data.text.empty() && g.textAreaW > 0) { lines = getWrappedLines(slot.data.text, static_cast(g.textAreaW - 4), fontSize, 9, slot.data.splitType); applyEllipsis(lines, 8, static_cast(g.textAreaW - 4), fontSize, renderer); if (lines.count > 1) { const auto fm = tsl::gfx::FontManager::getFontMetricsForCharacter('A', fontSize); effectiveHeight += extraLinesHeight(static_cast(lines.count - 1), fm.lineHeight); } } if (baseY + effectiveHeight > static_cast(tsl::cfg::FramebufferHeight)) return; // ── Background ─────────────────────────────────────────────────────────── renderer->enableScissoring(static_cast(std::max(0, x)), static_cast(std::max(0, baseY)), static_cast(NOTIF_WIDTH), static_cast(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(tsl::cfg::FramebufferWidth)) drawH = 0; if (iconVertPad + baseY + drawH > static_cast(tsl::cfg::FramebufferHeight)) drawH = static_cast(tsl::cfg::FramebufferHeight) - iconVertPad - baseY; if (drawH > 0) renderer->drawBitmapRGBA4444( static_cast(dstX), static_cast(iconVertPad + baseY), static_cast(NOTIF_ICON_DIM), static_cast(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(titleInnerWf); const s32 lineCount = static_cast(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(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