Several fixes in soft-wrap implementation.

Added soft-wrap support in UITextView and UITooltip.
Added more soft-wrap tests, now testing also selection.
This commit is contained in:
Martín Lucas Golini
2026-02-03 01:41:15 -03:00
parent e553398b04
commit ec47dffa30
12 changed files with 262 additions and 66 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -368,6 +368,11 @@ class EE_API Text {
/** Finds the visual line index that contains the given character index. */
size_t findVisualLineFromCharIndex( size_t charIndex );
/** @return A list of rectangles that cover the selection of the string, each rectangle
* has the line spacing height and covers the width of the selection.
*/
std::vector<Rectf> getSelectionRects( size_t selectionStartIndex, size_t selectionEndIndex );
protected:
struct VertexCoords {
Vector2f texCoords;
@@ -383,7 +388,7 @@ class EE_API Text {
mutable bool mColorsNeedUpdate : 1 { false };
mutable bool mContainsColorEmoji : 1 { false };
mutable bool mVisualLinesNeedUpdate : 1 { true };
mutable bool mCachedWidthNeedUpdate: 1 { true };
mutable bool mCachedWidthNeedUpdate : 1 { true };
bool mTabStops : 1 { false };
bool mLineWrapKeepIndentation : 1 { false };

View File

@@ -124,6 +124,10 @@ class EE_API UITextView : public UIWidget {
bool isWordWrap() const;
std::pair<int, int> getSelection() const;
void setSelection( std::pair<int, int> sel );
protected:
Text* mTextCache;
String mString;
@@ -132,13 +136,7 @@ class EE_API UITextView : public UIWidget {
Int32 mSelCurInit;
Int32 mSelCurEnd;
Uint32 mTextDrawHints{ 0 };
struct SelPosCache {
SelPosCache( Vector2f ip, Vector2f ep ) : initPos( ip ), endPos( ep ) {}
Vector2f initPos;
Vector2f endPos;
};
std::vector<SelPosCache> mSelPosCache;
std::vector<Rectf> mSelRectsCache;
Int32 mLastSelCurInit;
Int32 mLastSelCurEnd;
bool mSelecting;

View File

@@ -74,7 +74,7 @@ LineWrap::computeLineBreaksInternal( const String::View& string, Font* font, Uin
LineWrapType info;
info.wraps.push_back( 0 );
if ( string.empty() || nullptr == font || mode == LineWrapMode::NoWrap || maxWidth == 0 ) {
if ( string.empty() || nullptr == font ) {
if constexpr ( std::is_same_v<LineWrapType, LineWrapInfoEx> ) {
info.wrapsWidth.push_back( 0 );
}
@@ -143,6 +143,7 @@ LineWrap::computeLineBreaksInternal( const String::View& string, Font* font, Uin
size_t lastSpace = 0;
Uint32 prevChar = 0;
size_t idx = 0;
bool hasWrap = maxWidth > 0 && mode != LineWrapMode::NoWrap;
for ( const auto& curChar : string ) {
if ( curChar == '\n' ) {
@@ -175,7 +176,7 @@ LineWrap::computeLineBreaksInternal( const String::View& string, Font* font, Uin
xoffset += w;
if ( xoffset > maxWidth ) {
if ( hasWrap && xoffset > maxWidth ) {
if ( mode == LineWrapMode::Word && lastSpace ) {
if constexpr ( std::is_same_v<LineWrapType, LineWrapInfoEx> ) {
info.wrapsWidth.push_back( std::ceil( lastWordWrapWidth ) );

View File

@@ -8,6 +8,7 @@
#include <eepp/graphics/renderer/opengl.hpp>
#include <eepp/graphics/renderer/renderer.hpp>
#include <eepp/graphics/text.hpp>
#include <eepp/graphics/textlayout.hpp>
#include <eepp/graphics/texture.hpp>
#include <eepp/graphics/texturefactory.hpp>
#include <limits>
@@ -1938,21 +1939,11 @@ void Text::ensureGeometryUpdate() {
}
}
// Helper lambda to check if index starts a soft-wrapped line (not a real newline)
auto isSoftWrapLineStart = [this, &useSoftWrap, &currentVisualLine]( Int64 idx ) -> bool {
if ( !useSoftWrap || currentVisualLine + 1 >= mVisualLines.size() )
return false;
// Check if this is the start of the next visual line
if ( idx == mVisualLines[currentVisualLine + 1] ) {
// It's a soft wrap if the previous char wasn't a newline
if ( idx > 0 && mString[idx - 1] != '\n' ) {
return true;
}
}
return false;
return !( !useSoftWrap || currentVisualLine + 1 >= mVisualLines.size() ) &&
idx == mVisualLines[currentVisualLine + 1] && idx > 0 && mString[idx] != '\n';
};
// Helper to update alignment for current visual line
auto updateAlignmentForLine = [this, &centerDiffX, &line]() {
switch ( Font::getHorizontalAlign( mAlign ) ) {
case TEXT_ALIGN_CENTER:
@@ -2179,6 +2170,8 @@ void Text::ensureGeometryUpdate() {
// For soft wrap, the width cache was already handled by ensureVisualLinesUpdate
if ( useSoftWrap && mCachedWidthNeedUpdate ) {
mCachedWidthNeedUpdate = false;
if ( !mLinesWidth.empty() )
mCachedWidth = *std::max_element( mLinesWidth.begin(), mLinesWidth.end() );
}
}
@@ -2785,4 +2778,72 @@ size_t Text::findVisualLineFromCharIndex( size_t charIndex ) {
return 0;
}
std::vector<Rectf> Text::getSelectionRects( size_t selectionStartIndex, size_t selectionEndIndex ) {
std::vector<Rectf> rects;
if ( selectionStartIndex == selectionEndIndex || !mFontStyleConfig.Font )
return rects;
if ( selectionStartIndex > selectionEndIndex )
std::swap( selectionStartIndex, selectionEndIndex );
ensureVisualLinesUpdate();
cacheWidth();
size_t startLine = findVisualLineFromCharIndex( selectionStartIndex );
size_t endLine = findVisualLineFromCharIndex( selectionEndIndex );
Float hspace =
mFontStyleConfig.Font
->getGlyph( ' ', mFontStyleConfig.CharacterSize, mFontStyleConfig.Style & Text::Bold,
mFontStyleConfig.Style & Text::Italic )
.advance;
Float vspace = static_cast<Float>(
mFontStyleConfig.Font->getLineSpacing( mFontStyleConfig.CharacterSize ) );
for ( size_t i = startLine; i <= endLine; ++i ) {
Float top = i * vspace;
Float bottom = top + vspace;
Float left = 0;
Float right = 0;
Float centerDiffX = 0;
if ( i < mLinesWidth.size() ) {
switch ( Font::getHorizontalAlign( mAlign ) ) {
case TEXT_ALIGN_CENTER:
centerDiffX = std::trunc( ( mCachedWidth - mLinesWidth[i] ) * 0.5f );
break;
case TEXT_ALIGN_RIGHT:
centerDiffX = mCachedWidth - mLinesWidth[i];
break;
}
}
// Calculate Left
if ( i == startLine ) {
left = findCharacterPos( selectionStartIndex ).x;
} else {
left = centerDiffX;
}
// Calculate Right
if ( i == endLine ) {
// If it's a newline character, we select a small chunk to indicate the newline
// selection
if ( selectionEndIndex < mString.size() && mString[selectionEndIndex] == '\n' ) {
right = findCharacterPos( selectionEndIndex ).x + hspace;
} else {
right = findCharacterPos( selectionEndIndex ).x;
}
} else {
right = centerDiffX + ( i < mLinesWidth.size() ? mLinesWidth[i] : 0 );
}
if ( left != right ) {
rects.push_back( Rectf( left, top, right, bottom ) );
}
}
return rects;
}
}} // namespace EE::Graphics

View File

@@ -545,6 +545,8 @@ void TextLayout::wrapLayout( const String::View& string, TextLayout& result,
const Float& outlineThickness, Float hspace ) {
std::size_t paragraphCount = result.paragraphs.size();
result.size = Sizef::Zero;
for ( std::size_t paragraphIdx = 0; paragraphIdx < paragraphCount; paragraphIdx++ ) {
ShapedTextParagraph& sp = result.paragraphs[paragraphIdx];
std::size_t shapedGlyphCount = sp.shapedGlyphs.size();
@@ -614,6 +616,9 @@ void TextLayout::wrapLayout( const String::View& string, TextLayout& result,
}
if ( sp.wrapInfo.wrapsWidth.empty() ) {
if ( shapedGlyphCount )
maxSize = sp.shapedGlyphs.back().position + sp.shapedGlyphs.back().advance;
// Restore the original wraps which are the paragraph wraps (no wrapping occurred)
sp.wrapInfo.wrapsWidth = std::move( wrapsWidth );
} else if ( !sp.shapedGlyphs.empty() ) {
@@ -622,9 +627,10 @@ void TextLayout::wrapLayout( const String::View& string, TextLayout& result,
}
sp.size = maxSize;
}
result.size = result.paragraphs[result.paragraphs.size() - 1].size;
result.size = { std::max( sp.size.x, result.size.x ),
std::max( sp.size.y, result.size.y ) };
}
}
} // namespace EE::Graphics

View File

@@ -272,7 +272,8 @@ TextRange DocumentView::getVisibleIndexRange( VisibleIndex visibleIndex ) const
Int64 idx = static_cast<Int64>( visibleIndex );
auto start = getVisibleIndexPosition( visibleIndex );
auto end = start;
if ( idx + 1 < static_cast<Int64>( mVisibleLines.size() ) &&
eeASSERT( visibleIndex >= static_cast<VisibleIndex>( 0 ) );
if ( idx >= 0 && idx + 1 < static_cast<Int64>( mVisibleLines.size() ) &&
mVisibleLines[idx + 1].line() == start.line() ) {
end.setColumn( mVisibleLines[idx + 1].column() );
} else {

View File

@@ -4701,7 +4701,7 @@ String UICodeEditor::checkMouseOverLink( const Vector2i& position, bool checkMod
if ( !mInteractiveLinks || ( checkModifiers && !getInput()->isKeyModPressed() ) )
return resetLinkOver( position );
TextPosition pos( resolveScreenPosition( position.asFloat(), false ) );
TextPosition pos( resolveScreenPosition( position.asFloat() ) );
if ( pos.line() >= (Int64)mDoc->linesCount() )
return resetLinkOver( position );

View File

@@ -323,6 +323,9 @@ UITextView* UITextView::setSelectionBackColor( const Color& color ) {
}
void UITextView::autoWrap() {
mTextCache->setLineWrapMode( mFlags & UI_WORD_WRAP ? LineWrapMode::Word
: LineWrapMode::NoWrap );
if ( mFlags & UI_WORD_WRAP ) {
wrapText( mSize.getWidth() - mPaddingPx.Left - mPaddingPx.Right );
}
@@ -333,7 +336,8 @@ void UITextView::wrapText( const Uint32& maxWidth ) {
mTextCache->setString( mString );
}
mTextCache->hardWrapText( maxWidth );
mTextCache->setLineWrapMode( LineWrapMode::Word );
mTextCache->setMaxWrapWidth( maxWidth );
invalidateDraw();
}
@@ -564,46 +568,26 @@ void UITextView::drawSelection( Text* textCache ) {
return;
}
Int32 lastEnd;
Vector2f initPos, endPos;
if ( mLastSelCurInit != selCurInit() || mLastSelCurEnd != selCurEnd() ) {
mSelPosCache.clear();
mSelRectsCache.clear();
mLastSelCurInit = selCurInit();
mLastSelCurEnd = selCurEnd();
do {
initPos = textCache->findCharacterPos( init );
lastEnd = textCache->getString().find_first_of( '\n', init );
if ( lastEnd < end && -1 != lastEnd ) {
endPos = textCache->findCharacterPos( lastEnd );
init = lastEnd + 1;
} else {
endPos = textCache->findCharacterPos( end );
lastEnd = end;
}
mSelPosCache.push_back( SelPosCache( initPos, endPos ) );
} while ( end != lastEnd );
mSelRectsCache = mTextCache->getSelectionRects( selCurInit(), selCurEnd() );
}
if ( !mSelPosCache.empty() ) {
if ( !mSelRectsCache.empty() ) {
Vector2f initPos, endPos;
Primitives P;
P.setColor( mFontStyleConfig.FontSelectionBackColor );
Float vspace = textCache->getFont()->getLineSpacing( mTextCache->getCharacterSize() );
Float height = mSize.y - mPaddingPx.Top - mPaddingPx.Bottom;
Float offsetY = eefloor( ( height - mTextCache->getTextHeight() ) * 0.5f );
for ( size_t i = 0; i < mSelRectsCache.size(); i++ ) {
initPos = mSelRectsCache[i].getPosition();
endPos = mSelRectsCache[i].getPosition() + mSelRectsCache[i].getSize();
for ( size_t i = 0; i < mSelPosCache.size(); i++ ) {
initPos = mSelPosCache[i].initPos;
endPos = mSelPosCache[i].endPos;
P.drawRectangle(
Rectf( mScreenPos.x + initPos.x + mRealAlignOffset.x + mPaddingPx.Left,
mScreenPos.y + initPos.y + offsetY + mPaddingPx.Top,
mScreenPos.x + endPos.x + mRealAlignOffset.x + mPaddingPx.Left,
mScreenPos.y + endPos.y + offsetY + mPaddingPx.Top + vspace ) );
P.drawRectangle( Rectf(
mScreenPos.x + initPos.x + mRealAlignOffset.x + mPaddingPx.Left,
mScreenPos.y + initPos.y + mRealAlignOffset.y + mPaddingPx.Top,
mScreenPos.x + endPos.x + mRealAlignOffset.x + mPaddingPx.Left,
mScreenPos.y + endPos.y + mRealAlignOffset.y + mPaddingPx.Top ) );
}
}
}
@@ -637,6 +621,15 @@ void UITextView::setFontStyleConfig( const UIFontStyleConfig& fontStyleConfig )
onFontStyleChanged();
}
std::pair<int, int> UITextView::getSelection() const {
return { mSelCurInit, mSelCurEnd };
}
void UITextView::setSelection( std::pair<int, int> sel ) {
selCurInit( std::clamp( sel.first, 0, (Int32)mString.size() ) );
selCurEnd( std::clamp( sel.second, 0, (Int32)mString.size() ) );
}
void UITextView::selCurInit( const Int32& init ) {
if ( mSelCurInit != init ) {
mSelCurInit = init;

View File

@@ -159,7 +159,7 @@ void UITooltip::draw() {
UINode::draw();
if ( mTextCache->getTextWidth() ) {
mTextCache->setAlign( getFlags() );
mTextCache->setAlign( getHorizontalAlign() | getVerticalAlign() );
mTextCache->draw( std::trunc( mScreenPos.x ) + (int)mAlignOffset.x,
std::trunc( mScreenPos.y ) + (int)mAlignOffset.y, Vector2f::One, 0.f,
getBlendMode() );
@@ -615,6 +615,9 @@ void UITooltip::onAlphaChange() {
}
void UITooltip::autoWrap() {
mTextCache->setLineWrapMode( mFlags & UI_WORD_WRAP ? LineWrapMode::Word
: LineWrapMode::NoWrap );
if ( mFlags & UI_WORD_WRAP && !mMaxWidthEq.empty() ) {
Float length =
lengthFromValue( mMaxWidthEq, CSS::PropertyRelativeTarget::ContainingBlockWidth );
@@ -627,7 +630,8 @@ void UITooltip::wrapText( const Uint32& maxWidth ) {
mTextCache->setString( mStringBuffer );
}
mTextCache->hardWrapText( maxWidth );
mTextCache->setLineWrapMode( LineWrapMode::Word );
mTextCache->setMaxWrapWidth( maxWidth );
invalidateDraw();
}

View File

@@ -161,8 +161,11 @@ EE_MAIN_FUNC int main( int, char*[] ) {
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
auto ll = UILinearLayout::NewVertical();
ll->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent );
auto editor = UITextView::New();
/*
auto editor = UITextEdit::New();
editor->setShowLineNumber( false );
*/
editor->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent );
editor->setParent( ll );
editor->setFontSize( PixelDensity::dpToPx( 12 ) );
@@ -170,7 +173,8 @@ EE_MAIN_FUNC int main( int, char*[] ) {
FontTrueType::New( "arabic", "unit_tests/assets/fonts/NotoNaskhArabic-Regular.ttf" ) );
FontManager::instance()->addFallbackFont( FontTrueType::New(
"NotoSerifBengali-Regular", "unit_tests/assets/fonts/NotoSansBengali-Regular.ttf" ) );
editor->setLineWrapMode( LineWrapMode::Word );
editor->setWordWrap( true );
// editor->setLineWrapMode( LineWrapMode::Word );
// editor->setFont( FontManager::instance()->getByName( "monospace" ) );
// editor->loadFromFile( "unit_tests/assets/textfiles/test-arabic-simple.uext" );
// editor->loadFromFile( "unit_tests/assets/textfiles/test-arabic.uext" );
@@ -179,7 +183,11 @@ EE_MAIN_FUNC int main( int, char*[] ) {
// editor->loadFromFile( "unit_tests/assets/textformat/english.utf8.lf.nobom.txt" );
// editor->loadFromFile( "unit_tests/assets/textfiles/test-arabic-mixed.uext" );
// editor->loadFromFile( "unit_tests/assets/textfiles/test-mixed-text.uext" );
editor->loadFromFile( "unit_tests/assets/textfiles/lorem-ipsum.uext" );
// editor->loadFromFile( "unit_tests/assets/textfiles/lorem-ipsum.uext" );
std::string buffer;
FileSystem::fileGet( "unit_tests/assets/textfiles/lorem-ipsum.uext", buffer );
editor->setText( buffer );
editor->setTextSelection( true );
editor->setFont( app.getUI()->getUIThemeManager()->getDefaultFont() );
editor->on( Event::KeyUp, [&]( const Event* event ) {

View File

@@ -1089,6 +1089,46 @@ UTEST( FontRendering, TextHardWrap ) {
}
}
UTEST( FontRendering, UITextViewWrappedSelection ) {
const auto runTest = [&]() {
UIApplication app(
WindowSettings( 1024, 650, "eepp - TextView Wrapped Selection", WindowStyle::Default,
WindowBackend::Default, 32, {}, 1, false, true ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(),
1.5f ) );
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
std::string buffer;
FileSystem::fileGet( "assets/textfiles/lorem-ipsum.uext", buffer );
auto textView = UITextView::New();
textView->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::WrapContent );
textView->setPixelsSize( app.getUI()->getPixelsSize() );
textView->setText( buffer );
textView->setWordWrap( true );
textView->setTextSelection( true );
textView->setSelection( { 51, 286 } );
SceneManager::instance()->update();
SceneManager::instance()->draw();
compareImages( utest_state, utest_result, app.getWindow(),
"eepp-textview-wrapped-selection" );
};
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();
}
}
UTEST( FontRendering, TextSoftWrapPos ) {
const auto runTest = [&]() {
UIApplication app(
@@ -1109,16 +1149,16 @@ UTEST( FontRendering, TextSoftWrapPos ) {
text.setMaxWrapWidth( 200.f );
Vector2f pos = text.findCharacterPos( 30 );
EXPECT_GT( pos.y, 0 );
EXPECT_GT( pos.y, 0.f );
Float vspace = text.getFont()->getLineSpacing( text.getCharacterSize() );
Vector2i queryPos( 10, (int)vspace + 5 );
Int32 foundIndex = text.findCharacterFromPos( queryPos );
EXPECT_GT( foundIndex, 14 );
EXPECT_GT( foundIndex, (Int32)14 );
Vector2f foundPos = text.findCharacterPos( foundIndex );
EXPECT_GT( foundPos.y, 0 );
EXPECT_GT( foundPos.y, 0.f );
};
UTEST_PRINT_STEP( "Text Shaper disabled" );
@@ -1137,3 +1177,82 @@ UTEST( FontRendering, TextSoftWrapPos ) {
runTest();
}
}
UTEST( FontRendering, TextSelection ) {
auto win = Engine::instance()->createWindow(
WindowSettings( 1024, 650, "eepp - Text Selection", WindowStyle::Default,
WindowBackend::Default, 32, {}, 1, false, true ) );
ASSERT_TRUE_MSG( win->isOpen(), "Failed to create Window" );
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
Text::TextShaperEnabled = false;
FontTrueType* font = FontTrueType::New( "NotoSans-Regular" );
bool loaded = font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
ASSERT_TRUE( loaded );
FontFamily::loadFromRegular( font );
FontStyleConfig config;
config.Font = font;
config.CharacterSize = 20;
config.FontColor = Color::Black;
config.Style = Text::Regular;
String txt( "Line 1\nLine 2 is longer\nLine 3" );
Text text;
text.setStyleConfig( config );
text.setString( txt );
// Test 1: Single line selection (Line 1)
{
std::vector<Rectf> rects = text.getSelectionRects( 0, 4 ); // "Line"
EXPECT_EQ( 1ul, rects.size() );
if ( !rects.empty() ) {
EXPECT_EQ( 0, rects[0].Top );
EXPECT_GT( rects[0].getWidth(), 0 );
EXPECT_EQ( text.findCharacterPos( 0 ).x, rects[0].Left );
EXPECT_EQ( text.findCharacterPos( 4 ).x, rects[0].Right );
}
}
// Test 2: Multi-line selection (Line 1 to Line 2)
{
// "Line 1\nLine 2" -> Indices: "Line 1" (0-5), "\n" (6), "Line 2" (7-12)
// Select from index 2 ("n" in "Line 1") to index 9 ("i" in "Line 2")
std::vector<Rectf> rects = text.getSelectionRects( 2, 9 );
EXPECT_EQ( 2ul, rects.size() );
if ( rects.size() >= 2 ) {
// First line rect: From index 2 to end of line 1
EXPECT_EQ( text.findCharacterPos( 2 ).x, rects[0].Left );
EXPECT_GT( rects[0].Right, rects[0].Left );
// Second line rect: From start of line 2 to index 9
EXPECT_EQ( 0, rects[1].Left ); // Left aligned
EXPECT_EQ( text.findCharacterPos( 9 ).x, rects[1].Right );
}
}
// Test 3: Full selection
{
std::vector<Rectf> rects = text.getSelectionRects( 0, txt.size() );
EXPECT_EQ( 3ul, rects.size() );
}
// Test 4: Soft wrap
{
text.setLineWrapMode( LineWrapMode::Word );
text.setMaxWrapWidth( 50 ); // Force wrap
text.setString( "This is a very long string that should wrap multiple times." );
// Ensure layout is updated
text.getVisualLineCount();
EXPECT_GT( text.getVisualLineCount(), (Uint32)1 );
std::vector<Rectf> rects = text.getSelectionRects( 0, text.getString().size() );
EXPECT_EQ( (size_t)text.getVisualLineCount(), rects.size() );
}
Engine::destroySingleton();
}