From d45ffbfe237ebde705b646df33e66c59b537420a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sun, 8 Mar 2026 21:51:30 -0300 Subject: [PATCH] Add basic HTML tables support, this allows us to display tables in Markdown files. --- .agent/rules/build-project.md | 6 +- include/eepp/ui/uihelper.hpp | 6 + include/eepp/ui/uihtmltable.hpp | 82 ++++++++ src/eepp/ui/tools/htmlformatter.cpp | 86 ++++++++- src/eepp/ui/uihtmltable.cpp | 283 ++++++++++++++++++++++++++++ src/eepp/ui/uiwidgetcreator.cpp | 13 +- src/tests/unit_tests/richtext.cpp | 98 ++++++++++ 7 files changed, 568 insertions(+), 6 deletions(-) create mode 100644 include/eepp/ui/uihtmltable.hpp create mode 100644 src/eepp/ui/uihtmltable.cpp diff --git a/.agent/rules/build-project.md b/.agent/rules/build-project.md index bc2aae594..1cb266a7d 100644 --- a/.agent/rules/build-project.md +++ b/.agent/rules/build-project.md @@ -5,14 +5,14 @@ All build commands must be executed from the **root project directory**. Follow ## Step 1: Update Makefiles (Conditional) If you have **added, renamed, or deleted** any source files, you must regenerate the makefiles before compiling. -* **Tool:** Use `premake5` if installed; otherwise, fallback to `premake4` (the parameters are identical). +* **Tool:** Use `premake4` if installed; otherwise, fallback to `premake5` (the parameters are identical). * **Linker Flag (`--with-mold-linker`):** This flag is conditional. If the `mold` linker is installed on the system, you **must** include it to speed up linking. If `mold` is not installed, omit the flag. **Command (if `mold` is installed):** -`premake5 --disable-static-build --with-mold-linker --with-debug-symbols --address-sanitizer gmake` +`premake4 --disable-static-build --with-mold-linker --with-debug-symbols --address-sanitizer gmake` **Command (if `mold` is NOT installed):** -`premake5 --disable-static-build --with-debug-symbols --address-sanitizer gmake` +`premake4 --disable-static-build --with-debug-symbols --address-sanitizer gmake` *(If no files were added/removed, you may skip Step 1).* diff --git a/include/eepp/ui/uihelper.hpp b/include/eepp/ui/uihelper.hpp index dd5646c68..7e87df911 100644 --- a/include/eepp/ui/uihelper.hpp +++ b/include/eepp/ui/uihelper.hpp @@ -111,6 +111,12 @@ enum UINodeType { UI_TYPE_TEXTSPAN, UI_TYPE_RICHTEXT, UI_TYPE_MARKDOWNVIEW, + UI_TYPE_HTML_TABLE, + UI_TYPE_HTML_TABLE_HEAD, + UI_TYPE_HTML_TABLE_BODY, + UI_TYPE_HTML_TABLE_FOOTER, + UI_TYPE_HTML_TABLE_ROW, + UI_TYPE_HTML_TABLE_CELL, UI_TYPE_MODULES = 10000, UI_TYPE_TERMINAL = 10001, UI_TYPE_USER = 200000, diff --git a/include/eepp/ui/uihtmltable.hpp b/include/eepp/ui/uihtmltable.hpp new file mode 100644 index 000000000..7a060eb4d --- /dev/null +++ b/include/eepp/ui/uihtmltable.hpp @@ -0,0 +1,82 @@ +#ifndef EE_UI_UIHTMLTABLE_HPP +#define EE_UI_UIHTMLTABLE_HPP + +#include +#include + +namespace EE { namespace UI { + +class EE_API UIHTMLTable : public UILayout { + public: + static UIHTMLTable* New(); + + UIHTMLTable(); + + virtual Uint32 getType() const; + + virtual bool isType( const Uint32& type ) const; + + virtual void updateLayout(); + + protected: + virtual Uint32 onMessage( const NodeMessage* Msg ); +}; + +class EE_API UIHTMLTableCell : public UIRichText { + public: + static UIHTMLTableCell* New( const std::string& tag ); + + explicit UIHTMLTableCell( const std::string& tag ); + + virtual Uint32 getType() const; + + virtual bool isType( const Uint32& type ) const; +}; + +class EE_API UIHTMLTableRow : public UIWidget { + public: + static UIHTMLTableRow* New(); + + UIHTMLTableRow(); + + virtual Uint32 getType() const; + + virtual bool isType( const Uint32& type ) const; +}; + +class EE_API UIHTMLTableHead : public UIWidget { + public: + static UIHTMLTableHead* New(); + + explicit UIHTMLTableHead(); + + virtual Uint32 getType() const; + + virtual bool isType( const Uint32& type ) const; +}; + +class EE_API UIHTMLTableFooter : public UIWidget { + public: + static UIHTMLTableFooter* New(); + + explicit UIHTMLTableFooter(); + + virtual Uint32 getType() const; + + virtual bool isType( const Uint32& type ) const; +}; + +class EE_API UIHTMLTableBody : public UIWidget { + public: + static UIHTMLTableBody* New(); + + explicit UIHTMLTableBody(); + + virtual Uint32 getType() const; + + virtual bool isType( const Uint32& type ) const; +}; + +}} // namespace EE::UI + +#endif diff --git a/src/eepp/ui/tools/htmlformatter.cpp b/src/eepp/ui/tools/htmlformatter.cpp index 9457f8d0a..1e2738dd3 100644 --- a/src/eepp/ui/tools/htmlformatter.cpp +++ b/src/eepp/ui/tools/htmlformatter.cpp @@ -7,6 +7,25 @@ namespace EE { namespace UI { namespace Tools { +// In HTML, whitespace processing depends heavily on whether elements are block-level +// or inline-level. The HTML specification states that sequences of whitespace +// (spaces, tabs, newlines) inside inline formatting contexts are collapsed into a +// single space, but leading and trailing spaces are removed entirely if they adjoin +// a block boundary (e.g. at the start or end of a `

