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.
This commit is contained in:
Martín Lucas Golini
2026-02-08 13:35:16 -03:00
parent 4979623687
commit e0bf0a23e5
18 changed files with 909 additions and 58 deletions

View File

@@ -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`

View File

@@ -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.

View File

@@ -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.

View File

@@ -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": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -21,6 +21,7 @@
#include <eepp/graphics/globaltextureatlas.hpp>
#include <eepp/graphics/glyphdrawable.hpp>
#include <eepp/graphics/image.hpp>
#include <eepp/graphics/linewrap.hpp>
#include <eepp/graphics/ninepatch.hpp>
#include <eepp/graphics/ninepatchmanager.hpp>
#include <eepp/graphics/particle.hpp>
@@ -33,13 +34,16 @@
#include <eepp/graphics/renderer/renderergl.hpp>
#include <eepp/graphics/renderer/renderergl3.hpp>
#include <eepp/graphics/rendermode.hpp>
#include <eepp/graphics/richtext.hpp>
#include <eepp/graphics/scopedtexture.hpp>
#include <eepp/graphics/scrollparallax.hpp>
#include <eepp/graphics/shader.hpp>
#include <eepp/graphics/shaderprogram.hpp>
#include <eepp/graphics/shaderprogrammanager.hpp>
#include <eepp/graphics/shapedglyph.hpp>
#include <eepp/graphics/sprite.hpp>
#include <eepp/graphics/text.hpp>
#include <eepp/graphics/textlayout.hpp>
#include <eepp/graphics/texture.hpp>
#include <eepp/graphics/textureatlas.hpp>
#include <eepp/graphics/textureatlasloader.hpp>

View File

@@ -31,6 +31,7 @@ class EE_API Drawable {
UINODEDRAWABLE_LAYERDRAWABLE,
UIBORDERDRAWABLE,
UIBACKGROUNDDRAWABLE,
RICHTEXT,
CUSTOM
};

View File

@@ -0,0 +1,119 @@
#ifndef EE_GRAPHICS_RICHTEXT_HPP
#define EE_GRAPHICS_RICHTEXT_HPP
#include <eepp/graphics/drawable.hpp>
#include <eepp/graphics/text.hpp>
#include <memory>
#include <vector>
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<std::shared_ptr<Text>>& 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> text;
Vector2f position; // Local position relative to RichText origin
};
/** @brief Structure representing a rendered paragraph (line). */
struct RenderParagraph {
std::vector<RenderSpan> spans;
Float y{ 0 };
Float height{ 0 };
Float maxAscent{ 0 };
Float width{ 0 };
};
/** @return The list of rendered lines. */
const std::vector<RenderParagraph>& getLines() const { return mLines; }
protected:
std::vector<std::shared_ptr<Text>> mSpans;
std::vector<RenderParagraph> mLines;
FontStyleConfig mDefaultStyle;
Uint32 mAlign{ TEXT_ALIGN_LEFT };
Float mMaxWidth{ 0.f };
Sizef mSize;
bool mNeedsLayoutUpdate{ true };
void updateLayout();
};
}} // namespace EE::Graphics
#endif

View File

@@ -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++"

View File

@@ -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++"

View File

@@ -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<FontTrueType*>( 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<LineWrapType, LineWrapInfoEx> ) {
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<LineWrapType, LineWrapInfoEx> ) {
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<LineWrapType, LineWrapInfoEx> ) {
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<LineWrapType, LineWrapInfoEx> ) {
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;

View File

@@ -0,0 +1,227 @@
#include <eepp/graphics/font.hpp>
#include <eepp/graphics/fontmanager.hpp>
#include <eepp/graphics/linewrap.hpp>
#include <eepp/graphics/primitives.hpp>
#include <eepp/graphics/richtext.hpp>
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<Text>();
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<Text> renderSpanText = std::make_shared<Text>();
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

View File

@@ -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<size_t>( 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<Float> 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 ) {

View File

@@ -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 )

View File

@@ -0,0 +1,115 @@
#include <eepp/ee.hpp>
#include <eepp/graphics/fontfamily.hpp>
#include <eepp/graphics/fonttruetype.hpp>
#include <eepp/graphics/primitives.hpp>
#include <eepp/graphics/richtext.hpp>
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<Float>( 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<Float>( 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<Float>( 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<Float>( 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<Float>( 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;
}

View File

@@ -0,0 +1,60 @@
#include "utest.h"
#include <iostream>
#include <eepp/graphics/image.hpp>
#include <eepp/graphics/text.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/window/window.hpp>
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;
}
}
}

View File

@@ -1,3 +1,4 @@
#include "compareimages.hpp"
#include "utest.hpp"
#include <eepp/graphics/batchrenderer.hpp>
@@ -10,6 +11,7 @@
#include <eepp/graphics/image.hpp>
#include <eepp/graphics/primitives.hpp>
#include <eepp/graphics/renderer/renderergl.hpp>
#include <eepp/graphics/richtext.hpp>
#include <eepp/graphics/text.hpp>
#include <eepp/scene/scenemanager.hpp>
#include <eepp/system/filesystem.hpp>
@@ -24,8 +26,6 @@
#include <eepp/ui/uithememanager.hpp>
#include <eepp/window/engine.hpp>
#include <iostream>
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() );

View File

@@ -0,0 +1,235 @@
#include "compareimages.hpp"
#include "utest.hpp"
#include <eepp/graphics/fontfamily.hpp>
#include <eepp/graphics/fonttruetype.hpp>
#include <eepp/graphics/primitives.hpp>
#include <eepp/graphics/richtext.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/system/scopedop.hpp>
#include <eepp/system/sys.hpp>
#include <eepp/window/engine.hpp>
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<Float>( 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<Float>( 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<Float>( 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();
}
}