diff --git a/loader/include/Geode/UI.hpp b/loader/include/Geode/UI.hpp index 2aeb4a780..c4b864762 100644 --- a/loader/include/Geode/UI.hpp +++ b/loader/include/Geode/UI.hpp @@ -10,6 +10,7 @@ #include "ui/General.hpp" #include "ui/GeodeUI.hpp" #include "ui/IconButtonSprite.hpp" +#include "ui/Label.hpp" #include "ui/Layout.hpp" #include "ui/LazySprite.hpp" #include "ui/ListView.hpp" diff --git a/loader/include/Geode/ui/Label.hpp b/loader/include/Geode/ui/Label.hpp new file mode 100644 index 000000000..68140be56 --- /dev/null +++ b/loader/include/Geode/ui/Label.hpp @@ -0,0 +1,358 @@ +#pragma once + +#include +#include + +namespace geode { + /// A bitmap font cache that acts similarly to cocos2d::CCBMFontConfiguration, but is more performant. + class GEODE_DLL BitmapFont final { + public: + BitmapFont(); + ~BitmapFont(); + + /// Loads a bitmap font from a .fnt file. Caches for future reuse. + /// @param fntFile File name of the .fnt file + /// @return Pointer to the loaded bitmap font, or nullptr if failed + static BitmapFont* load(ZStringView fntFile); + + /// Purges a bitmap font from the cache. + /// @param fntFile File name of the .fnt file + static void purgeFont(ZStringView fntFile); + + /// Purges a bitmap font from the cache. + /// @param font Pointer to the bitmap font to purge + static void purgeFont(BitmapFont const* font); + + /// Purges the entire font cache. Called automatically on textures reload. + static void purgeAllFonts(); + + using KerningPair = std::pair; + + struct KerningValue { + float amount; + float scaled; + }; + + struct KerningPairHash { + size_t operator()(KerningPair const& pair) const noexcept { + return std::hash()(static_cast(pair.first) << 32 | pair.second); + } + }; + + using KerningMap = std::unordered_map; + + struct CharDef final { + uint32_t codepoint = 0; + cocos2d::CCRect rect{}; + float xOffset = 0.f; + float yOffset = 0.f; + float xAdvance = 0.f; + + /// Values pre-calculated with CCDirector::getContentScaleFactor() + cocos2d::CCRect uv{}; + cocos2d::CCSize sizeScaled{}; + float xOffsetScaled = 0.f; + float yOffsetScaled = 0.f; + float xAdvanceScaled = 0.f; + }; + + struct Padding final { + int32_t left = 0; + int32_t right = 0; + int32_t top = 0; + int32_t bottom = 0; + }; + + /// Loads the font file from a given path. + /// @param fntFile File name of the .fnt file + /// @return True if successfully parsed, false otherwise + bool initWithFile(ZStringView fntFile); + + /// Loads the font file using file contents. + /// @param text Raw contents of the .fnt file + /// @note Atlas path relies on knowing the font file path. + /// Call setFontFile() separately to resolve the absolute path instead of storing the relative file name. + /// @return True if successfully parsed, false otherwise + bool initWithContents(std::string_view text); + + /// Initializes extra cached fields (dividing by content scale factor, texture UVs). + /// @note Invoked automatically by BitmapFont::load(). Must be called manually + /// if constructing a font via initWithFile() or initWithContents(). + void initBakedValues(); + + void setFontFile(std::string file) noexcept; + void setAtlasName(std::string name) noexcept; + + ZStringView getFontFile() const noexcept; + ZStringView getAtlasName() const noexcept; + + std::unordered_map const& getCharDefs() const noexcept; + KerningMap const& getKernings() const noexcept; + Padding getPadding() const noexcept; + cocos2d::CCSize getAtlasSize() const noexcept; + float getCommonHeight() const noexcept; + + /// @return Same as getCommonHeight(), but divided by content scale factor. + float getCommonHeightScaled() const noexcept; + + float getKerning(char32_t first, char32_t second) const noexcept; + + private: + std::string m_atlasName; + std::string m_fntFilename; + std::unordered_map m_characters; + KerningMap m_kerning; + Padding m_padding{}; + cocos2d::CCSize m_atlasSize{}; + float m_commonHeight = 0.f; + float m_commonHeightScaled = 0.f; + }; + + /// Container that stores mappings for emojis/custom nodes used by Label + class GEODE_DLL EmojiRegistry { + public: + using NodeFactory = Function; + using NodeFactoryParams = Function; + + struct EmojiEntry final { + ZStringView frameName; + + EmojiEntry(ZStringView frameName) noexcept; + + cocos2d::CCSpriteFrame* getFrame() const; + void invalidate() noexcept; + + private: + mutable cocos2d::CCSpriteFrame* cachedFrame = nullptr; + }; + + using Entry = std::variant; + + EmojiRegistry(); + ~EmojiRegistry(); + + EmojiRegistry(EmojiRegistry const&); + EmojiRegistry(EmojiRegistry&&) noexcept; + EmojiRegistry& operator=(EmojiRegistry const&); + EmojiRegistry& operator=(EmojiRegistry&&) noexcept; + + /// Inserts a new replacement mapping to the registry. + /// @param sequence A UTF-32 view to a sequence that's going to be replaced by a custom node. + /// @param factory Function that returns a newly created node. + /// @note The sequence view is expected to point to static memory and never be freed. + void insert(std::u32string_view sequence, NodeFactory factory); + + /// Inserts a new replacement mapping to the registry. + /// @param sequence A UTF-32 view to a sequence that's going to be replaced by a custom node. + /// @param factory Function that returns a newly created node. + /// @note The sequence view is expected to point to static memory and never be freed. + void insert(std::u32string_view sequence, NodeFactoryParams factory); + + /// Inserts a new replacement mapping to the registry. + /// @param sequence A UTF-32 view to a sequence that's going to be replaced by a sprite. + /// @param frameName Frame name for the provided emoji. + /// @note The sequence view is expected to point to static memory and never be freed. + void insert(std::u32string_view sequence, ZStringView frameName); + + /// Inserts a sequence of mappings to the registry. + /// @note All sequence views are expected to point to static memory and never be freed. + void insert(std::span> entries); + + /// Removes an entry from the registry by provided sequence. + void erase(std::u32string_view sequence); + + Entry* match(std::u32string_view str, size_t& index); + + private: + friend class Label; + + struct Impl; + std::shared_ptr m_impl; + + EmojiRegistry(std::shared_ptr impl); + }; + + /// A drop-in replacement for CCLabelBMFont with improved performance + /// and support for emojis, Unicode, fallback fonts and color tags. + class GEODE_DLL Label : public cocos2d::CCNode, public cocos2d::CCRGBAProtocol, public cocos2d::CCLabelProtocol, public cocos2d::CCBlendProtocol { + public: + Label(); + ~Label() override; + + enum class Alignment : uint8_t { + Left, + Center, + Right, + Justify + }; + + /// Create an empty Label with a default font. + /// @param font Pointer to the default font + static Label* create(BitmapFont* font); + + /// Create an empty Label with a default font file. + /// @param font Filename of the .fnt file (e.g. "bigFont.fnt") + static Label* create(ZStringView font); + + /// Create a Label with the specified text and font. + /// @param text The text to display + /// @param font The font to use + static Label* create(std::string text, BitmapFont* font); + + /// Create a Label with the specified text and font file. + /// @param text The text to display + /// @param font The font file to use (e.g. "bigFont.fnt") + static Label* create(std::string text, ZStringView font); + + /// Create a Label with the specified rich-text and font. + /// @param text Rich text that may contain color tags + /// @param font The font to use + static Label* createRich(std::string text, BitmapFont* font); + + /// Create a Label with the specified rich-text and font file. + /// @param text Rich text that may contain color tags + /// @param font The font file to use (e.g. "bigFont.fnt") + static Label* createRich(std::string text, ZStringView font); + + /// Forces a relayout/redraw of internal quads based on currently active dirty flags. + /// Runs automatically every frame before rendering or when fetching content size. + void validate(); + + /// Marks the text as dirty. Used to force a full pass of text shaping and layout. + /// Examples: text, font, or kerning parameter changes. + void setTextDirty(bool dirty) noexcept; + bool isTextDirty() noexcept; + + /// Marks the layout as dirty. Used to redraw all quads without triggering Unicode reshaping. + /// Examples: text alignment, line spacing or wrap width changes. + void setLayoutDirty(bool dirty) noexcept; + bool isLayoutDirty() noexcept; + + /// Marks the quads as dirty. Used to modify quad colors efficiently. + void setQuadsDirty(bool dirty) noexcept; + bool isQuadsDirty() noexcept; + + /// Registers a fallback font used when glyphs are missing from the primary font. + /// @return True if font was added successfully. + bool registerFont(ZStringView font); + bool registerFont(BitmapFont* font); + + /// Sets the text of the label. + void setText(std::string text) noexcept; + ZStringView getText() noexcept; + + void setRichText(std::string text); + + /// Sets the label string. Provided by CCLabelProtocol. + /// @note Prefer using Label::setText to avoid a string copy. + void setString(char const* label) override; + char const* getString() override; + + void setAlignment(Alignment alignment) noexcept; + Alignment getAlignment() noexcept; + + /// Sets the maximum line width and enables line wrapping mode (or disables if 0.f). + void setMaxWidth(float maxWidth) noexcept; + float getMaxWidth() noexcept; + + /// Sets whether line wrapping should also break words in parts to fit the maximum line width. + /// @note This is only relevant if line wrapping is enabled + void setBreakWords(bool breakWords) noexcept; + bool isBreakWords() noexcept; + + /// Sets the maximum label content width and automatically scales it down when needed. + /// @param width Target content width + /// @param maxScale The default scale to use when label fits normally + /// @param minScale Minimum allowed scale + void setLimitLabelWidth(float width, float maxScale = 1.f, float minScale = 0.f) noexcept; + + /// Sets the maximum label content height and automatically scales it down when needed. + /// @param height Target content height + /// @param maxScale The default scale to use when label fits normally + /// @param minScale Minimum allowed scale + void setLimitLabelHeight(float height, float maxScale = 1.f, float minScale = 0.f) noexcept; + + /// Sets the maximum label content size and automatically scales it down when needed. + /// @param size Target content size + /// @param maxScale The default scale to use when label fits normally + /// @param minScale Minimum allowed scale + void setLimitLabelSize(cocos2d::CCSize size, float maxScale = 1.f, float minScale = 0.f) noexcept; + + /// Disables automatic content size scaling. + void removeLabelSizeLimit() noexcept; + + /// Acts similarly to setLimitLabelSize, except instead of letterboxing it attempts to + /// automatically pick the best line wrap width, filling more area with largest possible scale. + void setFitBox(cocos2d::CCSize size, float maxScale = 1.f) noexcept; + void disableFitBox() noexcept; + + /// Sets extra kerning between each character. + void setExtraKerning(float kerning) noexcept; + float getExtraKerning() noexcept; + + /// Sets extra line spacing between each line. + void setLineSpacing(float lineSpacing) noexcept; + float getLineSpacing() noexcept; + + /// Sets the color for a specified character index (ignores whitespace). + /// @note You have to call validate() before attempting to change colors, otherwise the changes will be overwritten. + void setCharColor(uint32_t index, cocos2d::ccColor4B color); + + /// Gets total amount of characters currently visible (excluding whitespace, emojis and custom nodes). + size_t getCharCount() noexcept; + + /// Assigns the emoji registry container and enables the rendering of emojis. + void setEmojiRegistry(EmojiRegistry const& registry); + EmojiRegistry getEmojiRegistry(); + + void setColor(cocos2d::ccColor3B const& color) override; + cocos2d::ccColor3B const& getColor() override; + cocos2d::ccColor3B const& getDisplayedColor() override; + + void setOpacity(GLubyte opacity) override; + GLubyte getOpacity() override; + GLubyte getDisplayedOpacity() override; + + /// Set whether emojis should inherit the label color or remain white. Defaults to false. + void setUseColoredEmojis(bool value) noexcept; + bool getUseColoredEmojis() noexcept; + + /// Set whether custom embedded nodes should inherit the label color or remain the original color. Defaults to false. + void setUseColoredNodes(bool value) noexcept; + bool getUseColoredNodes() noexcept; + + std::vector> const& getEmbeddedNodes() const noexcept; + + void setOpacityModifyRGB(bool bValue) override; + bool isOpacityModifyRGB() override; + + void setCascadeColorEnabled(bool cascadeColorEnabled) override; + bool isCascadeColorEnabled() override; + + void setCascadeOpacityEnabled(bool cascadeOpacityEnabled) override; + bool isCascadeOpacityEnabled() override; + + void updateDisplayedColor(cocos2d::ccColor3B const& color) override; + void updateDisplayedOpacity(GLubyte opacity) override; + + void setBlendFunc(cocos2d::ccBlendFunc blendFunc) override; + cocos2d::ccBlendFunc getBlendFunc() override; + + cocos2d::CCSize const& getContentSize() const override; + cocos2d::CCSize getScaledContentSize() override; + + void draw() override; + + protected: + bool initWithFont(BitmapFont* font); + bool initWithFontFile(ZStringView fntFile); + bool initWithString(std::string text, BitmapFont* font); + bool initWithString(std::string text, ZStringView fntFile); + bool initWithRichText(std::string text, BitmapFont* font); + bool initWithRichText(std::string text, ZStringView fntFile); + + private: + struct Impl; + Impl* m_impl = nullptr; + }; +} \ No newline at end of file diff --git a/loader/src/ui/nodes/Label.cpp b/loader/src/ui/nodes/Label.cpp new file mode 100644 index 000000000..b5a6223bc --- /dev/null +++ b/loader/src/ui/nodes/Label.cpp @@ -0,0 +1,2120 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include + +using namespace geode::prelude; + +static StringMap& GetBitmapFontsCache() { + static StringMap cache; + return cache; +} + +BitmapFont::BitmapFont() = default; +BitmapFont::~BitmapFont() = default; + +BitmapFont* BitmapFont::load(ZStringView fntFile) { + auto& cache = GetBitmapFontsCache(); + auto it = cache.find(fntFile); + if (it != cache.end()) { + return &it->second; + } + + BitmapFont font{}; + if (!font.initWithFile(fntFile)) { + return nullptr; + } + + font.initBakedValues(); + + auto [it2, _] = cache.emplace(fntFile, std::move(font)); + return &it2->second; +} + +void BitmapFont::purgeFont(ZStringView fntFile) { + GetBitmapFontsCache().erase(fntFile); +} + +void BitmapFont::purgeFont(BitmapFont const* font) { + auto& cache = GetBitmapFontsCache(); + for (auto it = cache.begin(); it != cache.end(); ++it) { + if (&it->second == font) { + cache.erase(it); + break; + } + } +} + +void BitmapFont::purgeAllFonts() { + GetBitmapFontsCache().clear(); +} + +template +static void forEachPair(std::string_view line, F&& fn) { + for (auto p : asp::iter::split(line, ' ')) { + for (auto pair : asp::iter::split(p, '\t')) { + auto arr = asp::iter::split(pair, '=').collect>(); + auto size = arr.size(); + if (size == 2) fn(arr[0], arr[1]); + else if (size == 1) fn(arr[0], std::string_view("")); + } + } +} + +bool BitmapFont::initWithFile(ZStringView fntFile) { +#if defined(GEODE_IS_MOBILE) || !defined(NDEBUG) + unsigned long size = 0; + uint8_t* data = CCFileUtils::get()->getFileData(fntFile.c_str(), "rb", &size); + if (!data || !size) { + return false; + } + + std::unique_ptr buffer(data); + auto contents = std::string_view(reinterpret_cast(data), size); +#else + auto fullPath = CCFileUtils::get()->fullPathForFilename(fntFile.c_str(), false); + auto res = file::readString(fullPath); + if (res.isErr()) { + return false; + } + + auto contents = std::move(res).unwrap(); +#endif + + m_fntFilename = fntFile; + + if (!this->initWithContents(contents)) { + return false; + } + + if (m_atlasName.empty()) { + return false; + } + + return true; +} + +bool BitmapFont::initWithContents(std::string_view text) { + for (auto line : asp::iter::lines(text)) { + if (line.starts_with("char ")) { + line.remove_prefix(5); + + CharDef def; + forEachPair(line, [&def](std::string_view key, std::string_view value) { + if (key == "id") { + def.codepoint = utils::numFromString(value).unwrapOrDefault(); + } else if (key == "x") { + def.rect.origin.x = utils::numFromString(value).unwrapOrDefault(); + } else if (key == "y") { + def.rect.origin.y = utils::numFromString(value).unwrapOrDefault(); + } else if (key == "width") { + def.rect.size.width = utils::numFromString(value).unwrapOrDefault(); + } else if (key == "height") { + def.rect.size.height = utils::numFromString(value).unwrapOrDefault(); + } else if (key == "xoffset") { + def.xOffset = utils::numFromString(value).unwrapOrDefault(); + } else if (key == "yoffset") { + def.yOffset = utils::numFromString(value).unwrapOrDefault(); + } else if (key == "xadvance") { + def.xAdvance = utils::numFromString(value).unwrapOrDefault(); + } + }); + + m_characters.emplace(def.codepoint, std::move(def)); + } else if (line.starts_with("kerning ")) { + line.remove_prefix(8); + + KerningPair pair{}; + float amount = 0.f; + forEachPair(line, [&](std::string_view key, std::string_view value) { + if (key == "first") { + pair.first = utils::numFromString(value).unwrapOrDefault(); + } else if (key == "second") { + pair.second = utils::numFromString(value).unwrapOrDefault(); + } else if (key == "amount") { + amount = utils::numFromString(value).unwrapOrDefault(); + } + }); + + m_kerning.emplace(pair, KerningValue{amount, amount}); + } else if (line.starts_with("info ")) { + line.remove_prefix(5); + forEachPair(line, [&](std::string_view key, std::string_view value) { + if (key == "padding") { + int values[4] = {}; + + int index = 0; + for (auto p : asp::iter::split(value, ',')) { + if (index >= 4) break; + values[index++] = utils::numFromString(p).unwrapOrDefault(); + } + + m_padding.left = values[0]; + m_padding.top = values[1]; + m_padding.right = values[2]; + m_padding.bottom = values[3]; + } + }); + } else if (line.starts_with("common ")) { + line.remove_prefix(7); + + auto max = CCConfiguration::sharedConfiguration()->getMaxTextureSize(); + bool error = false; + forEachPair(line, [&](std::string_view key, std::string_view value) { + if (error) return; + + if (key == "lineHeight") { + m_commonHeight = utils::numFromString(value).unwrapOrDefault(); + } else if (key == "scaleW") { + auto val = utils::numFromString(value).unwrapOrDefault(); + m_atlasSize.width = val; + + if (val > max) { + log::error("BitmapFont width {} exceeds max texture limit {}", val, max); + error = true; + } + + } else if (key == "scaleH") { + auto val = utils::numFromString(value).unwrapOrDefault(); + m_atlasSize.height = val; + + if (val > max) { + log::error("BitmapFont height {} exceeds max texture limit {}", val, max); + error = true; + } + } else if (key == "pages") { + auto val = utils::numFromString(value).unwrapOrDefault(); + if (val != 1) { + log::error("BitmapFont contains more than one atlas ({})", val); + error = true; + } + } + }); + + if (error) { + return false; + } + } else if (line.starts_with("page ")) { + line.remove_prefix(5); + forEachPair(line, [&](std::string_view key, std::string_view value) { + if (key == "file") { + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') { + value = value.substr(1, value.size() - 2); + } + + if (m_fntFilename.empty()) { + m_atlasName = value; + } else { + StringBuffer<> relPathBuf(value); // null-terminate + m_atlasName = CCFileUtils::get()->fullPathFromRelativeFile(relPathBuf.c_str(), m_fntFilename.c_str()); + } + } + }); + } else if (line.starts_with("chars ")) { + line.remove_prefix(6); + forEachPair(line, [&](std::string_view key, std::string_view value) { + if (key == "count") { + m_characters.reserve(utils::numFromString(value).unwrapOrDefault()); + } + }); + } else if (line.starts_with("kernings ")) { + line.remove_prefix(9); + forEachPair(line, [&](std::string_view key, std::string_view value) { + if (key == "count") { + m_kerning.reserve(utils::numFromString(value).unwrapOrDefault()); + } + }); + } + } + + return true; +} + +void BitmapFont::initBakedValues() { + auto scale = 1.f / CCDirector::get()->getContentScaleFactor(); + + for (auto& def : m_characters | std::views::values) { + def.sizeScaled = def.rect.size * scale; + def.xOffsetScaled = def.xOffset * scale; + def.yOffsetScaled = def.yOffset * scale; + def.xAdvanceScaled = def.xAdvance * scale; + def.uv = CCRect{ + def.rect.origin.x / m_atlasSize.width, // left + def.rect.origin.y / m_atlasSize.height, // top + (def.rect.origin.x + def.rect.size.width) / m_atlasSize.width, // right + (def.rect.origin.y + def.rect.size.height) / m_atlasSize.height // bottom + }; + } + + for (auto& kerning : m_kerning | std::views::values) { + kerning.scaled *= scale; + } + + m_commonHeightScaled = m_commonHeight * scale; +} + +void BitmapFont::setFontFile(std::string file) noexcept { + m_fntFilename = std::move(file); +} + +ZStringView BitmapFont::getFontFile() const noexcept { + return m_fntFilename; +} + +void BitmapFont::setAtlasName(std::string name) noexcept { + m_atlasName = std::move(name); +} + +ZStringView BitmapFont::getAtlasName() const noexcept { + return m_atlasName; +} + +std::unordered_map const& BitmapFont::getCharDefs() const noexcept { + return m_characters; +} + +BitmapFont::KerningMap const& BitmapFont::getKernings() const noexcept { + return m_kerning; +} + +BitmapFont::Padding BitmapFont::getPadding() const noexcept { + return m_padding; +} + +CCSize BitmapFont::getAtlasSize() const noexcept { + return m_atlasSize; +} + +float BitmapFont::getCommonHeight() const noexcept { + return m_commonHeight; +} + +float BitmapFont::getCommonHeightScaled() const noexcept { + return m_commonHeightScaled; +} + +float BitmapFont::getKerning(char32_t first, char32_t second) const noexcept { + auto it = m_kerning.find({ first, second }); + if (it != m_kerning.end()) { + return it->second.scaled; + } + return 0.f; +} + +struct EmojiRegistry::Impl { + std::vector m_keys; + std::vector m_entries; + + ListenerHandle m_textureReloadHandle; + + Impl() { + m_textureReloadHandle = GameEvent(GameEventType::TexturesUnloaded).listen([this]() { + for (auto& entry : m_entries) { + if (auto* emoji = std::get_if(&entry)) { + emoji->invalidate(); + } + } + }); + } + + Entry* match(std::u32string_view str, size_t& index) { + if (index >= str.size() || m_keys.empty()) { + return nullptr; + } + + std::u32string_view slice = str.substr(index); + + auto it = std::ranges::upper_bound(m_keys, slice); + + for (auto r = std::make_reverse_iterator(it); r != m_keys.rend(); ++r) { + std::u32string_view key = *r; + + if (key.size() <= slice.size() && slice.starts_with(key)) { + size_t idx = std::distance(m_keys.begin(), r.base() - 1); + index += key.size() - 1; + return &m_entries[idx]; + } + + if (!key.empty() && key[0] < slice[0]) { + break; + } + } + + return nullptr; + } + + void insert(std::u32string_view sequence, Entry entry) { + auto it = std::ranges::lower_bound(m_keys, sequence); + auto index = std::distance(m_keys.begin(), it); + + if (it != m_keys.end() && *it == sequence) { + m_entries[index] = std::move(entry); + } else { + m_keys.insert(it, sequence); + m_entries.insert(m_entries.begin() + index, std::move(entry)); + } + } + + void insert(std::span> entries) { + if (entries.empty()) return; + + size_t newSize = m_keys.size() + entries.size(); + m_keys.reserve(newSize); + m_entries.reserve(newSize); + + for (auto& item : entries) { + m_keys.push_back(std::get<0>(item)); + m_entries.push_back(std::move(std::get<1>(item))); + } + + std::vector indices(m_keys.size()); + for (size_t i = 0; i < indices.size(); ++i) { + indices[i] = i; + } + + std::ranges::sort(indices, [this](size_t a, size_t b) { + return m_keys[a] < m_keys[b]; + }); + + std::vector sortedKeys; + std::vector sortedEntries; + sortedKeys.reserve(m_keys.size()); + sortedEntries.reserve(m_entries.size()); + + for (size_t idx : indices) { + sortedKeys.push_back(m_keys[idx]); + sortedEntries.push_back(std::move(m_entries[idx])); + } + + m_keys = std::move(sortedKeys); + m_entries = std::move(sortedEntries); + } + + void erase(std::u32string_view sequence) { + auto it = std::ranges::lower_bound(m_keys, sequence); + auto index = std::distance(m_keys.begin(), it); + if (it != m_keys.end() && *it == sequence) { + m_entries.erase(m_entries.begin() + index); + m_keys.erase(it); + } + } +}; + +class SharedIndexBuffer { +public: + static SharedIndexBuffer& get() { + static SharedIndexBuffer instance; + return instance; + } + + GLuint getIBO(size_t quads) { + if (m_ibo == 0) { + glGenBuffers(1, &m_ibo); + } + + if (quads > m_capacity) { + size_t newCapacity = std::max(quads, m_capacity + m_capacity / 2 + 128); + + std::vector indices(newCapacity * 6); + for (size_t i = 0; i < newCapacity; ++i) { + auto v = static_cast(i * 4); + indices[i * 6 + 0] = v + 0; + indices[i * 6 + 1] = v + 1; + indices[i * 6 + 2] = v + 2; + indices[i * 6 + 3] = v + 2; + indices[i * 6 + 4] = v + 1; + indices[i * 6 + 5] = v + 3; + } + + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ibo); + glBufferData( + GL_ELEMENT_ARRAY_BUFFER, + indices.size() * sizeof(uint32_t), + indices.data(), + GL_STATIC_DRAW + ); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); + + m_capacity = newCapacity; + } + + return m_ibo; + } + + void purge() { + if (m_ibo != 0) { + glDeleteBuffers(1, &m_ibo); + m_ibo = 0; + m_capacity = 0; + } + } + +private: + GLuint m_ibo = 0; + size_t m_capacity = 0; +}; + +$on_game(TexturesUnloaded) { + BitmapFont::purgeAllFonts(); + SharedIndexBuffer::get().purge(); +} + +struct LabelFontBatch { + Ref texture; + std::vector quads; + size_t vboCapacity = 0; + GLuint vbo = 0; + bool isDirty = true; + + LabelFontBatch() = default; + LabelFontBatch(CCTexture2D* tex) : texture(tex) {} + LabelFontBatch(LabelFontBatch const&) = delete; + LabelFontBatch(LabelFontBatch&& o) noexcept + : texture(std::move(o.texture)), + quads(std::move(o.quads)), + vboCapacity(o.vboCapacity), + vbo(o.vbo), isDirty(o.isDirty) + { + o.vbo = 0; + o.vboCapacity = 0; + } + + ~LabelFontBatch() { + if (vbo) glDeleteBuffers(1, &vbo); + } + + LabelFontBatch& operator=(LabelFontBatch const&) = delete; + LabelFontBatch& operator=(LabelFontBatch&& o) noexcept { + if (this != &o) { + if (vbo) glDeleteBuffers(1, &vbo); + texture = std::move(o.texture); + quads = std::move(o.quads); + vboCapacity = std::exchange(o.vboCapacity, 0); + vbo = std::exchange(o.vbo, 0); + isDirty = o.isDirty; + } + return *this; + } + + void upload() { + if (!isDirty || quads.empty()) return; + + if (vbo == 0) glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + + if (quads.size() > vboCapacity) { + vboCapacity = quads.size() + quads.size() / 2 + 16; + } + + glBufferData(GL_ARRAY_BUFFER, vboCapacity * sizeof(ccV2F_C4B_T2F_Quad), nullptr, GL_STREAM_DRAW); + glBufferSubData(GL_ARRAY_BUFFER, 0, quads.size() * sizeof(ccV2F_C4B_T2F_Quad), quads.data()); + + isDirty = false; + } + + void draw() const { + if (quads.empty()) return; + + ccGLBindTexture2D(texture->getName()); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + + glVertexAttribPointer( + kCCVertexAttrib_Position, + 2, GL_FLOAT, GL_FALSE, + sizeof(ccV2F_C4B_T2F), + reinterpret_cast(offsetof(ccV2F_C4B_T2F, vertices)) + ); + glVertexAttribPointer( + kCCVertexAttrib_Color, + 4, GL_UNSIGNED_BYTE, GL_TRUE, + sizeof(ccV2F_C4B_T2F), + reinterpret_cast(offsetof(ccV2F_C4B_T2F, colors)) + ); + glVertexAttribPointer( + kCCVertexAttrib_TexCoords, + 2, GL_FLOAT, GL_FALSE, + sizeof(ccV2F_C4B_T2F), + reinterpret_cast(offsetof(ccV2F_C4B_T2F, texCoords)) + ); + + glDrawElements( + GL_TRIANGLES, + static_cast(quads.size() * 6), + GL_UNSIGNED_INT, + nullptr + ); + } +}; + +struct CharQuadRef { + uint32_t batchIndex : 8; + uint32_t charIndex : 24; +}; + +struct ShapedItem { + enum class Kind : uint8_t { + Glyph, + Emoji, + Newline, + Space, + Node, + } kind; + uint8_t fontIndex; + float advance; + + union { + BitmapFont::CharDef const* def; // glyph + CCNode* node; // embedded + CCSpriteFrame* frame; // emoji + }; +}; + +struct WordSpan { + uint32_t start, end; + float width; + bool isWhitespace; +}; + +struct LineSpan { + uint32_t start, end; + float width; + uint32_t whitespaceCount; +}; + +struct StyleSpan { + uint32_t start, end; + std::optional color; +}; + +struct Label::Impl { + std::string m_text; + asp::SmallVec m_fonts; + asp::SmallVec m_batches; + std::vector m_chars; + + Label* m_label; + std::shared_ptr m_emojiRegistry; + asp::SmallVec m_emojiBatches; + std::vector> m_embeddedNodes; + + std::vector m_shaped; + asp::SmallVec m_words; + asp::SmallVec m_lines; + + std::vector m_styles; + + float m_spaceWidth = 0.f; + float m_maxLineWidth = 0.f; + float m_extraKerning = 0.f; + float m_extraLineSpacing = 0.f; + + Alignment m_alignment = Alignment::Left; + bool m_wordBreak = false; + bool m_lineBreak = false; + bool m_isOpacityModifyRGB = true; + + ccBlendFunc m_blendFunc = {CC_BLEND_SRC, CC_BLEND_DST}; + ccColor3B m_color = {255, 255, 255}; + GLubyte m_opacity = 255; + + CCSize m_limitSize{}; + float m_defaultScale = 1.f; + float m_minScale = 0.f; + bool m_limitChanged = false; + bool m_fitLabelSize = false; + + bool m_emojisUseColors = false; + bool m_customNodesColors = false; + + bool m_useRichText = false; + + bool m_textDirty = true; + bool m_layoutDirty = true; + bool m_quadsDirty = true; + + Impl(Label* label) : m_label(label) {} + + float getFontScale(size_t fontIndex) const { + if (fontIndex == 0 || m_fonts.empty()) return 1.0f; + + float baseHeight = m_fonts.data()[0]->getCommonHeight(); + float fallbackHeight = m_fonts.data()[fontIndex]->getCommonHeight(); + + if (fallbackHeight <= 0.0f) return 1.0f; + return baseHeight / fallbackHeight; + } + + ccColor4B getEffectiveColor(ccColor3B color) const { + if (m_isOpacityModifyRGB) { + return { + static_cast(color.r * m_opacity / 255), + static_cast(color.g * m_opacity / 255), + static_cast(color.b * m_opacity / 255), + static_cast(m_opacity) + }; + } + + return { color.r, color.g, color.b, m_opacity }; + } + + ccColor4B getEffectiveColor() const { + return this->getEffectiveColor(m_color); + } + + ccColor4B getEmojiColor() const { + if (m_emojisUseColors) { + return { m_color.r, m_color.g, m_color.b, m_opacity }; + } + return { 255, 255, 255, m_opacity }; + } + + uint8_t getOrCreateBatch(CCTexture2D* texture) { + for (size_t i = 0; i < m_emojiBatches.size(); i++) { + if (m_emojiBatches[i].texture == texture) { + return static_cast(i); + } + } + + m_emojiBatches.emplace_back(LabelFontBatch{texture}); + return static_cast(m_emojiBatches.size() - 1); + } + + bool tryMatchEmojiOrNode(std::u32string_view str, size_t& index) { + if (!m_emojiRegistry) return false; + + auto entry = m_emojiRegistry->match(str, index); + if (!entry) return false; + + float baseHeight = m_fonts.data()[0]->getCommonHeightScaled(); + + std::visit([&](T& e) { + if constexpr (std::is_same_v) { + auto frame = e.getFrame(); + if (!frame) return; + + auto size = frame->getRect().size; + auto batchIndex = this->getOrCreateBatch(frame->getTexture()); + + m_shaped.push_back(ShapedItem{ + .kind = ShapedItem::Kind::Emoji, + .fontIndex = batchIndex, + .advance = size.width * (baseHeight / size.height), + .frame = frame, + }); + } else if constexpr (std::is_same_v) { + auto node = e(); + if (!node) return; + + auto size = node->getContentSize(); + + m_shaped.push_back(ShapedItem{ + .kind = ShapedItem::Kind::Node, + .fontIndex = 0, + .advance = size.width * (baseHeight / size.height), + .node = node, + }); + + m_label->addChild(node); + m_embeddedNodes.push_back(node); + } else if constexpr (std::is_same_v) { + auto node = e(str, index); + if (!node) return; + + auto size = node->getContentSize(); + + m_shaped.push_back(ShapedItem{ + .kind = ShapedItem::Kind::Node, + .fontIndex = 0, + .advance = size.width * (baseHeight / size.height), + .node = node, + }); + + m_label->addChild(node); + m_embeddedNodes.push_back(node); + } + }, *entry); + + return true; + } + + std::tuple findGlyphDef(char32_t& ch) { + char32_t upper = (ch >= U'a' && ch <= U'z') ? (ch - U'a' + U'A') : ch; // backwards compat with CCLabelBMFont + + for (uint8_t j = 0; j < m_fonts.size(); ++j) { + auto const& chars = m_fonts[j]->getCharDefs(); + auto it = chars.find(ch); + if (it != chars.end()) { + return { &it->second, j }; + } + + if (ch != upper) { + auto it2 = chars.find(upper); + if (it2 != chars.end()) { + ch = upper; + return {&it2->second, j}; + } + } + } + + return {nullptr, 0}; + } + + void appendGlyphItem( + char32_t ch, + BitmapFont::CharDef const* def, + uint8_t fontIndex, + uint32_t& prevCp, + uint8_t& prevFontIndex + ) { + float fontScale = this->getFontScale(fontIndex); + + float advance = def->xAdvanceScaled * fontScale; + if (prevCp != 0 && prevFontIndex == fontIndex) { + float kerning = m_fonts[fontIndex]->getKerning(prevCp, ch) * fontScale; + if (kerning != 0.f && !m_shaped.empty()) { + m_shaped.back().advance += kerning; + } + } + advance += m_extraKerning; + + m_shaped.push_back(ShapedItem{ + .kind = ShapedItem::Kind::Glyph, + .fontIndex = fontIndex, + .advance = advance, + .def = def + }); + + prevCp = ch; + prevFontIndex = fontIndex; + } + + static constexpr int parseHexDigit(char32_t ch) { + if (ch >= U'0' && ch <= U'9') return ch - U'0'; + if (ch >= U'a' && ch <= U'f') return ch - U'a' + 10; + if (ch >= U'A' && ch <= U'F') return ch - U'A' + 10; + return -1; + } + + static std::optional parseHexColor(std::u32string_view str) { + if (str.size() == 3) { + int r = parseHexDigit(str[0]); + int g = parseHexDigit(str[1]); + int b = parseHexDigit(str[2]); + + if (r < 0 || g < 0 || b < 0) return std::nullopt; + + return ccColor3B{ + static_cast(r * 17), + static_cast(g * 17), + static_cast(b * 17) + }; + } + + if (str.size() == 6) { + int r1 = parseHexDigit(str[0]); + int r2 = parseHexDigit(str[1]); + int g1 = parseHexDigit(str[2]); + int g2 = parseHexDigit(str[3]); + int b1 = parseHexDigit(str[4]); + int b2 = parseHexDigit(str[5]); + + if (r1 < 0 || r2 < 0 || g1 < 0 || g2 < 0 || b1 < 0 || b2 < 0) return std::nullopt; + + return ccColor3B{ + static_cast((r1 << 4) | r2), + static_cast((g1 << 4) | g2), + static_cast((b1 << 4) | b2), + }; + } + + return std::nullopt; + } + + static bool tryParseRichTags(std::u32string_view str, size_t& i, std::vector& colorStack) { + if (str[i] != U'<') return false; + + size_t closePos = str.find(U'>', i); + if (closePos == std::string::npos) return false; + + auto tag = str.substr(i + 1, closePos - i - 1); + if (tag.empty()) return false; + + if (tag == U"/c") { + if (colorStack.empty()) return false; + + colorStack.pop_back(); + i = closePos; + return true; + } + + if (tag[0] == U'c') { + ccColor3B color{255, 0, 0}; + + if (tag.size() == 2) { + switch (tag[1]) { + case U'b': color = {74, 82, 225}; break; + case U'g': color = {64, 227, 72}; break; + case U'l': color = {96, 171, 239}; break; + case U'j': color = {50, 200, 255}; break; + case U'y': color = {255, 255, 0}; break; + case U'o': color = {255, 90, 75}; break; + case U'r': color = {255, 90, 90}; break; + case U'p': color = {255, 0, 255}; break; + case U'a': color = {149, 50, 255}; break; + case U'd': color = {255, 150, 255}; break; + case U'c': color = {255, 255, 150}; break; + case U'f': color = {150, 255, 255}; break; + case U's': color = {255, 220, 65}; break; + default: break; + } + } else if (tag.size() > 2 && tag[1] == U'-') { + auto hex = tag.substr(2); + if (auto parsed = parseHexColor(hex)) { + color = parsed.value(); + } + } + + colorStack.push_back(color); + i = closePos; + return true; + } + + return false; + } + + void reshapeRich(std::u32string_view str) { + uint32_t prevCp = 0; + uint8_t prevFontIndex = 0; + + std::vector colorStack; + + auto currentColor = [&]() -> std::optional { + return colorStack.empty() ? std::nullopt : std::optional(colorStack.back()); + }; + + auto openSpan = [&](uint32_t start) { + auto color = currentColor(); + + if (!m_styles.empty() && m_styles.back().end == start && m_styles.back().color == color) { + m_styles.back().end = std::numeric_limits::max(); + return; + } + + m_styles.push_back(StyleSpan{ + .start = start, + .end = std::numeric_limits::max(), + .color = currentColor() + }); + }; + + auto closeSpan = [&](uint32_t end) { + if (!m_styles.empty() && m_styles.back().end == std::numeric_limits::max()) { + if (m_styles.back().start == end) { + m_styles.pop_back(); + } else { + m_styles.back().end = end; + } + } + }; + + openSpan(0); + + for (size_t i = 0; i < str.length(); i++) { + char32_t ch = str[i]; + switch (ch) { + case '\n': { + m_shaped.push_back(ShapedItem{ + .kind = ShapedItem::Kind::Newline, + .fontIndex = 0, + .advance = 0.f, + .def = nullptr + }); + prevCp = 0; + break; + } + case ' ': + case '\t': { + float advance = m_spaceWidth + m_extraKerning; + if (ch == '\t') advance *= 4.f; + + m_shaped.push_back(ShapedItem{ + .kind = ShapedItem::Kind::Space, + .fontIndex = 0, + .advance = advance, + .def = nullptr + }); + + prevCp = ch; + prevFontIndex = 0; + break; + } + default: { + if (this->tryMatchEmojiOrNode(str, i)) { + prevCp = 0; + continue; + } + + if (this->tryParseRichTags(str, i, colorStack)) { + auto idx = m_shaped.size(); + closeSpan(idx); + openSpan(idx); + continue; + } + + auto [def, fontIndex] = this->findGlyphDef(ch); + if (!def) break; + + this->appendGlyphItem(ch, def, fontIndex, prevCp, prevFontIndex); + break; + } + } + } + + closeSpan(m_shaped.size()); + } + + void reshape() { + for (auto& node : m_embeddedNodes) { + node->removeFromParent(); + } + + m_embeddedNodes.clear(); + m_shaped.clear(); + m_styles.clear(); + + fmt::basic_memory_buffer buffer; + buffer.resize(simdutf::utf32_length_from_utf8(m_text)); + if (simdutf::convert_utf8_to_utf32(m_text.data(), m_text.size(), buffer.data()) == 0) { + return; + } + + auto str = std::u32string_view(buffer.data(), buffer.size()); + m_shaped.reserve(str.size()); + + if (m_useRichText) { + this->reshapeRich(str); + return; + } + + uint32_t prevCp = 0; + uint8_t prevFontIndex = 0; + + for (size_t i = 0; i < str.length(); i++) { + char32_t ch = str[i]; + switch (ch) { + case '\n': { + m_shaped.push_back(ShapedItem{ + .kind = ShapedItem::Kind::Newline, + .fontIndex = 0, + .advance = 0.f, + .def = nullptr + }); + prevCp = 0; + break; + } + case ' ': + case '\t': { + float advance = m_spaceWidth + m_extraKerning; + if (ch == '\t') advance *= 4.f; + + m_shaped.push_back(ShapedItem{ + .kind = ShapedItem::Kind::Space, + .fontIndex = 0, + .advance = advance, + .def = nullptr + }); + + prevCp = ch; + prevFontIndex = 0; + break; + } + default: { + if (this->tryMatchEmojiOrNode(str, i)) { + prevCp = 0; + continue; + } + + auto [def, fontIndex] = this->findGlyphDef(ch); + if (!def) break; + + this->appendGlyphItem(ch, def, fontIndex, prevCp, prevFontIndex); + break; + } + } + } + } + + void splitWords() { + m_words.clear(); + m_words.reserve(m_shaped.size()); + + uint32_t i = 0; + while (i < m_shaped.size()) { + auto kind = m_shaped[i].kind; + + if (kind == ShapedItem::Kind::Newline) { + m_words.push_back(WordSpan{ i, i + 1, 0.f, false }); + ++i; + continue; + } + + bool isWs = kind == ShapedItem::Kind::Space; + uint32_t start = i; + float width = 0.f; + + while ( + i < m_shaped.size() + && m_shaped[i].kind != ShapedItem::Kind::Newline + && (m_shaped[i].kind == ShapedItem::Kind::Space) == isWs + ) { + width += m_shaped[i].advance; + ++i; + } + + m_words.push_back(WordSpan{ start, i, width, isWs }); + } + } + + void breakLinesSimple() { + m_lines.clear(); + + if (m_words.empty()) return; + + uint32_t lineWordStart = 0; + float width = 0.f; + uint32_t whitespaceCount = 0; + + for (uint32_t wi = 0; wi < m_words.size(); ++wi) { + auto const& w = m_words[wi]; + + if (w.end - w.start == 1 && m_shaped[w.start].kind == ShapedItem::Kind::Newline) { + m_lines.push_back(LineSpan{ lineWordStart, wi, width, whitespaceCount }); + lineWordStart = wi + 1; + width = 0.f; + whitespaceCount = 0; + continue; + } + + width += w.width; + if (w.isWhitespace && wi > lineWordStart) ++whitespaceCount; + } + + m_lines.push_back(LineSpan{ lineWordStart, static_cast(m_words.size()), width, whitespaceCount }); + } + + void breakLongWords() { + if (m_words.empty()) return; + + asp::SmallVec result; + result.reserve(m_words.size()); + + for (auto const& w : m_words) { + if (w.isWhitespace || w.width <= m_maxLineWidth) { + result.emplace_back(w); + continue; + } + + uint32_t segStart = w.start; + float segWidth = 0.f; + + for (uint32_t i = w.start; i < w.end; ++i) { + float advance = m_shaped[i].advance; + + if (segWidth + advance > m_maxLineWidth && i > segStart) { + result.push_back(WordSpan{ segStart, i, segWidth, false }); + segStart = i; + segWidth = 0.f; + } + + segWidth += advance; + } + + result.push_back(WordSpan{ segStart, w.end, segWidth, false }); + } + + m_words = std::move(result); + } + + void breakLinesWrapped() { + m_lines.clear(); + + if (m_words.empty()) return; + + uint32_t lineWordStart = 0; + float lineWidth = 0.f; + float lineWidthNoSpace = 0.f; + uint32_t whitespaceCount = 0; + + auto pushLine = [&](uint32_t end) { + if (end > lineWordStart && m_words[end - 1].isWhitespace) { + --end; + } + m_lines.push_back(LineSpan{ lineWordStart, end, lineWidthNoSpace, whitespaceCount }); + }; + + for (uint32_t wi = 0; wi < m_words.size(); ++wi) { + auto const& w = m_words[wi]; + + if (w.end - w.start == 1 && m_shaped[w.start].kind == ShapedItem::Kind::Newline) { + pushLine(wi); + lineWordStart = wi + 1; + lineWidth = 0.f; + lineWidthNoSpace = 0.f; + whitespaceCount = 0; + continue; + } + + if (wi != lineWordStart && lineWidth + w.width > m_maxLineWidth) { + pushLine(wi); + lineWordStart = wi; + + // strip leading whitespace + while (lineWordStart < m_words.size() && m_words[lineWordStart].isWhitespace) { + auto const& w1 = m_words[lineWordStart]; + if (w1.end - w1.start == 1 && m_shaped[w1.start].kind == ShapedItem::Kind::Newline) { + break; + } + ++lineWordStart; + } + + lineWidth = 0.f; + lineWidthNoSpace = 0.f; + whitespaceCount = 0; + } + + lineWidth += w.width; + if (!w.isWhitespace) { + lineWidthNoSpace = lineWidth; + if (wi > lineWordStart && m_words[wi - 1].isWhitespace) { + ++whitespaceCount; + } + } + } + + pushLine(m_words.size()); + } + + void breakLines() { + if (m_lineBreak && m_maxLineWidth > 0.f) { + if (m_wordBreak) { + this->breakLongWords(); + } + this->breakLinesWrapped(); + } else { + this->breakLinesSimple(); + } + } + + void emitQuads() { + for (auto& batch : m_batches) { + batch.quads.clear(); + batch.isDirty = true; + } + + for (auto& batch : m_emojiBatches) { + batch.quads.clear(); + batch.isDirty = true; + } + + m_chars.clear(); + m_chars.reserve(m_shaped.size()); + + if (m_lines.empty() || m_fonts.empty()) { + m_label->setContentSize({0.f, 0.f}); + return; + } + + float alignWidth = 0.f; + for (auto const& line : m_lines) alignWidth = std::max(alignWidth, line.width); + + auto color = this->getEffectiveColor(); + float commonHeight = m_fonts[0]->getCommonHeightScaled(); + float lineHeight = commonHeight + m_extraLineSpacing; + + float maxLineWidth = 0.f; + float totalHeight = lineHeight * m_lines.size(); + float y = totalHeight; + + size_t styleIdx = 0; + auto getColor = [&](uint32_t i) -> ccColor4B { + if (!m_useRichText) return color; + + while (styleIdx + 1 < m_styles.size() && i >= m_styles[styleIdx].end) { + ++styleIdx; + } + + if (styleIdx < m_styles.size() && m_styles[styleIdx].color) { + return this->getEffectiveColor(*m_styles[styleIdx].color); + } + + return color; + }; + + for (uint32_t li = 0; li < m_lines.size(); ++li) { + auto const& line = m_lines[li]; + bool isLastLine = li == m_lines.size() - 1; + + float startX = 0.f; + float extraPerGap = 0.f; + + switch (m_alignment) { + case Alignment::Center: + startX = (alignWidth - line.width) * 0.5f; + break; + case Alignment::Right: + startX = alignWidth - line.width; + break; + case Alignment::Justify: + if (!isLastLine && line.whitespaceCount > 0 && alignWidth > 0.f) { + extraPerGap = (alignWidth - line.width) / static_cast(line.whitespaceCount); + } + break; + case Alignment::Left: + default: + break; + } + + float x = startX; + float lineMaxX = startX; + + for (uint32_t wi = line.start; wi < line.end; ++wi) { + auto const& word = m_words[wi]; + + for (uint32_t ii = word.start; ii < word.end; ++ii) { + auto const& item = m_shaped[ii]; + + switch (item.kind) { + case ShapedItem::Kind::Glyph: { + auto const& def = *item.def; + auto& batch = m_batches[item.fontIndex]; + float fontScale = this->getFontScale(item.fontIndex); + + float qx = x + def.xOffsetScaled * fontScale; + float qy = y - def.yOffsetScaled * fontScale; + float w = def.sizeScaled.width * fontScale; + float h = def.sizeScaled.height * fontScale; + + lineMaxX = std::max(lineMaxX, qx + w); + + auto newColor = getColor(ii); + + batch.quads.push_back({ + .bl = { .vertices = { qx, qy - h }, .colors = newColor, .texCoords = { def.uv.origin.x, def.uv.size.height } }, + .br = { .vertices = { qx + w, qy - h }, .colors = newColor, .texCoords = { def.uv.size.width, def.uv.size.height } }, + .tl = { .vertices = { qx, qy }, .colors = newColor, .texCoords = { def.uv.origin.x, def.uv.origin.y } }, + .tr = { .vertices = { qx + w, qy }, .colors = newColor, .texCoords = { def.uv.size.width, def.uv.origin.y } }, + }); + + m_chars.push_back({ + .batchIndex = item.fontIndex, + .charIndex = static_cast(batch.quads.size() - 1) + }); + break; + } + case ShapedItem::Kind::Node: { + auto size = item.node->getContentSize(); + float scale = size.height > 0.f ? commonHeight / size.height : 1.f; + item.node->setScale(scale); + item.node->setPosition({ x + size.width * scale * 0.5f, y + commonHeight * 0.5f - commonHeight }); + if (auto rgba = typeinfo_cast(item.node)) { + if (m_customNodesColors) rgba->setColor(m_color); + rgba->setOpacity(m_opacity); + } + lineMaxX = std::max(lineMaxX, x + item.advance); + break; + } + case ShapedItem::Kind::Emoji: { + auto frame = item.frame; + auto const& rectPoints = frame->getRect(); + auto const& rectPixels = frame->getRectInPixels(); + bool rotated = frame->isRotated(); + + auto& batch = m_emojiBatches[item.fontIndex]; + auto texture = batch.texture.data(); + float texW = static_cast(texture->getPixelsWide()); + float texH = static_cast(texture->getPixelsHigh()); + + float blU, blV, brU, brV, tlU, tlV, trU, trV; + + if (!rotated) { + float left = rectPixels.origin.x / texW; + float right = (rectPixels.origin.x + rectPixels.size.width) / texW; + float top = rectPixels.origin.y / texH; + float bottom = (rectPixels.origin.y + rectPixels.size.height) / texH; + + blU = left; blV = bottom; + brU = right; brV = bottom; + tlU = left; tlV = top; + trU = right; trV = top; + } else { + float left = rectPixels.origin.x / texW; + float right = left + rectPixels.size.height / texW; + float top = rectPixels.origin.y / texH; + float bottom = top + rectPixels.size.width / texH; + + blU = left; blV = top; + brU = left; brV = bottom; + tlU = right; tlV = top; + trU = right; trV = bottom; + } + + float scale = rectPoints.size.height > 0.f ? commonHeight / rectPoints.size.height : 1.f; + float w = rectPoints.size.width * scale; + float h = rectPoints.size.height * scale; + float qx = x; + float qy = y; + + auto emojiColor = this->getEmojiColor(); + + batch.quads.push_back({ + .bl = { .vertices = { qx, qy - h }, .colors = emojiColor, .texCoords = { blU, blV } }, + .br = { .vertices = { qx + w, qy - h }, .colors = emojiColor, .texCoords = { brU, brV } }, + .tl = { .vertices = { qx, qy }, .colors = emojiColor, .texCoords = { tlU, tlV } }, + .tr = { .vertices = { qx + w, qy }, .colors = emojiColor, .texCoords = { trU, trV } }, + }); + lineMaxX = std::max(lineMaxX, x + item.advance); + break; + } + case ShapedItem::Kind::Space: + case ShapedItem::Kind::Newline: + break; + } + + x += item.advance; + if (extraPerGap != 0.f && item.kind == ShapedItem::Kind::Space) { + x += extraPerGap; + } + lineMaxX = std::max(lineMaxX, x); + } + } + + maxLineWidth = std::max(maxLineWidth, lineMaxX); + y -= lineHeight; + } + + m_label->setContentSize({ maxLineWidth, lineHeight * m_lines.size() }); + } + + void updateQuads() { + auto color = this->getEffectiveColor(); + + if (!m_useRichText || m_styles.empty()) { + for (auto& batch : m_batches) { + for (auto& quad : batch.quads) { + quad.bl.colors = color; + quad.br.colors = color; + quad.tl.colors = color; + quad.tr.colors = color; + } + batch.isDirty = true; + } + } else { + size_t charIdx = 0; + size_t styleIdx = 0; + + for (uint32_t ii = 0; ii < m_shaped.size(); ++ii) { + if (m_shaped[ii].kind != ShapedItem::Kind::Glyph) continue; + + while (styleIdx + 1 < m_styles.size() && ii >= m_styles[styleIdx].end) { + ++styleIdx; + } + + auto const& ref = m_chars[charIdx++]; + auto& quad = m_batches[ref.batchIndex].quads[ref.charIndex]; + + auto quadColor = styleIdx < m_styles.size() && m_styles[styleIdx].color + ? this->getEffectiveColor(*m_styles[styleIdx].color) + : color; + + quad.bl.colors = quadColor; + quad.br.colors = quadColor; + quad.tl.colors = quadColor; + quad.tr.colors = quadColor; + } + + for (auto& batch : m_batches) { + batch.isDirty = true; + } + } + + auto emojiColor = this->getEmojiColor(); + + for (auto& batch : m_emojiBatches) { + for (auto& quad : batch.quads) { + quad.bl.colors = emojiColor; + quad.br.colors = emojiColor; + quad.tl.colors = emojiColor; + quad.tr.colors = emojiColor; + } + batch.isDirty = true; + } + + for (auto& node : m_embeddedNodes) { + if (auto* rgba = typeinfo_cast(node.data())) { + if (m_customNodesColors) rgba->setColor(m_color); + rgba->setOpacity(m_opacity); + } + } + } + + void applyLimitScale() { + if (m_fitLabelSize || m_limitSize.width <= 0.f && m_limitSize.height <= 0.f) { + return; + } + + auto size = m_label->CCNode::getContentSize(); + if (size.width <= 0.f || size.height <= 0.f) { + return; + } + + float scale = m_defaultScale; + if (m_limitSize.width > 0.f) { + scale = std::min(scale, m_limitSize.width / size.width); + } + if (m_limitSize.height > 0.f) { + scale = std::min(scale, m_limitSize.height / size.height); + } + if (m_minScale > 0.f) { + scale = std::max(scale, m_minScale); + } + + m_label->setScale(scale); + } + + void applyFitBox() { + if (m_limitSize.width <= 0.f && m_limitSize.height <= 0.f) { + this->breakLines(); + return; + } + + m_lineBreak = false; + this->breakLinesSimple(); + + float naturalWidth = 0.f; + for (auto const& line : m_lines) naturalWidth = std::max(naturalWidth, line.width); + + if (m_words.empty() || naturalWidth <= 0.f) { + m_label->setScale(m_defaultScale); + return; + } + + float lo = 1.f; + float hi = std::max(naturalWidth, lo); + + float bestWrap = hi; + float bestScale = -1.f; + float lineHeight = m_fonts.empty() ? 0.f : m_fonts.front()->getCommonHeightScaled() + m_extraLineSpacing; + + m_lineBreak = true; + + constexpr auto ITERATIONS = 14; + + for (int i = 0; i < ITERATIONS; ++i) { + float mid = (lo + hi) * 0.5f; + m_maxLineWidth = mid; + this->breakLines(); + + float w = 0.f; + for (auto const& line : m_lines) w = std::max(w, line.width); + float h = lineHeight * m_lines.size(); + + float rawWidthRatio = (m_limitSize.width > 0.f && w > 0.f) ? m_limitSize.width / w : std::numeric_limits::max(); + float rawHeightRatio = (m_limitSize.height > 0.f && h > 0.f) ? m_limitSize.height / h : std::numeric_limits::max(); + + float widthRatio = std::min(rawWidthRatio, m_defaultScale); + float heightRatio = std::min(rawHeightRatio, m_defaultScale); + + float scale = std::min(widthRatio, heightRatio); + + if (scale > bestScale) { + bestScale = scale; + bestWrap = mid; + } + + if (widthRatio > heightRatio) { + lo = mid; + } else { + hi = mid; + } + } + + m_maxLineWidth = bestWrap; + this->breakLines(); + + m_label->setScale(bestScale); + } + + void validate() { + if (m_limitChanged && m_fitLabelSize) { + m_layoutDirty = true; + } + + if (!m_textDirty && !m_layoutDirty && !m_quadsDirty) { + if (m_limitChanged) { + this->applyLimitScale(); + m_limitChanged = false; + } + return; + } + + if (m_textDirty) { + this->reshape(); + this->splitWords(); + + if (!m_fitLabelSize) { + this->breakLines(); + } + + m_textDirty = false; + m_layoutDirty = true; + } + + if (m_layoutDirty) { + if (m_fitLabelSize) { + this->applyFitBox(); + } + + this->emitQuads(); + m_layoutDirty = false; + m_quadsDirty = false; + } else if (m_quadsDirty) { + this->updateQuads(); + m_quadsDirty = false; + } + + m_limitChanged = false; + this->applyLimitScale(); + } +}; + +EmojiRegistry::EmojiEntry::EmojiEntry(ZStringView frameName) noexcept : frameName(frameName) {} + +CCSpriteFrame* EmojiRegistry::EmojiEntry::getFrame() const { + if (!cachedFrame) { + cachedFrame = CCSpriteFrameCache::get()->spriteFrameByName(frameName.c_str()); + } + return cachedFrame; +} + +void EmojiRegistry::EmojiEntry::invalidate() noexcept { + cachedFrame = nullptr; +} + +EmojiRegistry::EmojiRegistry() : m_impl(std::make_shared()) {} +EmojiRegistry::EmojiRegistry(std::shared_ptr impl) : m_impl(std::move(impl)) {} +EmojiRegistry::~EmojiRegistry() = default; + +EmojiRegistry::EmojiRegistry(EmojiRegistry const&) = default; +EmojiRegistry::EmojiRegistry(EmojiRegistry&&) noexcept = default; +EmojiRegistry& EmojiRegistry::operator=(EmojiRegistry const&) = default; +EmojiRegistry& EmojiRegistry::operator=(EmojiRegistry&&) noexcept = default; + +void EmojiRegistry::insert(std::u32string_view sequence, NodeFactory factory) { + m_impl->insert(sequence, std::move(factory)); +} + +void EmojiRegistry::insert(std::u32string_view sequence, NodeFactoryParams factory) { + m_impl->insert(sequence, std::move(factory)); +} + +void EmojiRegistry::insert(std::u32string_view sequence, ZStringView frameName) { + m_impl->insert(sequence, EmojiEntry(frameName)); +} + +void EmojiRegistry::insert(std::span> entries) { + m_impl->insert(entries); +} + +void EmojiRegistry::erase(std::u32string_view sequence) { + m_impl->erase(sequence); +} + +EmojiRegistry::Entry* EmojiRegistry::match(std::u32string_view str, size_t& index) { + return m_impl->match(str, index); +} + +Label::Label() : m_impl(new Impl(this)) {} + +Label::~Label() { + delete m_impl; +} + +Label* Label::create(BitmapFont* font) { + auto ret = new Label(); + if (ret->initWithFont(font)) { + ret->autorelease(); + return ret; + } + delete ret; + return nullptr; +} + +Label* Label::create(ZStringView font) { + auto ret = new Label(); + if (ret->initWithFontFile(font)) { + ret->autorelease(); + return ret; + } + delete ret; + return nullptr; +} + +Label* Label::create(std::string text, BitmapFont* font) { + auto ret = new Label(); + if (ret->initWithString(std::move(text), font)) { + ret->autorelease(); + return ret; + } + delete ret; + return nullptr; +} + +Label* Label::create(std::string text, ZStringView font) { + auto ret = new Label(); + if (ret->initWithString(std::move(text), font)) { + ret->autorelease(); + return ret; + } + delete ret; + return nullptr; +} + +Label* Label::createRich(std::string text, BitmapFont* font) { + auto ret = new Label(); + if (ret->initWithRichText(std::move(text), font)) { + ret->autorelease(); + return ret; + } + delete ret; + return nullptr; +} + +Label* Label::createRich(std::string text, ZStringView font) { + auto ret = new Label(); + if (ret->initWithRichText(std::move(text), font)) { + ret->autorelease(); + return ret; + } + delete ret; + return nullptr; +} + +void Label::draw() { + if (!m_pShaderProgram) return; + + this->validate(); + if (m_impl->m_chars.empty()) return; + + ccGLEnable(m_eGLServerState); + m_pShaderProgram->use(); + m_pShaderProgram->setUniformsForBuiltins(); + + ccGLBlendFunc(m_impl->m_blendFunc.src, m_impl->m_blendFunc.dst); + ccGLEnableVertexAttribs(kCCVertexAttribFlag_PosColorTex); + + glBindBuffer(GL_ARRAY_BUFFER, 0); + + size_t maxQuads = 0; + + for (auto& batch : m_impl->m_batches) { + batch.upload(); + maxQuads = std::max(maxQuads, batch.quads.size()); + } + + for (auto& batch : m_impl->m_emojiBatches) { + batch.upload(); + maxQuads = std::max(maxQuads, batch.quads.size()); + } + + if (maxQuads == 0) return; + + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, SharedIndexBuffer::get().getIBO(maxQuads)); + + for (auto const& batch : m_impl->m_batches) { batch.draw(); } + ccGLBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + for (auto const& batch : m_impl->m_emojiBatches) { batch.draw(); } + + glBindBuffer(GL_ARRAY_BUFFER, 0); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); +} + +void Label::validate() { + m_impl->validate(); +} + +void Label::setTextDirty(bool dirty) noexcept { + m_impl->m_textDirty = dirty; +} + +void Label::setLayoutDirty(bool dirty) noexcept { + m_impl->m_layoutDirty = dirty; +} + +void Label::setQuadsDirty(bool dirty) noexcept { + m_impl->m_quadsDirty = dirty; +} + +bool Label::isTextDirty() noexcept { + return m_impl->m_textDirty; +} + +bool Label::isLayoutDirty() noexcept { + return m_impl->m_layoutDirty; +} + +bool Label::isQuadsDirty() noexcept { + return m_impl->m_quadsDirty; +} + +bool Label::registerFont(BitmapFont* font) { + if (!font) return false; + + auto texture = CCTextureCache::get()->addImage(font->getAtlasName().c_str(), false); + if (!texture) return false; + + m_impl->m_fonts.emplace_back(font); + m_impl->m_batches.emplace_back(texture); + + if (m_impl->m_spaceWidth == 0.f) { + auto const& chars = font->getCharDefs(); + auto it = chars.find(U' '); + if (it != chars.end()) { + m_impl->m_spaceWidth = it->second.xAdvanceScaled; + } + } + + m_impl->m_textDirty = true; + + return true; +} + +bool Label::registerFont(ZStringView font) { + return this->registerFont(BitmapFont::load(font)); +} + +void Label::setAlignment(Alignment alignment) noexcept { + if (m_impl->m_alignment == alignment) { + return; + } + + m_impl->m_alignment = alignment; + m_impl->m_layoutDirty = true; +} + +Label::Alignment Label::getAlignment() noexcept { + return m_impl->m_alignment; +} + +void Label::setMaxWidth(float maxWidth) noexcept { + if (m_impl->m_maxLineWidth == maxWidth) { + return; + } + + m_impl->m_maxLineWidth = maxWidth; + m_impl->m_lineBreak = maxWidth > 0.f; + m_impl->m_layoutDirty = true; +} + +float Label::getMaxWidth() noexcept { + return m_impl->m_maxLineWidth; +} + +void Label::setBreakWords(bool breakWords) noexcept { + if (m_impl->m_wordBreak == breakWords) { + return; + } + + m_impl->m_wordBreak = breakWords; + m_impl->m_layoutDirty = true; +} + +bool Label::isBreakWords() noexcept { + return m_impl->m_wordBreak; +} + +void Label::setLimitLabelWidth(float width, float maxScale, float minScale) noexcept { + m_impl->m_limitSize.width = width; + m_impl->m_defaultScale = maxScale; + m_impl->m_minScale = minScale; + m_impl->m_limitChanged = true; + m_impl->m_fitLabelSize = false; +} + +void Label::setLimitLabelHeight(float height, float maxScale, float minScale) noexcept { + m_impl->m_limitSize.height = height; + m_impl->m_defaultScale = maxScale; + m_impl->m_minScale = minScale; + m_impl->m_limitChanged = true; + m_impl->m_fitLabelSize = false; +} + +void Label::setLimitLabelSize(CCSize size, float maxScale, float minScale) noexcept { + m_impl->m_limitSize = size; + m_impl->m_defaultScale = maxScale; + m_impl->m_minScale = minScale; + m_impl->m_limitChanged = true; + m_impl->m_fitLabelSize = false; +} + +void Label::removeLabelSizeLimit() noexcept { + m_impl->m_limitSize = CCSize{0.f, 0.f}; + m_impl->m_limitChanged = true; +} + +void Label::setFitBox(CCSize size, float maxScale) noexcept { + m_impl->m_limitSize = size; + m_impl->m_defaultScale = maxScale; + m_impl->m_fitLabelSize = true; + m_impl->m_limitChanged = true; +} + +void Label::disableFitBox() noexcept { + m_impl->m_fitLabelSize = false; + m_impl->m_limitChanged = true; +} + +void Label::setExtraKerning(float kerning) noexcept { + m_impl->m_extraKerning = kerning; + m_impl->m_textDirty = true; +} + +float Label::getExtraKerning() noexcept { + return m_impl->m_extraKerning; +} + +void Label::setLineSpacing(float lineSpacing) noexcept { + m_impl->m_extraLineSpacing = lineSpacing; + m_impl->m_textDirty = true; +} + +float Label::getLineSpacing() noexcept { + return m_impl->m_extraLineSpacing; +} + +void Label::setCharColor(uint32_t index, ccColor4B color) { + auto& chars = m_impl->m_chars; + if (index >= chars.size()) return; + + auto ref = chars[index]; + + auto& quad = m_impl->m_batches[ref.batchIndex].quads[ref.charIndex]; + quad.bl.colors = color; + quad.br.colors = color; + quad.tl.colors = color; + quad.tr.colors = color; + + m_impl->m_batches[ref.batchIndex].isDirty = true; +} + +size_t Label::getCharCount() noexcept { + this->validate(); + return m_impl->m_chars.size(); +} + +void Label::setEmojiRegistry(EmojiRegistry const& registry) { + m_impl->m_emojiRegistry = registry.m_impl; +} + +EmojiRegistry Label::getEmojiRegistry() { + return EmojiRegistry(m_impl->m_emojiRegistry); +} + +void Label::setColor(ccColor3B const& color) { + if (m_impl->m_color == color) { + return; + } + + m_impl->m_color = color; + m_impl->m_quadsDirty = true; +} + +ccColor3B const& Label::getColor() { + return m_impl->m_color; +} + +ccColor3B const& Label::getDisplayedColor() { + return m_impl->m_color; +} + +void Label::setOpacity(GLubyte opacity) { + if (m_impl->m_opacity == opacity) { + return; + } + + m_impl->m_opacity = opacity; + m_impl->m_quadsDirty = true; +} + +GLubyte Label::getOpacity() { + return m_impl->m_opacity; +} + +GLubyte Label::getDisplayedOpacity() { + return m_impl->m_opacity; +} + +void Label::setUseColoredEmojis(bool value) noexcept { + if (m_impl->m_emojisUseColors == value) { + return; + } + + m_impl->m_emojisUseColors = value; + m_impl->m_quadsDirty = true; +} + +bool Label::getUseColoredEmojis() noexcept { + return m_impl->m_emojisUseColors; +} + +void Label::setUseColoredNodes(bool value) noexcept { + if (m_impl->m_customNodesColors == value) { + return; + } + + m_impl->m_customNodesColors = value; + m_impl->m_quadsDirty = true; +} + +bool Label::getUseColoredNodes() noexcept { + return m_impl->m_customNodesColors; +} + +std::vector> const& Label::getEmbeddedNodes() const noexcept { + return m_impl->m_embeddedNodes; +} + +void Label::setOpacityModifyRGB(bool bValue) { + if (m_impl->m_isOpacityModifyRGB == bValue) { + return; + } + + m_impl->m_isOpacityModifyRGB = bValue; + m_impl->m_quadsDirty = true; +} + +bool Label::isOpacityModifyRGB() { + return m_impl->m_isOpacityModifyRGB; +} + +void Label::setCascadeColorEnabled(bool cascadeColorEnabled) {} +bool Label::isCascadeColorEnabled() { return false; } +void Label::updateDisplayedColor(ccColor3B const& color) {} +void Label::updateDisplayedOpacity(GLubyte opacity) {} +void Label::setCascadeOpacityEnabled(bool cascadeOpacityEnabled) {} +bool Label::isCascadeOpacityEnabled() { return false; } + +void Label::setText(std::string text) noexcept { + if (m_impl->m_text == text) { + return; + } + + m_impl->m_text = std::move(text); + m_impl->m_textDirty = true; + m_impl->m_useRichText = false; +} + +ZStringView Label::getText() noexcept { + return m_impl->m_text; +} + +void Label::setRichText(std::string text) { + if (m_impl->m_text == text) { + return; + } + + m_impl->m_text = std::move(text); + m_impl->m_textDirty = true; + m_impl->m_useRichText = true; +} + +void Label::setString(char const* label) { + if (m_impl->m_text == label) { + return; + } + + m_impl->m_text = label; + m_impl->m_textDirty = true; + m_impl->m_useRichText = false; +} + +char const* Label::getString() { + return m_impl->m_text.c_str(); +} + +void Label::setBlendFunc(ccBlendFunc blendFunc) { + m_impl->m_blendFunc = blendFunc; +} + +ccBlendFunc Label::getBlendFunc() { + return m_impl->m_blendFunc; +} + +CCSize const& Label::getContentSize() const { + const_cast(this)->validate(); + return CCNode::getContentSize(); +} + +CCSize Label::getScaledContentSize() { + this->validate(); + return CCNode::getScaledContentSize(); +} + +bool Label::initWithFont(BitmapFont* font) { + if (!CCNode::init()) { + return false; + } + + if (!this->registerFont(font)) { + return false; + } + + bool hasPMA = m_impl->m_batches[0].texture->hasPremultipliedAlpha(); + m_impl->m_isOpacityModifyRGB = hasPMA; + + if (!hasPMA) { + this->setBlendFunc({ GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA }); + } + + this->setShaderProgram(CCShaderCache::sharedShaderCache()->programForKey(kCCShader_PositionTextureColor)); + this->setAnchorPoint({0.5f, 0.5f}); + + return true; +} + +bool Label::initWithFontFile(ZStringView fntFile) { + return this->initWithFont(BitmapFont::load(fntFile)); +} + +bool Label::initWithString(std::string text, BitmapFont* font) { + if (!this->initWithFont(font)) { + return false; + } + + m_impl->m_text = std::move(text); + + return true; +} + +bool Label::initWithString(std::string text, ZStringView fntFile) { + return this->initWithString(std::move(text), BitmapFont::load(fntFile)); +} + +bool Label::initWithRichText(std::string text, BitmapFont* font) { + if (!this->initWithString(std::move(text), font)) { + return false; + } + + m_impl->m_useRichText = true; + + return true; +} + +bool Label::initWithRichText(std::string text, ZStringView fntFile) { + return this->initWithRichText(std::move(text), BitmapFont::load(fntFile)); +} \ No newline at end of file