Optimized layouting of UIHTMLTable.

Fixed in HTMLFormatter not handling correctly spaces after `<br/>`.
This commit is contained in:
Martín Lucas Golini
2026-03-09 02:26:33 -03:00
parent 6fd0950eba
commit 8dd4124518
4 changed files with 117 additions and 53 deletions

View File

@@ -6,6 +6,9 @@
namespace EE { namespace UI {
class UIHTMLTableRow;
class UIHTMLTableCell;
class EE_API UIHTMLTable : public UILayout {
public:
static UIHTMLTable* New();
@@ -20,6 +23,11 @@ class EE_API UIHTMLTable : public UILayout {
protected:
virtual Uint32 onMessage( const NodeMessage* Msg );
std::vector<UIHTMLTableRow*> mRows;
std::vector<Float> mColWidths;
std::vector<UIHTMLTableCell*> mCells;
std::vector<Uint32> mRowCellOffsets;
};
class EE_API UIHTMLTableCell : public UIRichText {

View File

@@ -175,22 +175,24 @@ String HTMLFormatter::collapseXmlWhitespace( const String& text, const pugi::xml
}
}
// Step 2: Determine if the left boundary is a block element.
// Step 2: Determine if the left boundary is a block element or a forced line break (<br/>).
// We use getLogicalPrev, and if the previous node is just empty space
// (lacks significant text), we keep looking further back.
pugi::xml_node prev = getLogicalPrev( node );
while ( prev && prev.type() == pugi::node_pcdata && !hasSignificantText( prev ) ) {
prev = getLogicalPrev( prev );
}
bool prevInline = isInlineNode( prev );
// A node is a valid inline neighbor only if it is an inline node AND not a <br/>
// (because <br/> strips adjacent whitespace).
bool prevInline = isInlineNode( prev ) && !String::iequals( prev.name(), "br" );
// Step 3: Determine if the right boundary is a block element.
// Step 3: Determine if the right boundary is a block element or a forced line break.
// We use getLogicalNext, skipping over any non-significant text nodes.
pugi::xml_node next = getLogicalNext( node );
while ( next && next.type() == pugi::node_pcdata && !hasSignificantText( next ) ) {
next = getLogicalNext( next );
}
bool nextInline = isInlineNode( next );
bool nextInline = isInlineNode( next ) && !String::iequals( next.name(), "br" );
// Step 4: Trim leading and trailing spaces if they adjoin a block boundary.
if ( !prevInline && !res.empty() && res[0] == ' ' )

View File

@@ -25,17 +25,15 @@ void UIHTMLTable::updateLayout() {
return;
mPacking = true;
// TODO: Optimize this horrendous implementation (fix the heap-allocation crazyness)
UIHTMLTableHead* head = nullptr;
UIHTMLTableBody* body = nullptr;
UIHTMLTableFooter* footer = nullptr;
std::vector<UIHTMLTableRow*> rows;
std::function<void( Node* )> collectRows = [&]( Node* node ) {
Node* child = node->getFirstChild();
while ( child ) {
mRows.clear();
auto collectRows = [&]( auto self, Node* node ) -> void {
for ( Node* child = node->getFirstChild(); child; child = child->getNextNode() ) {
if ( child->getType() == UI_TYPE_HTML_TABLE_ROW ) {
rows.push_back( child->asType<UIHTMLTableRow>() );
mRows.push_back( child->asType<UIHTMLTableRow>() );
} else if ( child->getType() != UI_TYPE_HTML_TABLE ) {
if ( child->getType() == UI_TYPE_HTML_TABLE_HEAD )
head = child->asType<UIHTMLTableHead>();
@@ -44,30 +42,31 @@ void UIHTMLTable::updateLayout() {
else if ( child->getType() == UI_TYPE_HTML_TABLE_FOOTER )
footer = child->asType<UIHTMLTableFooter>();
collectRows( child );
self( self, child );
}
child = child->getNextNode();
}
};
collectRows( this );
collectRows( collectRows, this );
if ( rows.empty() ) {
if ( mRows.empty() ) {
mPacking = false;
return;
}
std::vector<std::vector<UIHTMLTableCell*>> grid;
mCells.clear();
mRowCellOffsets.clear();
mRowCellOffsets.push_back( 0 );
size_t maxCols = 0;
for ( auto* row : rows ) {
std::vector<UIHTMLTableCell*> cells;
Node* child = row->getFirstChild();
while ( child ) {
if ( child->getType() == UI_TYPE_HTML_TABLE_CELL )
cells.push_back( child->asType<UIHTMLTableCell>() );
child = child->getNextNode();
for ( auto* row : mRows ) {
size_t cellCount = 0;
for ( Node* child = row->getFirstChild(); child; child = child->getNextNode() ) {
if ( child->getType() == UI_TYPE_HTML_TABLE_CELL ) {
mCells.push_back( child->asType<UIHTMLTableCell>() );
cellCount++;
}
}
grid.push_back( cells );
maxCols = std::max( maxCols, cells.size() );
mRowCellOffsets.push_back( (Uint32)mCells.size() );
maxCols = std::max( maxCols, cellCount );
}
if ( maxCols == 0 ) {
@@ -75,31 +74,29 @@ void UIHTMLTable::updateLayout() {
return;
}
std::vector<Float> colWidths( maxCols, 0.f );
mColWidths.assign( maxCols, 0.f );
// Get natural width for each column (without wrapping)
for ( const auto& rowCells : grid ) {
for ( size_t i = 0; i < rowCells.size(); ++i ) {
UIHTMLTableCell* cell = rowCells[i];
for ( size_t r = 0; r < mRows.size(); ++r ) {
Uint32 start = mRowCellOffsets[r];
Uint32 end = mRowCellOffsets[r + 1];
for ( Uint32 i = 0; i < end - start; ++i ) {
UIHTMLTableCell* cell = mCells[start + i];
cell->setLayoutWidthPolicy( SizePolicy::WrapContent );
cell->updateLayout();
colWidths[i] = std::max( colWidths[i], cell->getPixelsSize().getWidth() );
mColWidths[i] = std::max( mColWidths[i], cell->getPixelsSize().getWidth() );
}
}
Float availableWidth = getPixelsSize().getWidth() - mPaddingPx.Left - mPaddingPx.Right;
Float totalUnwrappedWidth = 0;
for ( Float w : colWidths )
for ( Float w : mColWidths )
totalUnwrappedWidth += w;
if ( totalUnwrappedWidth > availableWidth && totalUnwrappedWidth > 0 ) {
if ( totalUnwrappedWidth > 0 ) {
Float scale = availableWidth / totalUnwrappedWidth;
for ( size_t i = 0; i < maxCols; ++i )
colWidths[i] *= scale;
} else if ( totalUnwrappedWidth < availableWidth && maxCols > 0 && totalUnwrappedWidth > 0 ) {
Float scale = availableWidth / totalUnwrappedWidth;
for ( size_t i = 0; i < maxCols; ++i )
colWidths[i] *= scale;
mColWidths[i] *= scale;
}
Float headHeight = 0;
@@ -107,35 +104,37 @@ void UIHTMLTable::updateLayout() {
Float footerHeight = 0;
// Apply layout and calculate heights
size_t rowCount = grid.size();
size_t rowCount = mRows.size();
for ( size_t r = 0; r < rowCount; ++r ) {
Float rowHeight = 0;
size_t columnCount = grid[r].size();
for ( size_t c = 0; c < columnCount; ++c ) {
UIHTMLTableCell* cell = grid[r][c];
Uint32 start = mRowCellOffsets[r];
Uint32 end = mRowCellOffsets[r + 1];
Uint32 columnCount = end - start;
for ( Uint32 c = 0; c < columnCount; ++c ) {
UIHTMLTableCell* cell = mCells[start + c];
cell->setLayoutWidthPolicy( SizePolicy::Fixed );
cell->setPixelsSize( colWidths[c], cell->getPixelsSize().getHeight() );
cell->setPixelsSize( mColWidths[c], cell->getPixelsSize().getHeight() );
cell->updateLayout();
rowHeight = std::max( rowHeight, cell->getPixelsSize().getHeight() );
}
// Position cells inside the row and equalize height
Float currentX = 0;
for ( size_t c = 0; c < columnCount; ++c ) {
UIHTMLTableCell* cell = grid[r][c];
for ( Uint32 c = 0; c < columnCount; ++c ) {
UIHTMLTableCell* cell = mCells[start + c];
cell->setPixelsPosition( currentX, 0 );
cell->setPixelsSize( cell->getPixelsSize().getWidth(), rowHeight );
currentX += colWidths[c];
currentX += mColWidths[c];
}
// Set row height and width
UIHTMLTableRow* row = rows[r];
UIHTMLTableRow* row = mRows[r];
row->setPixelsSize( availableWidth, rowHeight );
if ( r == 0 ) {
headHeight = rowHeight;
} else if ( r == rowCount - 1 && columnCount &&
grid[r][0]->getParent()->isType( UI_TYPE_HTML_TABLE_FOOTER ) ) {
mCells[start]->getParent()->isType( UI_TYPE_HTML_TABLE_FOOTER ) ) {
footerHeight = rowHeight;
} else {
bodyHeight += rowHeight;
@@ -143,8 +142,8 @@ void UIHTMLTable::updateLayout() {
}
// Position rows vertically
// We also need to ensure that the containers (thead, tbody, etc.) are positioned at 0,0
// and have the correct size, so the absolute positioning of the rows works as expected.
// We also need to ensure that the containers (thead, tbody, etc.) are positioned correctly and
// have the correct size, so the absolute positioning of the rows works as expected.
if ( head ) {
head->setPixelsPosition( 0, 0 );
head->setPixelsSize( { getPixelsSize().x, headHeight } );
@@ -161,15 +160,18 @@ void UIHTMLTable::updateLayout() {
}
Float currentY = mPaddingPx.Top - headHeight;
for ( auto* row : rows ) {
for ( size_t r = 0; r < rowCount; ++r ) {
UIHTMLTableRow* row = mRows[r];
row->setPixelsPosition( mPaddingPx.Left, currentY );
currentY += row->getPixelsSize().getHeight();
}
if ( head && !rows.empty() )
rows[0]->setPixelsPosition( mPaddingPx.Left, 0 );
if ( footer && !rows.empty() )
rows[rowCount - 1]->setPixelsPosition( mPaddingPx.Left, 0 );
// Reset positions if they are inside specialized containers
if ( head && !mRows.empty() )
mRows[0]->setPixelsPosition( mPaddingPx.Left, 0 );
if ( footer && !mRows.empty() )
mRows[rowCount - 1]->setPixelsPosition( mPaddingPx.Left, 0 );
if ( mWidthPolicy == SizePolicy::MatchParent )
setInternalPixelsWidth( getMatchParentWidth() );

View File

@@ -836,3 +836,55 @@ UTEST( UIHTMLTable, basicLayout ) {
eeDelete( sceneNode );
Engine::destroySingleton();
}
UTEST( UIRichText, WhitespaceCollapseBRTest ) {
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 );
UI::UISceneNode* sceneNode = UI::UISceneNode::New();
UI::UIThemeManager* themeManager = sceneNode->getUIThemeManager();
themeManager->setDefaultFont( font );
String xml = R"xml(
<h1 align="center" id="rt">
<img src="icon" /><br/>
ecode
</h1>
)xml";
sceneNode->loadLayoutFromString( xml );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "rt" );
ASSERT_TRUE( rt != nullptr );
sceneNode->update( Time::Zero );
// The "ecode" text span should NOT have a leading space.
bool foundEcodeWithLeadingSpace = false;
auto checkSpansRecursive = [&]( Node* n, auto&& checkSpansRecursiveRef ) -> void {
if ( !n ) return;
if ( n->isWidget() && n->isType( UI_TYPE_TEXTSPAN ) ) {
UI::UITextSpan* span = static_cast<UI::UITextSpan*>( n );
if ( span->getText().size() > 0 && span->getText()[0] == ' ' &&
span->getText().find( "ecode" ) != String::InvalidPos ) {
foundEcodeWithLeadingSpace = true;
}
}
for ( Node* child = n->getFirstChild(); child; child = child->getNextNode() ) {
checkSpansRecursiveRef( child, checkSpansRecursiveRef );
}
};
checkSpansRecursive( rt, checkSpansRecursive );
EXPECT_FALSE( foundEcodeWithLeadingSpace );
eeDelete( sceneNode );
Engine::destroySingleton();
}