` or `

`). +// +// For example: +//

+// +// +// +//

+// In this snippet, the spaces and newlines between `

` and `` are completely +// dropped because they touch the block boundary of `

`. The spaces between `` and +// `` are inside an inline context, but because `` and `` are inline, they +// might normally produce a space, except leading/trailing rules can apply depending on +// significant text content. To properly emulate HTML's visual rendering, we must +// identify whether a node acts as an "inline" element. + bool HTMLFormatter::isInlineNode( const pugi::xml_node& node ) { if ( !node ) return false; @@ -14,6 +33,8 @@ bool HTMLFormatter::isInlineNode( const pugi::xml_node& node ) { return true; if ( node.type() != pugi::node_element ) return false; + + // Compare element tags against known HTML inline elements and our internal equivalents. std::string_view name( node.name() ); return String::iequals( name, "a" ) || String::iequals( name, "span" ) || String::iequals( name, "textspan" ) || String::iequals( name, "b" ) || @@ -24,9 +45,22 @@ bool HTMLFormatter::isInlineNode( const pugi::xml_node& node ) { String::iequals( name, "mark" ); } +// "Significant text" in the context of HTML whitespace collapsing means any text +// that is not entirely composed of whitespace characters, or elements that have a +// visual inline presence like images () or line breaks (
). +// Empty inline elements (e.g. ``) or those containing only whitespace +// are often ignored when evaluating boundaries for whitespace trimming. +// +// This function allows us to peer inside nodes or text blocks to see if they actually +// contain anything that visually anchors a whitespace sequence. If a node lacks +// significant text, the whitespace logic can skip over it to find the true logical +// boundary. + bool HTMLFormatter::hasSignificantText( const pugi::xml_node& node ) { if ( !node ) return false; + + // For plain text, check if there's any non-whitespace character. if ( node.type() == pugi::node_pcdata ) { std::string_view v( node.value() ); for ( char c : v ) { @@ -35,6 +69,9 @@ bool HTMLFormatter::hasSignificantText( const pugi::xml_node& node ) { } return false; } + + // For inline elements, certain tags are inherently significant (img, br). + // Otherwise, we recursively check their children. if ( isInlineNode( node ) ) { std::string_view name( node.name() ); if ( String::iequals( name, "img" ) || String::iequals( name, "br" ) ) @@ -45,18 +82,35 @@ bool HTMLFormatter::hasSignificantText( const pugi::xml_node& node ) { } return false; } - return true; // Block nodes are significant boundaries + + // Block nodes inherently form a significant boundary. We don't look inside them + // because a block node interrupts the inline formatting context entirely. + return true; } +// In HTML, elements can be nested arbitrarily, meaning the "previous" inline node +// visually preceding a text block might not be its direct sibling in the DOM tree. +// For instance, in `text `, the space is technically a sibling +// of ``, but logically it follows `text`. +// +// `getLogicalPrev` traverses the DOM tree backward, diving into the rightmost children +// of previous siblings, or walking up to the parent, as long as the traversed nodes +// remain within the inline formatting context. This effectively finds the closest +// visual element to the left of the current node. + pugi::xml_node HTMLFormatter::getLogicalPrev( const pugi::xml_node& node ) { pugi::xml_node p = node; while ( p ) { + // If there is a previous sibling, we move to it and then drill down + // to its last (rightmost) inline child, simulating visual left-to-right flow. if ( p.previous_sibling() ) { p = p.previous_sibling(); while ( p.last_child() && isInlineNode( p ) ) p = p.last_child(); return p; } + // If there are no previous siblings, we move up to the parent. + // If the parent is a block element, we've hit a block boundary and stop. p = p.parent(); if ( !isInlineNode( p ) ) break; @@ -64,15 +118,22 @@ pugi::xml_node HTMLFormatter::getLogicalPrev( const pugi::xml_node& node ) { return pugi::xml_node(); } +// `getLogicalNext` is the counterpart to `getLogicalPrev`. It traverses the DOM tree +// forward to find the closest visual element to the right of the current node. +// It drills into the leftmost children of next siblings, or walks up the parent tree +// to continue the forward search, bounded by block elements. + pugi::xml_node HTMLFormatter::getLogicalNext( const pugi::xml_node& node ) { pugi::xml_node p = node; while ( p ) { + // Move to the next sibling and drill down to its first (leftmost) inline child. if ( p.next_sibling() ) { p = p.next_sibling(); while ( p.first_child() && isInlineNode( p ) ) p = p.first_child(); return p; } + // Move up to the parent, stopping at block boundaries. p = p.parent(); if ( !isInlineNode( p ) ) break; @@ -80,10 +141,27 @@ pugi::xml_node HTMLFormatter::getLogicalNext( const pugi::xml_node& node ) { return pugi::xml_node(); } +// This function implements HTML-compliant whitespace collapsing for a given text node. +// +// HTML rules dictate that: +// 1. Any contiguous sequence of whitespace characters (spaces, tabs, newlines) +// is collapsed into a single space character (' '). +// 2. If this text node logically adjoins a block element (e.g. it is the first or last +// thing inside a `

