From e0bf0a23e5c8bd74ba40671e535da697272458eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sun, 8 Feb 2026 13:35:16 -0300 Subject: [PATCH] Added a basic RichText class (and added tests and an example). Fixed LineWrap when using initial X offset and the word does not fit in the current line but it fits in the next. Added basic agent rules. --- .agent/rules/build-project.md | 11 + .agent/rules/project-introduction.md | 13 + .agent/rules/unit-tests.md | 18 ++ .ecode/project_build.json | 6 + .../assets/fontrendering/eepp-rich-text.webp | Bin 0 -> 12922 bytes include/eepp/graphics.hpp | 4 + include/eepp/graphics/drawable.hpp | 1 + include/eepp/graphics/richtext.hpp | 119 +++++++++ premake4.lua | 6 + premake5.lua | 6 + src/eepp/graphics/linewrap.cpp | 37 ++- src/eepp/graphics/richtext.cpp | 227 +++++++++++++++++ src/eepp/graphics/text.cpp | 45 +++- src/eepp/graphics/textlayout.cpp | 14 ++ src/examples/richtext/richtext.cpp | 115 +++++++++ src/tests/unit_tests/compareimages.hpp | 60 +++++ src/tests/unit_tests/fontrendering.cpp | 50 +--- src/tests/unit_tests/richtext.cpp | 235 ++++++++++++++++++ 18 files changed, 909 insertions(+), 58 deletions(-) create mode 100644 .agent/rules/build-project.md create mode 100644 .agent/rules/project-introduction.md create mode 100644 .agent/rules/unit-tests.md create mode 100644 bin/unit_tests/assets/fontrendering/eepp-rich-text.webp create mode 100644 include/eepp/graphics/richtext.hpp create mode 100644 src/eepp/graphics/richtext.cpp create mode 100644 src/examples/richtext/richtext.cpp create mode 100644 src/tests/unit_tests/compareimages.hpp create mode 100644 src/tests/unit_tests/richtext.cpp diff --git a/.agent/rules/build-project.md b/.agent/rules/build-project.md new file mode 100644 index 000000000..bf530b86d --- /dev/null +++ b/.agent/rules/build-project.md @@ -0,0 +1,11 @@ +--- +trigger: always_on +--- + +To build the project in debug mode you must run from the root project directory: + +`make -C make/linux -j$(nproc)` + +If any file has been added you should also run (previous to the make command): + +`premake4 --disable-static-build --with-mold-linker --with-debug-symbols --address-sanitizer gmake` \ No newline at end of file diff --git a/.agent/rules/project-introduction.md b/.agent/rules/project-introduction.md new file mode 100644 index 000000000..75f6f9cec --- /dev/null +++ b/.agent/rules/project-introduction.md @@ -0,0 +1,13 @@ +--- +trigger: always_on +--- + +[eepp](https://github.com/SpartanJ/eepp/) is an open source cross-platform game and application development framework heavily focused on the development of rich graphical user interfaces. + +Inside this repository also lives [ecode](https://github.com/SpartanJ/ecode/). ecode is a lightweight multi-platform code editor designed for modern hardware with a focus on responsiveness and performance. It has been developed with the hardware-accelerated eepp GUI, which provides the core technology for the editor. The project comes as the first serious project using the eepp GUI, and it's currently being developed to improve the eepp GUI library as part of one of its main objectives. + +Very basic eepp documentation can be found at `docs/articles`. Many class headers have Doxygen documentation, rely on that. eepp headers are at `include/eepp/`. + +A good amount of examples on how to use the library can be found in `src/examples`. + +The `README.md` at the root directory explains in more detail about the project. diff --git a/.agent/rules/unit-tests.md b/.agent/rules/unit-tests.md new file mode 100644 index 000000000..a2c14a72a --- /dev/null +++ b/.agent/rules/unit-tests.md @@ -0,0 +1,18 @@ +--- +trigger: always_on +--- + +Project provide a good range of unit-tests that they must pass to guarantee that changes made do not break functionality. +To run the tests you must execute the binary: +`bin/unit_tests/eepp-unit_tests-debug` + +This path is from the root directory, you can run it from anywhere, current working directory is managed by the binary. + +If you need to run an specific test you can use the filter parameter, it supports glob patterns, for example: + +`bin/unit_tests/eepp-unit_tests-debug --filter="FontRendering.*Offset*"` + +Will run all tests with "Offset" in its name. +It's expected that for *any* requested new functionality you must add new tests and also tests with previously existing ones. Initially always test with the most relevant to the change that's has been made. + +Tests can be found at: `src/tests/unit_tests`. Being `src/tests/unit_tests/fontrendering.cpp` the most complete set of tests related to text rendering. diff --git a/.ecode/project_build.json b/.ecode/project_build.json index 7bed40a13..f05254b4e 100644 --- a/.ecode/project_build.json +++ b/.ecode/project_build.json @@ -333,6 +333,12 @@ "command": "${project_root}/bin/eepp-MapEditor-debug", "name": "eepp-MapEditor-debug", "working_dir": "${project_root}/bin" + }, + { + "args": "", + "command": "${project_root}/bin/eepp-richtext-debug", + "name": "eepp-richtext-debug", + "working_dir": "${project_root}/bin" } ], "var": { diff --git a/bin/unit_tests/assets/fontrendering/eepp-rich-text.webp b/bin/unit_tests/assets/fontrendering/eepp-rich-text.webp new file mode 100644 index 0000000000000000000000000000000000000000..8d60a54826978ab6a1cb8ab07a1c97df83f0be37 GIT binary patch literal 12922 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 literal 0 HcmV?d00001 diff --git a/include/eepp/graphics.hpp b/include/eepp/graphics.hpp index af47c8a71..38a2d0ea9 100644 --- a/include/eepp/graphics.hpp +++ b/include/eepp/graphics.hpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -33,13 +34,16 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include +#include #include #include #include diff --git a/include/eepp/graphics/drawable.hpp b/include/eepp/graphics/drawable.hpp index 42b825888..6ae137bc8 100644 --- a/include/eepp/graphics/drawable.hpp +++ b/include/eepp/graphics/drawable.hpp @@ -31,6 +31,7 @@ class EE_API Drawable { UINODEDRAWABLE_LAYERDRAWABLE, UIBORDERDRAWABLE, UIBACKGROUNDDRAWABLE, + RICHTEXT, CUSTOM }; diff --git a/include/eepp/graphics/richtext.hpp b/include/eepp/graphics/richtext.hpp new file mode 100644 index 000000000..8cfa66f6a --- /dev/null +++ b/include/eepp/graphics/richtext.hpp @@ -0,0 +1,119 @@ +#ifndef EE_GRAPHICS_RICHTEXT_HPP +#define EE_GRAPHICS_RICHTEXT_HPP + +#include +#include +#include +#include + +namespace EE { namespace Graphics { + +/** + * @brief A drawable class that renders rich text with multiple styles and spans. + * + * RichText allows rendering text with different fonts, sizes, colors, and styles mixed together. + * It supports word wrapping and alignment. + */ +class EE_API RichText : public Drawable { + public: + /** @return A new instance of RichText. */ + static RichText* New(); + + /** @brief Default constructor. */ + RichText(); + + /** @brief Destructor. */ + ~RichText(); + + /** + * @brief Adds a text span with a specific style configuration. + * @param text The text content. + * @param style The font style configuration to apply. + */ + void addSpan( const String& text, const FontStyleConfig& style ); + + /** + * @brief Adds a text span with individual style parameters. + * @param text The text content. + * @param font The font to use (optional, uses default if null). + * @param characterSize The character size (optional, uses default if 0). + * @param color The text color (optional, uses default if White). + * @param style The text style (optional, uses default if Regular). + */ + void addSpan( const String& text, Font* font = nullptr, Uint32 characterSize = 0, + Color color = Color::White, Uint32 style = Text::Regular ); + + /** @brief Clears all text spans. */ + void clear(); + + /** @brief Sets the default font style configuration used for new spans if not specified. */ + void setFontStyleConfig( const FontStyleConfig& styleConfig ); + + /** @return The default font style configuration. */ + FontStyleConfig& getFontStyleConfig() { return mDefaultStyle; } + + /** @brief Sets the text alignment (Left, Center, Right). */ + void setAlign( Uint32 align ); + + /** @brief Sets the maximum width for wrapping. If 0, wrapping is disabled. */ + void setMaxWidth( Float width ); + + /** @return The maximum width for wrapping. */ + Float getMaxWidth() const { return mMaxWidth; } + + /** @return The list of text spans. */ + std::vector>& getSpans() { return mSpans; } + + virtual void draw( const Float& X, const Float& Y, const Vector2f& scale = Vector2f::One, + const Float& rotation = 0, BlendMode effect = BlendMode::Alpha(), + const OriginPoint& rotationCenter = OriginPoint::OriginCenter, + const OriginPoint& scaleCenter = OriginPoint::OriginCenter ); + + virtual void draw(); + + virtual void draw( const Vector2f& position ); + + virtual void draw( const Vector2f& position, const Sizef& size ); + + virtual bool isStateful() { return false; } + + virtual Sizef getSize(); + + virtual Sizef getPixelsSize(); + + /** @brief Invalidates the layout, forcing a recalculation on the next update. */ + void invalidate(); + + /** @brief Structure representing a rendered span within a line. */ + struct RenderSpan { + std::shared_ptr text; + Vector2f position; // Local position relative to RichText origin + }; + + /** @brief Structure representing a rendered paragraph (line). */ + struct RenderParagraph { + std::vector spans; + Float y{ 0 }; + Float height{ 0 }; + Float maxAscent{ 0 }; + Float width{ 0 }; + }; + + /** @return The list of rendered lines. */ + const std::vector& getLines() const { return mLines; } + + protected: + std::vector> mSpans; + std::vector mLines; + FontStyleConfig mDefaultStyle; + Uint32 mAlign{ TEXT_ALIGN_LEFT }; + Float mMaxWidth{ 0.f }; + Sizef mSize; + bool mNeedsLayoutUpdate{ true }; + + void updateLayout(); +}; + +}} // namespace EE::Graphics + +#endif diff --git a/premake4.lua b/premake4.lua index 50843195f..760cb6929 100644 --- a/premake4.lua +++ b/premake4.lua @@ -1559,6 +1559,12 @@ solution "eepp" files { "src/examples/ui_hello_world/*.cpp" } build_link_configuration( "eepp-ui-hello-world", true ) + project "eepp-richtext" + set_kind() + language "C++" + files { "src/examples/richtext/*.cpp" } + build_link_configuration( "eepp-richtext", true ) + project "eepp-7guis-counter" set_kind() language "C++" diff --git a/premake5.lua b/premake5.lua index 03d4578f6..56c708686 100644 --- a/premake5.lua +++ b/premake5.lua @@ -1435,6 +1435,12 @@ workspace "eepp" files { "src/examples/ui_hello_world/*.cpp" } build_link_configuration( "eepp-ui-hello-world", true ) + project "eepp-richtext" + set_kind() + language "C++" + files { "src/examples/richtext/*.cpp" } + build_link_configuration( "eepp-richtext", true ) + project "eepp-7guis-counter" set_kind() language "C++" diff --git a/src/eepp/graphics/linewrap.cpp b/src/eepp/graphics/linewrap.cpp index a3175f95f..1520aa810 100644 --- a/src/eepp/graphics/linewrap.cpp +++ b/src/eepp/graphics/linewrap.cpp @@ -129,6 +129,9 @@ LineWrap::computeLineBreaksInternal( const String::View& string, Font* font, Uin : whiteSpaceWidth; if ( keepIndentation ) { + // paddingStart is the offset added to the wrapped lines based on the initial intendation of + // the line. This is to keep the indented wrapped lines aligned with the line indentation. + // Useful for code editors. info.paddingStart = LineWrap::computeOffsets( string, font, characterSize, fontStyle, outlineThickness, tabWidth, eemax( maxWidth - hspace, hspace ), tabStops ); @@ -142,7 +145,7 @@ LineWrap::computeLineBreaksInternal( const String::View& string, Font* font, Uin static_cast( font )->isIdentifiedAsMonospace() && Text::canSkipShaping( textDrawHints ) ) ); - size_t lastSpace = 0; + size_t lastSpace = std::string::npos; Uint32 prevChar = 0; size_t idx = 0; bool hasWrap = maxWidth > 0 && mode != LineWrapMode::NoWrap; @@ -179,7 +182,7 @@ LineWrap::computeLineBreaksInternal( const String::View& string, Font* font, Uin xoffset += w; if ( hasWrap && xoffset > maxWidth ) { - if ( mode == LineWrapMode::Word && lastSpace ) { + if ( mode == LineWrapMode::Word && lastSpace != std::string::npos ) { if constexpr ( std::is_same_v ) { info.wrapsWidth.push_back( std::ceil( lastWordWrapWidth ) ); } @@ -187,14 +190,32 @@ LineWrap::computeLineBreaksInternal( const String::View& string, Font* font, Uin info.wraps.push_back( lastSpace + 1 ); xoffset = info.paddingStart + ( xoffset - lastWidth ); } else { - if constexpr ( std::is_same_v ) { - info.wrapsWidth.push_back( std::ceil( xoffset - w ) ); - } + // If we are about to split a word, check if we can move it to the next line + if ( mode == LineWrapMode::Word && info.wraps.size() == 1 && + xoffset - initialXOffset <= maxWidth ) { + // We can move it to the next line + if constexpr ( std::is_same_v ) { + info.wrapsWidth.push_back( 0 ); // Empty line width + } - info.wraps.push_back( idx ); - xoffset = info.paddingStart; + // Wrap at the beginning of the line + size_t splitIdx = info.wraps.back(); + info.wraps.push_back( splitIdx ); + + // xoffset on next line = paddingStart + (width of content from splitIdx to + // idx). width of content from splitIdx to idx = (xoffset - initialXOffset) + Float pendingWidth = xoffset - initialXOffset; + xoffset = info.paddingStart + pendingWidth; + } else { + if constexpr ( std::is_same_v ) { + info.wrapsWidth.push_back( std::ceil( xoffset - w ) ); + } + + info.wraps.push_back( idx ); + xoffset = info.paddingStart; + } } - lastSpace = 0; + lastSpace = std::string::npos; lastWordWrapWidth = 0.f; } else if ( isWrapChar( curChar ) ) { lastSpace = idx; diff --git a/src/eepp/graphics/richtext.cpp b/src/eepp/graphics/richtext.cpp new file mode 100644 index 000000000..891e1b873 --- /dev/null +++ b/src/eepp/graphics/richtext.cpp @@ -0,0 +1,227 @@ +#include +#include +#include +#include +#include + +namespace EE { namespace Graphics { + +RichText* RichText::New() { + return eeNew( RichText, () ); +} + +RichText::RichText() : Drawable( Drawable::RICHTEXT ) {} + +RichText::~RichText() {} + +void RichText::draw() { + draw( mPosition.x, mPosition.y ); +} + +void RichText::draw( const Vector2f& position ) { + draw( position.x, position.y ); +} + +void RichText::draw( const Vector2f& position, const Sizef& size ) { + Sizef s = getSize(); + if ( s != Sizef::Zero ) { + draw( position.x, position.y, + { size.getWidth() / s.getWidth(), size.getHeight() / s.getHeight() } ); + } +} + +void RichText::draw( const Float& X, const Float& Y, const Vector2f& scale, const Float& rotation, + BlendMode effect, const OriginPoint& rotationCenter, + const OriginPoint& scaleCenter ) { + updateLayout(); + + for ( auto& line : mLines ) { + for ( auto& span : line.spans ) { + // span.position is local to the line (x) + baseline offset (y) line.Y is the line + // vertical offset + + Vector2f pos = span.position; + + if ( rotation == 0 && scale == Vector2f::One ) { + span.text->draw( std::trunc( X + pos.x ), std::trunc( Y + line.y + pos.y ), + Vector2f::One, 0, effect ); + } else { + span.text->draw( std::trunc( X + pos.x * scale.x ), + std::trunc( Y + ( line.y + pos.y ) * scale.y ), scale, rotation, + effect, rotationCenter, scaleCenter ); + } + } + } +} + +Sizef RichText::getPixelsSize() { + return getSize(); +} + +void RichText::addSpan( const String& text, const FontStyleConfig& style ) { + if ( text.empty() ) + return; + + auto span = std::make_shared(); + span->setString( text ); + span->setStyleConfig( style ); + mSpans.push_back( span ); + mNeedsLayoutUpdate = true; +} + +void RichText::addSpan( const String& text, Font* font, Uint32 characterSize, Color color, + Uint32 style ) { + FontStyleConfig config; + config.Font = font ? font : mDefaultStyle.Font; + config.CharacterSize = characterSize != 0 ? characterSize : mDefaultStyle.CharacterSize; + config.FontColor = color; + config.Style = style; + config.ShadowColor = mDefaultStyle.ShadowColor; + config.ShadowOffset = mDefaultStyle.ShadowOffset; + config.OutlineThickness = mDefaultStyle.OutlineThickness; + config.OutlineColor = mDefaultStyle.OutlineColor; + + addSpan( text, config ); +} + +void RichText::clear() { + mSpans.clear(); + mLines.clear(); + mNeedsLayoutUpdate = true; +} + +void RichText::setFontStyleConfig( const FontStyleConfig& styleConfig ) { + mDefaultStyle = styleConfig; + mNeedsLayoutUpdate = true; +} + +void RichText::setAlign( Uint32 align ) { + if ( mAlign != align ) { + mAlign = align; + mNeedsLayoutUpdate = true; + } +} + +void RichText::setMaxWidth( Float width ) { + if ( mMaxWidth != width ) { + mMaxWidth = width; + mNeedsLayoutUpdate = true; + } +} + +void RichText::invalidate() { + mNeedsLayoutUpdate = true; + for ( auto& span : mSpans ) { + span->invalidate(); + } +} + +void RichText::updateLayout() { + if ( !mNeedsLayoutUpdate ) + return; + + mLines.clear(); + mLines.push_back( RenderParagraph() ); + + Float curX = 0; + Float maxWidth = 0; + + for ( auto& span : mSpans ) { + if ( span->getString().empty() ) + continue; + + auto& fontStyle = span->getFontStyleConfig(); + if ( !fontStyle.Font ) + continue; + + Uint32 textHints = span->getTextHints(); + + LineWrapInfoEx wrapInfo = LineWrap::computeLineBreaksEx( + span->getString(), fontStyle, mMaxWidth > 0 ? mMaxWidth : 1e9f, + mMaxWidth > 0 ? LineWrapMode::Word : LineWrapMode::NoWrap, false, 4, 0.f, textHints, + false, curX ); + + // Make sure we have the end of the string as a "wrap" point for the loop + if ( wrapInfo.wraps.empty() || wrapInfo.wraps.back() != (Float)span->getString().size() ) + wrapInfo.wraps.push_back( span->getString().size() ); + + for ( size_t i = 0; i < wrapInfo.wraps.size() - 1; ++i ) { + size_t startIdx = wrapInfo.wraps[i]; + size_t endIdx = wrapInfo.wraps[i + 1]; + bool isNewline = ( endIdx - startIdx == 1 && span->getString()[startIdx] == '\n' ); + + if ( !isNewline ) { + std::shared_ptr renderSpanText = std::make_shared(); + renderSpanText->setString( + span->getString().substr( startIdx, endIdx - startIdx ) ); + renderSpanText->setStyleConfig( fontStyle ); + + RenderSpan renderSpan; + renderSpan.text = renderSpanText; + renderSpan.position = { curX, 0 }; // Y adjusted later + + RenderParagraph& currentLine = mLines.back(); + currentLine.spans.push_back( renderSpan ); + + Float ascent = fontStyle.Font->getAscent( fontStyle.CharacterSize ); + Float height = fontStyle.Font->getLineSpacing( fontStyle.CharacterSize ); + + currentLine.maxAscent = std::max( currentLine.maxAscent, ascent ); + currentLine.height = std::max( currentLine.height, height ); + + Float spanWidth = renderSpan.text->getTextWidth(); + curX += spanWidth; + currentLine.width += spanWidth; + } + + // If it's a newline, or if it's not the very last segment (which means it wrapped), + // start a new line. Exception: If the last segment was just a newline, we already + // handled it. + if ( i < wrapInfo.wraps.size() - 2 || isNewline ) { + maxWidth = std::max( maxWidth, curX ); + mLines.push_back( RenderParagraph() ); + curX = 0; + } + } + } + + maxWidth = std::max( maxWidth, curX ); + + if ( !mLines.empty() && mLines.back().spans.empty() && mLines.size() > 1 ) { + mLines.pop_back(); + } + + Float curY = 0; + for ( auto& line : mLines ) { + line.y = curY; + + Float xOffset = 0; + if ( mMaxWidth > 0 && mAlign != 0 ) { + Uint32 hAlign = Font::getHorizontalAlign( mAlign ); + if ( hAlign == TEXT_ALIGN_CENTER ) { + xOffset = ( mMaxWidth - line.width ) * 0.5f; + } else if ( hAlign == TEXT_ALIGN_RIGHT ) { + xOffset = mMaxWidth - line.width; + } + } + + for ( auto& span : line.spans ) { + Float ascent = span.text->getFont()->getAscent( span.text->getCharacterSize() ); + Float offsetY = line.maxAscent - ascent; + span.position.x += xOffset; + span.position.y = offsetY; + } + + curY += line.height; + } + + mSize = Sizef( maxWidth, curY ); + mNeedsLayoutUpdate = false; +} + +Sizef RichText::getSize() { + updateLayout(); + return mSize; +} + +}} // namespace EE::Graphics diff --git a/src/eepp/graphics/text.cpp b/src/eepp/graphics/text.cpp index 0f1ccab83..466dd3e47 100644 --- a/src/eepp/graphics/text.cpp +++ b/src/eepp/graphics/text.cpp @@ -821,8 +821,8 @@ Vector2f Text::findCharacterPos( std::size_t index ) const { Vector2f pos = Text::findCharacterPos( index - startIdx, mFontStyleConfig.Font, mFontStyleConfig.CharacterSize, strWrapper, - mFontStyleConfig.Style, mTabWidth, mFontStyleConfig.OutlineThickness, {}, true, - mTextHints ); + mFontStyleConfig.Style, mTabWidth, mFontStyleConfig.OutlineThickness, {}, true, mTextHints, + TextDirection::Unspecified, lineIndex == 0 ? mInitialOffset : Vector2f::Zero ); return Vector2f( pos.x + centerDiffX, y ); } @@ -1167,6 +1167,47 @@ Vector2f Text::findCharacterPos( std::size_t index, Font* font, const Uint32& fo } #endif + // If soft-wrap is enabled and we are not using the shaper (or it's skipped), we need to compute + // line breaks to correctly calculate the position. + if ( lineWrapMode != LineWrapMode::NoWrap ) { + if ( index == 0 ) + return position; + + LineWrapInfo info = LineWrap::computeLineBreaks( + string, font, fontSize, maxWrapWidth, lineWrapMode, style, outlineThickness, false, + tabWidth, 0.f, textDrawHints, false, initialOffset.x ); + + size_t lineIndex = 0; + size_t lineStartIdx = 0; + + for ( size_t i = 1; i < info.wraps.size(); ++i ) { + if ( index < static_cast( info.wraps[i] ) ) { + break; + } + lineIndex = i; + lineStartIdx = info.wraps[i]; + } + + if ( lineIndex > 0 ) { + position.x = info.paddingStart; + position.x += info.paddingStart; + position.y += vspace * lineIndex; + } + + Float segmentWidth = 0; + if ( index > lineStartIdx ) { + std::optional currentTabOffset = + lineIndex == 0 ? initialOffset.x : info.paddingStart; + String::View segment = string.view().substr( lineStartIdx, index - lineStartIdx ); + segmentWidth = + Text::getTextWidth( font, fontSize, segment, style, tabWidth, outlineThickness, + textDrawHints, direction, currentTabOffset ); + } + + position.x += segmentWidth; + return position; + } + Uint32 prevChar = 0; bool isMonospace = font->isMonospace(); for ( std::size_t i = 0; i < index; ++i ) { diff --git a/src/eepp/graphics/textlayout.cpp b/src/eepp/graphics/textlayout.cpp index aa9185a20..aa779ddb2 100644 --- a/src/eepp/graphics/textlayout.cpp +++ b/src/eepp/graphics/textlayout.cpp @@ -578,6 +578,17 @@ void TextLayout::wrapLayout( const String::View& string, TextLayout& result, bool performWordWrap = ( lineWrapMode == LineWrapMode::Word && lastSpace != std::string::npos ); + bool forceNextLine = false; + + if ( !performWordWrap && lineWrapMode == LineWrapMode::Word && + sp.wrapInfo.wraps.size() == 1 ) { + // Check if fits in next line + Float currentWordWidth = + sg.position.x + sg.advance.x - sp.shapedGlyphs[0].position.x; + if ( currentWordWidth <= wrapWidth - sp.wrapInfo.paddingStart ) { + forceNextLine = true; + } + } if ( performWordWrap ) { ShapedGlyph& prevBreakGlyph = sp.shapedGlyphs[lastSpace]; @@ -589,6 +600,9 @@ void TextLayout::wrapLayout( const String::View& string, TextLayout& result, if ( performWordWrap ) { breakIndex = lastSpace + 1; breakStringIdx = lastSpaceStringIdx + 1; + } else if ( forceNextLine ) { + breakIndex = 0; + breakStringIdx = sp.shapedGlyphs[0].stringIndex; } if ( breakIndex > idx ) diff --git a/src/examples/richtext/richtext.cpp b/src/examples/richtext/richtext.cpp new file mode 100644 index 000000000..cec36d1d0 --- /dev/null +++ b/src/examples/richtext/richtext.cpp @@ -0,0 +1,115 @@ +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::Graphics; +using namespace EE::Window; + +void runRichTextTest() { + auto win = Engine::instance()->createWindow( WindowSettings( 1024, 768, "RichText Example" ) ); + + if ( !win->isOpen() ) + return; + + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + FontTrueType* font = + FontTrueType::New( "NotoSans-Regular", "assets/fonts/NotoSans-Regular.ttf" ); + + if ( !font || !font->loaded() ) + return; + + FontFamily::loadFromRegular( font ); + + RichText richText; + richText.getFontStyleConfig().Font = font; + richText.getFontStyleConfig().CharacterSize = 24; + richText.setAlign( TEXT_ALIGN_LEFT ); + + // Add spans using the helper method + richText.addSpan( "Hello " ); + richText.addSpan( "bold world", nullptr, 24, Color::Red, + Text::Bold ); // Use nullptr to use the font associated with the style + // (if loaded via FontFamily) + richText.addSpan( "! This is a " ); + richText.addSpan( "colored", nullptr, 0, + Color::Green ); // Inherit font and size, change color + richText.addSpan( " processing example. " ); + richText.addSpan( "It should support " ); + richText.addSpan( "soft wrapping", nullptr, 0, Color::Blue, Text::Italic ); + richText.addSpan( " across multiple lines if the text is long enough. " ); + richText.addSpan( "And also " ); + richText.addSpan( "different font sizes", nullptr, 32, + Color( 255, 0, 255, 255 ) ); // Magenta manually + richText.addSpan( " in the same block." ); + + richText.setMaxWidth( std::ceil( win->getWidth() * 0.4 ) ); + richText.setPosition( { 25.f, 50.f } ); + + RichText richText2 = richText; + richText2.setPosition( + richText2.getPosition() + Vector2f{ 25.f, 0.f } + + Vector2f{ static_cast( std::ceil( win->getWidth() * 0.4 ) ), 0 } ); + richText2.setMaxWidth( std::ceil( win->getWidth() * 0.1 ) ); + + RichText richText3 = richText2; + richText3.setPosition( + Vector2f{ 25.f, 50.f } + + Vector2f{ static_cast( std::ceil( win->getWidth() * 0.6 ) ), 0 } ); + richText3.setMaxWidth( win->getWidth() - richText3.getPosition().x ); + + win->getInput()->pushCallback( [&]( InputEvent* event ) { + if ( event->Type == InputEvent::VideoResize ) { + richText.setMaxWidth( std::ceil( win->getWidth() * 0.4 ) ); + + richText2.setPosition( + richText.getPosition() + Vector2f{ 25.f, 0.f } + + Vector2f{ static_cast( std::ceil( win->getWidth() * 0.4 ) ), 0 } ); + richText2.setMaxWidth( std::ceil( win->getWidth() * 0.1 ) ); + + richText3.setPosition( + Vector2f{ 25.f, 50.f } + + Vector2f{ static_cast( std::ceil( win->getWidth() * 0.6 ) ), 0 } ); + richText3.setMaxWidth( win->getWidth() - richText3.getPosition().x ); + } + } ); + + while ( win->isRunning() ) { + win->getInput()->update(); + + if ( win->getInput()->isKeyUp( KEY_ESCAPE ) ) { + win->close(); + } + + win->setClearColor( Color( 200, 200, 200 ) ); + win->clear(); + + // Draw a line to show the wrap width + Float boxWidth = std::ceil( win->getWidth() * 0.4 ); + Primitives p; + p.setColor( Color::Black ); + + Float line1X = richText.getPosition().x + boxWidth; + p.drawLine( { { line1X, 0 }, { line1X, (Float)win->getHeight() } } ); + + Float line2X = + richText2.getPosition().x + static_cast( std::ceil( win->getWidth() * 0.1 ) ); + p.drawLine( { { line2X, 0 }, { line2X, (Float)win->getHeight() } } ); + + richText.draw(); + richText2.draw(); + richText3.draw(); + + win->display(); + } + + Engine::destroySingleton(); +} + +EE_MAIN_FUNC int main( int argc, char* argv[] ) { + runRichTextTest(); + return 0; +} diff --git a/src/tests/unit_tests/compareimages.hpp b/src/tests/unit_tests/compareimages.hpp new file mode 100644 index 000000000..aeb6289d9 --- /dev/null +++ b/src/tests/unit_tests/compareimages.hpp @@ -0,0 +1,60 @@ +#include "utest.h" + +#include + +#include +#include +#include +#include + +using namespace EE; +using namespace EE::System; +using namespace EE::Graphics; +using namespace EE::Window; + +static void compareImages( utest_state_s& utest_state, int* utest_result, EE::Window::Window* win, + const std::string& imageName, + const std::string& imagesFolder = "fontrendering" ) { + auto saveType = Image::SaveType::WEBP; + auto saveExt( Image::saveTypeToExtension( saveType ) ); + std::string expectedImagePath( "assets/" + imagesFolder + "/" + imageName + "." + saveExt ); + + Image::FormatConfiguration fconf; + fconf.webpSaveLossless( true ); + + Image actualImage = win->getFrontBufferImage(); + actualImage.setImageFormatConfiguration( fconf ); + + if ( !FileSystem::fileExists( expectedImagePath ) ) + actualImage.saveToFile( expectedImagePath, saveType ); + + Image expectedImage( expectedImagePath ); + ASSERT_TRUE( expectedImage.getPixelsPtr() != nullptr ); + EXPECT_EQ_MSG( expectedImage.getWidth(), actualImage.getWidth(), "Images width not equal" ); + EXPECT_EQ_MSG( expectedImage.getHeight(), actualImage.getHeight(), "Images height not equal" ); + + Image::DiffResult result = actualImage.diff( expectedImage ); + EXPECT_TRUE( result.areSame() ); + if ( !result.areSame() ) { + auto saveExt( Image::saveTypeToExtension( saveType ) ); + std::string withTextShaper = + Text::TextShaperEnabled + ? ( Text::TextShaperOptimizations ? "_text_shape_no_opt" : "_text_shape" ) + : ""; + std::cerr << "Test FAILED: " << result.numDifferentPixels << " pixels differ." << std::endl; + std::cerr << "Maximum perceptual difference (Delta E): " << result.maxDeltaE << std::endl; + if ( !FileSystem::fileExists( "output" ) ) + FileSystem::makeDir( "output" ); + std::string actualImagePath = + "output/" + imageName + "_actual_output" + withTextShaper + "." + saveExt; + actualImage.saveToFile( actualImagePath, saveType ); + std::cerr << "Actual image saved to: " << actualImagePath << std::endl; + if ( result.diffImage ) { + std::string diffImagePath = + "output/" + imageName + "_diff_output" + withTextShaper + "." + saveExt; + result.diffImage->setImageFormatConfiguration( fconf ); + result.diffImage->saveToFile( diffImagePath, saveType ); + std::cerr << "Visual diff saved to: " << diffImagePath << std::endl; + } + } +} diff --git a/src/tests/unit_tests/fontrendering.cpp b/src/tests/unit_tests/fontrendering.cpp index 27a5f0bf4..1eab1a5f3 100644 --- a/src/tests/unit_tests/fontrendering.cpp +++ b/src/tests/unit_tests/fontrendering.cpp @@ -1,3 +1,4 @@ +#include "compareimages.hpp" #include "utest.hpp" #include @@ -10,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -24,8 +26,6 @@ #include #include -#include - using namespace EE; using namespace EE::Scene; using namespace EE::System; @@ -33,52 +33,6 @@ using namespace EE::Graphics; using namespace EE::Window; using namespace EE::UI; -static void compareImages( utest_state_s& utest_state, int* utest_result, EE::Window::Window* win, - const std::string& imageName ) { - auto saveType = Image::SaveType::WEBP; - auto saveExt( Image::saveTypeToExtension( saveType ) ); - std::string expectedImagePath( "assets/fontrendering/" + imageName + "." + saveExt ); - - Image::FormatConfiguration fconf; - fconf.webpSaveLossless( true ); - - Image actualImage = win->getFrontBufferImage(); - actualImage.setImageFormatConfiguration( fconf ); - - if ( !FileSystem::fileExists( expectedImagePath ) ) - actualImage.saveToFile( expectedImagePath, saveType ); - - Image expectedImage( expectedImagePath ); - ASSERT_TRUE( expectedImage.getPixelsPtr() != nullptr ); - EXPECT_EQ_MSG( expectedImage.getWidth(), actualImage.getWidth(), "Images width not equal" ); - EXPECT_EQ_MSG( expectedImage.getHeight(), actualImage.getHeight(), "Images height not equal" ); - - Image::DiffResult result = actualImage.diff( expectedImage ); - EXPECT_TRUE( result.areSame() ); - if ( !result.areSame() ) { - auto saveExt( Image::saveTypeToExtension( saveType ) ); - std::string withTextShaper = - Text::TextShaperEnabled - ? ( Text::TextShaperOptimizations ? "_text_shape_no_opt" : "_text_shape" ) - : ""; - std::cerr << "Test FAILED: " << result.numDifferentPixels << " pixels differ." << std::endl; - std::cerr << "Maximum perceptual difference (Delta E): " << result.maxDeltaE << std::endl; - if ( !FileSystem::fileExists( "output" ) ) - FileSystem::makeDir( "output" ); - std::string actualImagePath = - "output/" + imageName + "_actual_output" + withTextShaper + "." + saveExt; - actualImage.saveToFile( actualImagePath, saveType ); - std::cerr << "Actual image saved to: " << actualImagePath << std::endl; - if ( result.diffImage ) { - std::string diffImagePath = - "output/" + imageName + "_diff_output" + withTextShaper + "." + saveExt; - result.diffImage->setImageFormatConfiguration( fconf ); - result.diffImage->saveToFile( diffImagePath, saveType ); - std::cerr << "Visual diff saved to: " << diffImagePath << std::endl; - } - } -} - UTEST( FontRendering, fontsTest ) { FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); diff --git a/src/tests/unit_tests/richtext.cpp b/src/tests/unit_tests/richtext.cpp new file mode 100644 index 000000000..402798ab6 --- /dev/null +++ b/src/tests/unit_tests/richtext.cpp @@ -0,0 +1,235 @@ +#include "compareimages.hpp" +#include "utest.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::Graphics; +using namespace EE::Window; + +UTEST( RichText, basicFunctionality ) { + Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Test", + WindowStyle::Default, WindowBackend::Default, + 32, {}, 1, false, true ) ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); + font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" ); + + ASSERT_TRUE( font->loaded() ); + FontFamily::loadFromRegular( font ); + + RichText richText; + richText.getFontStyleConfig().Font = font; + richText.getFontStyleConfig().CharacterSize = 12; + + richText.addSpan( "Hello " ); + richText.addSpan( "world" ); + richText.addSpan( "bold", nullptr, 0, Color::White, Text::Bold ); + + // Force layout update + Sizef size = richText.getSize(); + EXPECT_TRUE( size.getWidth() > 0 ); + EXPECT_TRUE( size.getHeight() > 0 ); + + // Check that we have lines and spans + const auto& lines = richText.getLines(); + EXPECT_FALSE( lines.empty() ); + if ( !lines.empty() ) { + EXPECT_FALSE( lines[0].spans.empty() ); + // Check that spans have increasing X positions + if ( lines[0].spans.size() >= 2 ) { + EXPECT_GT( lines[0].spans[1].position.x, lines[0].spans[0].position.x ); + } + } + + // Check wrapping + Float fullWidth = size.getWidth(); + richText.setMaxWidth( fullWidth / 2 ); + + Sizef wrappedSize = richText.getSize(); + EXPECT_LT( wrappedSize.getWidth(), fullWidth ); + EXPECT_GT( wrappedSize.getHeight(), size.getHeight() ); + + Engine::destroySingleton(); +} + +UTEST( RichText, BaselineAlignment ) { + Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Baseline", + WindowStyle::Default, WindowBackend::Default, + 32, {}, 1, false, true ) ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); + font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" ); + ASSERT_TRUE( font->loaded() ); + + RichText richText; + richText.getFontStyleConfig().Font = font; + richText.addSpan( "Large", nullptr, 30 ); + richText.addSpan( "Small", nullptr, 12 ); + + richText.getSize(); // Update layout + + const auto& lines = richText.getLines(); + ASSERT_EQ( lines.size(), (size_t)1 ); + ASSERT_EQ( lines[0].spans.size(), (size_t)2 ); + + const auto& largeSpan = lines[0].spans[0]; + const auto& smallSpan = lines[0].spans[1]; + + // Large span should be at the top of the line (offset 0 relative to ascent difference) + // Small span should be pushed down + Float largeAscent = font->getAscent( 30 ); + Float smallAscent = font->getAscent( 12 ); + + // Expected offsets + Float expectedLargeY = 0; // maxAscent - largeAscent = 0 + Float expectedSmallY = largeAscent - smallAscent; + + EXPECT_NEAR( largeSpan.position.y, expectedLargeY, 0.001f ); + EXPECT_NEAR( smallSpan.position.y, expectedSmallY, 0.001f ); + + EXPECT_GT( smallSpan.position.y, largeSpan.position.y ); + + Engine::destroySingleton(); +} + +UTEST( LineWrap, SoftWrapPreventsWordSplitWithOffset ) { + Engine::instance()->createWindow( WindowSettings( 800, 600, "LineWrap Test", + WindowStyle::Default, WindowBackend::Default, + 32, {}, 1, false, true ) ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); + font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" ); + ASSERT_TRUE( font->loaded() ); + + String text = " World"; + Float width = Text::getTextWidth( font, 20, text, 0, 4, 0.f ); + Float maxWidth = width * 1.5f; + Float offset = maxWidth - ( width * 0.5f ); + + LineWrapInfo info = + LineWrap::computeLineBreaks( text, font, 20, maxWidth, LineWrapMode::Word, 0, 0.f, false, 4, + 0.f, TextHints::None, false, offset ); + + // With " World" (space at 0, W at 1). + // LineWrap returns the index of the wrap. + // Index 0 is always pushed at start. + // If we wrap at 1 (skipping space), we should have at least 2 wraps: 0 and 1. + ASSERT_GE( info.wraps.size(), (size_t)2 ); + EXPECT_EQ( info.wraps[1], 1 ); + + delete font; + + Engine::destroySingleton(); +} + +UTEST( RichText, RichTextTest ) { + const auto& createRichText = []( Font* font ) -> RichText { + RichText richText; + richText.getFontStyleConfig().Font = font; + richText.getFontStyleConfig().CharacterSize = 24; + richText.setAlign( TEXT_ALIGN_LEFT ); + + // Add spans using the helper method + richText.addSpan( "Hello " ); + richText.addSpan( "bold world", nullptr, 24, Color::Red, + Text::Bold ); // Use nullptr to use the font associated with the style + // (if loaded via FontFamily) + richText.addSpan( "! This is a " ); + richText.addSpan( "colored", nullptr, 0, + Color::Green ); // Inherit font and size, change color + richText.addSpan( " processing example. " ); + richText.addSpan( "It should support " ); + richText.addSpan( "soft wrapping", nullptr, 0, Color::Blue, Text::Italic ); + richText.addSpan( " across multiple lines if the text is long enough. " ); + richText.addSpan( "And also " ); + richText.addSpan( "different font sizes", nullptr, 32, + Color( 255, 0, 255, 255 ) ); // Magenta manually + richText.addSpan( " in the same block." ); + return richText; + }; + + const auto& runTest = [&createRichText, &utest_result]() { + auto win = + Engine::instance()->createWindow( WindowSettings( 1024, 650, "RichText Example" ) ); + + if ( !win->isOpen() ) + return; + + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + FontTrueType* font = + FontTrueType::New( "NotoSans-Regular", "../assets/fonts/NotoSans-Regular.ttf" ); + + ASSERT_TRUE( font && font->loaded() ); + + FontFamily::loadFromRegular( font ); + + RichText richText = createRichText( font ); + richText.setMaxWidth( std::ceil( win->getWidth() * 0.4 ) ); + richText.setPosition( { 50.f, 50.f } ); + + richText.setMaxWidth( std::ceil( win->getWidth() * 0.4 ) ); + richText.setPosition( { 25.f, 50.f } ); + + RichText richText2 = richText; + richText2.setPosition( + richText2.getPosition() + Vector2f{ 25.f, 0.f } + + Vector2f{ static_cast( std::ceil( win->getWidth() * 0.4 ) ), 0 } ); + richText2.setMaxWidth( std::ceil( win->getWidth() * 0.15 ) ); + + RichText richText3 = richText2; + richText3.setPosition( + Vector2f{ 25.f, 50.f } + + Vector2f{ static_cast( std::ceil( win->getWidth() * 0.6 ) ), 0 } ); + richText3.setMaxWidth( win->getWidth() - richText3.getPosition().x ); + + win->setClearColor( Color( 200, 200, 200 ) ); + win->clear(); + + // Draw a line to show the wrap width + Float boxWidth = std::ceil( win->getWidth() * 0.4 ); + Primitives p; + p.setColor( Color::Black ); + + Float line1X = richText.getPosition().x + boxWidth; + p.drawLine( { { line1X, 0 }, { line1X, (Float)win->getHeight() } } ); + + Float line2X = + richText2.getPosition().x + static_cast( std::ceil( win->getWidth() * 0.15 ) ); + p.drawLine( { { line2X, 0 }, { line2X, (Float)win->getHeight() } } ); + + richText.draw(); + richText2.draw(); + richText3.draw(); + + compareImages( utest_state, utest_result, win, "eepp-rich-text" ); + + Engine::destroySingleton(); + }; + + UTEST_PRINT_STEP( "Text Shaper disabled" ); + { + BoolScopedOp op( Text::TextShaperEnabled, false ); + runTest(); + } + + UTEST_PRINT_STEP( "Text Shaper enabled" ); + { + BoolScopedOp op( Text::TextShaperEnabled, true ); + runTest(); + + UTEST_PRINT_STEP( "Text Shaper enabled w/o optimizations" ); + BoolScopedOp op2( Text::TextShaperOptimizations, false ); + runTest(); + } +}