`), the leading or trailing space is completely removed. +// 3. To accurately determine boundaries, we must skip over "empty" text nodes or +// elements that lack significant visual content. +// +// This function first collapses all whitespace into single spaces. Then it looks both +// logically backward and forward using `getLogicalPrev` and `getLogicalNext`. If it +// determines that there is no valid inline node on a given side (meaning it has hit +// a block boundary), it strips the space on that side. + String HTMLFormatter::collapseXmlWhitespace( const String& text, const pugi::xml_node& node ) { String res; res.reserve( text.size() ); bool inSpace = false; + + // Step 1: Collapse all contiguous whitespace characters into a single space. for ( size_t i = 0; i < text.size(); ++i ) { if ( text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r' || text[i] == '\v' ) { @@ -97,18 +175,24 @@ String HTMLFormatter::collapseXmlWhitespace( const String& text, const pugi::xml } } + // Step 2: Determine if the left boundary is a block element. + // 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 ); + // Step 3: Determine if the right boundary is a block element. + // 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 ); + // Step 4: Trim leading and trailing spaces if they adjoin a block boundary. if ( !prevInline && !res.empty() && res[0] == ' ' ) res = res.substr( 1 ); diff --git a/src/eepp/ui/uihtmltable.cpp b/src/eepp/ui/uihtmltable.cpp new file mode 100644 index 000000000..8ed07bc40 --- /dev/null +++ b/src/eepp/ui/uihtmltable.cpp @@ -0,0 +1,283 @@ +#include +#include + +namespace EE { namespace UI { + +UIHTMLTable* UIHTMLTable::New() { + return eeNew( UIHTMLTable, () ); +} + +UIHTMLTable::UIHTMLTable() : UILayout( "table" ) { + mWidthPolicy = SizePolicy::MatchParent; + mHeightPolicy = SizePolicy::WrapContent; +} + +Uint32 UIHTMLTable::getType() const { + return UI_TYPE_HTML_TABLE; +} + +bool UIHTMLTable::isType( const Uint32& type ) const { + return UIHTMLTable::getType() == type || UILayout::isType( type ); +} + +void UIHTMLTable::updateLayout() { + if ( mPacking || !mVisible ) + return; + mPacking = true; + + // TODO: Optimize this horrendous implementation (fix the heap-allocation crazyness) + UIHTMLTableHead* head = nullptr; + UIHTMLTableBody* body = nullptr; + UIHTMLTableFooter* footer = nullptr; + + std::vector rows; + std::function collectRows = [&]( Node* node ) { + Node* child = node->getFirstChild(); + while ( child ) { + if ( child->getType() == UI_TYPE_HTML_TABLE_ROW ) { + rows.push_back( child->asType() ); + } else if ( child->getType() != UI_TYPE_HTML_TABLE ) { + if ( child->getType() == UI_TYPE_HTML_TABLE_HEAD ) + head = child->asType(); + else if ( child->getType() == UI_TYPE_HTML_TABLE_BODY ) + body = child->asType(); + else if ( child->getType() == UI_TYPE_HTML_TABLE_FOOTER ) + footer = child->asType(); + + collectRows( child ); + } + child = child->getNextNode(); + } + }; + collectRows( this ); + + if ( rows.empty() ) { + mPacking = false; + return; + } + + std::vector> grid; + size_t maxCols = 0; + for ( auto* row : rows ) { + std::vector cells; + Node* child = row->getFirstChild(); + while ( child ) { + if ( child->getType() == UI_TYPE_HTML_TABLE_CELL ) + cells.push_back( child->asType() ); + child = child->getNextNode(); + } + grid.push_back( cells ); + maxCols = std::max( maxCols, cells.size() ); + } + + if ( maxCols == 0 ) { + mPacking = false; + return; + } + + std::vector colWidths( 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]; + cell->setLayoutWidthPolicy( SizePolicy::WrapContent ); + cell->updateLayout(); + colWidths[i] = std::max( colWidths[i], cell->getPixelsSize().getWidth() ); + } + } + + Float availableWidth = getPixelsSize().getWidth() - mPaddingPx.Left - mPaddingPx.Right; + Float totalUnwrappedWidth = 0; + for ( Float w : colWidths ) + totalUnwrappedWidth += w; + + if ( totalUnwrappedWidth > availableWidth && 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; + } + + Float headHeight = 0; + Float bodyHeight = 0; + Float footerHeight = 0; + + // Apply layout and calculate heights + size_t rowCount = grid.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]; + cell->setLayoutWidthPolicy( SizePolicy::Fixed ); + cell->setPixelsSize( colWidths[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]; + cell->setPixelsPosition( currentX, 0 ); + cell->setPixelsSize( cell->getPixelsSize().getWidth(), rowHeight ); + currentX += colWidths[c]; + } + + // Set row height and width + UIHTMLTableRow* row = rows[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 ) ) { + footerHeight = rowHeight; + } else { + bodyHeight += rowHeight; + } + } + + // 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. + if ( head ) { + head->setPixelsPosition( 0, 0 ); + head->setPixelsSize( { getPixelsSize().x, headHeight } ); + } + + if ( body ) { + body->setPixelsPosition( 0, headHeight ); + body->setPixelsSize( { getPixelsSize().x, bodyHeight } ); + } + + if ( footer ) { + footer->setPixelsPosition( 0, headHeight + bodyHeight ); + footer->setPixelsSize( { getPixelsSize().x, footerHeight } ); + } + + Float currentY = mPaddingPx.Top - headHeight; + for ( auto* row : rows ) { + 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 ); + + if ( mWidthPolicy == SizePolicy::MatchParent ) + setInternalPixelsWidth( getMatchParentWidth() ); + + if ( mHeightPolicy == SizePolicy::WrapContent ) { + setInternalPixelsHeight( headHeight + bodyHeight + footerHeight + mPaddingPx.Bottom ); + } else if ( mHeightPolicy == SizePolicy::MatchParent ) { + setInternalPixelsHeight( getMatchParentHeight() ); + } + + mPacking = false; + mDirtyLayout = false; +} + +Uint32 UIHTMLTable::onMessage( const NodeMessage* Msg ) { + switch ( Msg->getMsg() ) { + case NodeMessage::LayoutAttributeChange: { + tryUpdateLayout(); + return 1; + } + } + + return 0; +} + +UIHTMLTableRow* UIHTMLTableRow::New() { + return eeNew( UIHTMLTableRow, () ); +} + +UIHTMLTableRow::UIHTMLTableRow() : UIWidget( "tr" ) { + mWidthPolicy = SizePolicy::MatchParent; + mHeightPolicy = SizePolicy::WrapContent; +} + +Uint32 UIHTMLTableRow::getType() const { + return UI_TYPE_HTML_TABLE_ROW; +} + +bool UIHTMLTableRow::isType( const Uint32& type ) const { + return UIHTMLTableRow::getType() == type || UIWidget::isType( type ); +} + +UIHTMLTableCell* UIHTMLTableCell::New( const std::string& tag ) { + return eeNew( UIHTMLTableCell, ( tag ) ); +} + +UIHTMLTableCell::UIHTMLTableCell( const std::string& tag ) : UIRichText( tag ) { + mWidthPolicy = SizePolicy::WrapContent; + mHeightPolicy = SizePolicy::WrapContent; +} + +Uint32 UIHTMLTableCell::getType() const { + return UI_TYPE_HTML_TABLE_CELL; +} + +bool UIHTMLTableCell::isType( const Uint32& type ) const { + return UIHTMLTableCell::getType() == type || UIRichText::isType( type ); +} + +UIHTMLTableHead* UIHTMLTableHead::New() { + return eeNew( UIHTMLTableHead, () ); +} + +UIHTMLTableHead::UIHTMLTableHead() : UIWidget( "thead" ) { + mWidthPolicy = SizePolicy::MatchParent; + mHeightPolicy = SizePolicy::WrapContent; +} + +Uint32 UIHTMLTableHead::getType() const { + return UI_TYPE_HTML_TABLE_HEAD; +} + +bool UIHTMLTableHead::isType( const Uint32& type ) const { + return UIHTMLTableHead::getType() == type || UIWidget::isType( type ); +} + +UIHTMLTableBody* UIHTMLTableBody::New() { + return eeNew( UIHTMLTableBody, () ); +} + +UIHTMLTableBody::UIHTMLTableBody() : UIWidget( "tbody" ) { + mWidthPolicy = SizePolicy::MatchParent; + mHeightPolicy = SizePolicy::WrapContent; +} + +Uint32 UIHTMLTableBody::getType() const { + return UI_TYPE_HTML_TABLE_BODY; +} + +bool UIHTMLTableBody::isType( const Uint32& type ) const { + return UIHTMLTableBody::getType() == type || UIWidget::isType( type ); +} + +UIHTMLTableFooter* UIHTMLTableFooter::New() { + return eeNew( UIHTMLTableFooter, () ); +} + +UIHTMLTableFooter::UIHTMLTableFooter() : UIWidget( "tfoot" ) { + mWidthPolicy = SizePolicy::MatchParent; + mHeightPolicy = SizePolicy::WrapContent; +} + +Uint32 UIHTMLTableFooter::getType() const { + return UI_TYPE_HTML_TABLE_FOOTER; +} + +bool UIHTMLTableFooter::isType( const Uint32& type ) const { + return UIHTMLTableFooter::getType() == type || UIWidget::isType( type ); +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/uiwidgetcreator.cpp b/src/eepp/ui/uiwidgetcreator.cpp index a31abf80b..4fa2d332a 100644 --- a/src/eepp/ui/uiwidgetcreator.cpp +++ b/src/eepp/ui/uiwidgetcreator.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -43,7 +44,6 @@ #include #include #include -#include #include namespace EE { namespace UI { @@ -157,13 +157,22 @@ void UIWidgetCreator::createBaseWidgetList() { registeredWidget["pre"] = UIRichText::NewPre; registeredWidget["img"] = [] { return UIImage::NewWithTag( "img" ); }; registeredWidget["input"] = UITextInput::New; - + registeredWidget["center"] = [] { + return UILinearLayout::NewVerticalWidthMatchParent( "center" ); + }; registeredWidget["html"] = [] { return UILinearLayout::NewVerticalWidthMatchParent( "html" ); }; registeredWidget["body"] = [] { return UILinearLayout::NewVerticalWidthMatchParent( "body" ); }; + registeredWidget["table"] = UIHTMLTable::New; + registeredWidget["tr"] = UIHTMLTableRow::New; + registeredWidget["thead"] = UIHTMLTableHead::New; + registeredWidget["tbody"] = UIHTMLTableBody::New; + registeredWidget["tfoot"] = UIHTMLTableFooter::New; + registeredWidget["th"] = [] { return UIHTMLTableCell::New( "th" ); }; + registeredWidget["td"] = [] { return UIHTMLTableCell::New( "td" ); }; sBaseListCreated = true; } diff --git a/src/tests/unit_tests/richtext.cpp b/src/tests/unit_tests/richtext.cpp index 61ed66513..5bed370e1 100644 --- a/src/tests/unit_tests/richtext.cpp +++ b/src/tests/unit_tests/richtext.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -738,3 +739,100 @@ UTEST( UIRichText, WhitespaceCollapseCodeTest ) { eeDelete( sceneNode ); Engine::destroySingleton(); } + +UTEST( UIHTMLTable, basicLayout ) { + Engine::instance()->createWindow( WindowSettings( 800, 600, "HTML Table 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 != nullptr && font->loaded() ); + FontFamily::loadFromRegular( font ); + + UI::UISceneNode* sceneNode = UI::UISceneNode::New(); + UI::UIThemeManager* themeManager = sceneNode->getUIThemeManager(); + themeManager->setDefaultFont( font ); + + String xml = R"xml( + + + + + + + + + + + + + + + + + +
Header 1Header 2
Row 1 Col 1Row 1 Col 2
Row 2 Col 1 which is very long and should cause wrapping if the table is narrow enoughRow 2 Col 2
+ )xml"; + + sceneNode->loadLayoutFromString( xml ); + + UI::UIHTMLTable* table = sceneNode->find( "table" ); + ASSERT_TRUE( table != nullptr ); + + // Force layout + sceneNode->update( Time::Zero ); + + // Check that we have rows and cells + int rowCount = 0; + std::function countRows = [&]( Node* node ) { + Node* child = node->getFirstChild(); + while ( child ) { + if ( child->isWidget() ) { + UIWidget* widget = static_cast( child ); + if ( widget->getType() == UI_TYPE_HTML_TABLE_ROW ) { + rowCount++; + } else if ( widget->getType() != UI_TYPE_HTML_TABLE ) { + countRows( widget ); + } + } + child = child->getNextNode(); + } + }; + countRows( table ); + EXPECT_EQ( rowCount, 3 ); + + // Verify that the table has a height greater than zero + EXPECT_GT( table->getPixelsSize().getHeight(), 0 ); + + // Check column synchronization + std::vector rows; + std::function collectRows = [&]( Node* node ) { + Node* child = node->getFirstChild(); + while ( child ) { + if ( child->isWidget() ) { + UIWidget* widget = static_cast( child ); + if ( widget->getType() == UI_TYPE_HTML_TABLE_ROW ) { + rows.push_back( static_cast( widget ) ); + } else if ( widget->getType() != UI_TYPE_HTML_TABLE ) { + collectRows( widget ); + } + } + child = child->getNextNode(); + } + }; + collectRows( table ); + + if ( rows.size() >= 2 ) { + Node* cell00 = rows[0]->getFirstChild(); + Node* cell10 = rows[1]->getFirstChild(); + if ( cell00 && cell10 && cell00->isWidget() && cell10->isWidget() ) { + EXPECT_EQ( cell00->asType()->getPixelsPosition().x, + cell10->asType()->getPixelsPosition().x ); + } + } + + eeDelete( sceneNode ); + Engine::destroySingleton(); +}