From e8f4c9c8f7fe0c9b41e2eab5c0bcd3242cec3a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sun, 8 Mar 2026 16:00:36 -0300 Subject: [PATCH] More improvements in Markdown and basic HTML support (WIP!). --- bin/assets/ui/breeze.css | 6 +- include/eepp/ui/uilinearlayout.hpp | 2 +- include/eepp/ui/uiscenenode.hpp | 24 ++-- include/eepp/ui/uiwidget.hpp | 17 +-- src/eepp/ui/doc/markdownhelper.cpp | 1 + src/eepp/ui/uilinearlayout.cpp | 4 +- src/eepp/ui/uimarkdownview.cpp | 2 +- src/eepp/ui/uirichtext.cpp | 47 +++++++- src/eepp/ui/uiscenenode.cpp | 47 +++++++- src/eepp/ui/uiwidgetcreator.cpp | 12 +- .../ui_markdownview/ui_markdownview.cpp | 4 + src/tests/unit_tests/richtext.cpp | 103 ++++++++++++++++++ 12 files changed, 230 insertions(+), 39 deletions(-) diff --git a/bin/assets/ui/breeze.css b/bin/assets/ui/breeze.css index 6e7977b6e..7d403448d 100644 --- a/bin/assets/ui/breeze.css +++ b/bin/assets/ui/breeze.css @@ -111,10 +111,14 @@ p, ol, ul, pre { margin: 1em 0; } +ol, ul { + margin-left: 2em; +} + li { margin: 0.67em 0; padding-left: 2em; - background-image: url("data:image/svg+xml;utf8,"); + background-image: url("data:image/svg,"); background-tint: var(--font); background-position: 0.6em 0.5em; background-size: 0.8em 0.8em; diff --git a/include/eepp/ui/uilinearlayout.hpp b/include/eepp/ui/uilinearlayout.hpp index 0e803d1d6..7d9956763 100644 --- a/include/eepp/ui/uilinearlayout.hpp +++ b/include/eepp/ui/uilinearlayout.hpp @@ -15,7 +15,7 @@ class EE_API UILinearLayout : public UILayout { static UILinearLayout* NewHorizontal(); - static UILinearLayout* NewVerticalWidthMatchParent(); + static UILinearLayout* NewVerticalWidthMatchParent( const std::string& tag ); virtual Uint32 getType() const; diff --git a/include/eepp/ui/uiscenenode.hpp b/include/eepp/ui/uiscenenode.hpp index 2294e3993..cc2c42f1f 100644 --- a/include/eepp/ui/uiscenenode.hpp +++ b/include/eepp/ui/uiscenenode.hpp @@ -671,6 +671,18 @@ class EE_API UISceneNode : public SceneNode { */ CSS::MediaFeatures getMediaFeatures() const; + /** + * @brief Loads UI nodes from XML. + * + * Core method that parses XML and creates widget hierarchy. + * + * @param node The XML node to parse. + * @param parent The parent to attach widgets to. + * @param marker Marker for style association. + * @return Vector of root widgets created. + */ + std::vector loadNode( pugi::xml_node node, Node* parent, const Uint32& marker = 0 ); + protected: friend class EE::UI::UIWindow; friend class EE::UI::UIWidget; @@ -885,18 +897,6 @@ class EE_API UISceneNode : public SceneNode { */ void resetTooltips( Node* node ); - /** - * @brief Loads UI nodes from XML. - * - * Core method that parses XML and creates widget hierarchy. - * - * @param node The XML node to parse. - * @param parent The parent to attach widgets to. - * @param marker Marker for style association. - * @return Vector of root widgets created. - */ - std::vector loadNode( pugi::xml_node node, Node* parent, const Uint32& marker ); - /** * @brief Applies a theme to a node and its subtree. * diff --git a/include/eepp/ui/uiwidget.hpp b/include/eepp/ui/uiwidget.hpp index d5e96f86e..a4fea9d40 100644 --- a/include/eepp/ui/uiwidget.hpp +++ b/include/eepp/ui/uiwidget.hpp @@ -1257,6 +1257,15 @@ class EE_API UIWidget : public UINode { */ String i18n( const std::string& str, const String& defaultValue ); + /** + * @brief Handles widget creation events. + * + * Called after the widget is created and initialized. This can be overridden + * to implement custom initialization behavior. + * WARNING: Do not manually call. + */ + virtual void onWidgetCreated(); + protected: friend class UIManager; friend class UISceneNode; @@ -1418,14 +1427,6 @@ class EE_API UIWidget : public UINode { */ virtual void onAutoSize(); - /** - * @brief Handles widget creation events. - * - * Called after the widget is created and initialized. This can be overridden - * to implement custom initialization behavior. - */ - virtual void onWidgetCreated(); - /** * @brief Handles padding change events. * diff --git a/src/eepp/ui/doc/markdownhelper.cpp b/src/eepp/ui/doc/markdownhelper.cpp index 7d4e8b57c..91c6fe759 100644 --- a/src/eepp/ui/doc/markdownhelper.cpp +++ b/src/eepp/ui/doc/markdownhelper.cpp @@ -14,6 +14,7 @@ std::string Markdown::toXHTML( std::string_view markdown, Dialect dialect, int f ( dialect == Dialect::CommonMark ? MD_DIALECT_COMMONMARK : MD_DIALECT_GITHUB ) | flags; md_html( markdown.data(), markdown.size(), process_output, &out, dialectFlag, MD_HTML_FLAG_XHTML ); + return out; } diff --git a/src/eepp/ui/uilinearlayout.cpp b/src/eepp/ui/uilinearlayout.cpp index b17ea7885..a043103fa 100644 --- a/src/eepp/ui/uilinearlayout.cpp +++ b/src/eepp/ui/uilinearlayout.cpp @@ -21,8 +21,8 @@ UILinearLayout* UILinearLayout::NewHorizontal() { return ( eeNew( UILinearLayout, () ) )->setOrientation( UIOrientation::Horizontal ); } -UILinearLayout* UILinearLayout::NewVerticalWidthMatchParent() { - return ( eeNew( UILinearLayout, () ) ) +UILinearLayout* UILinearLayout::NewVerticalWidthMatchParent( const std::string& tag ) { + return ( eeNew( UILinearLayout, ( tag, UIOrientation::Vertical ) ) ) ->setLayoutWidthPolicy( SizePolicy::MatchParent ) ->asType(); } diff --git a/src/eepp/ui/uimarkdownview.cpp b/src/eepp/ui/uimarkdownview.cpp index b083d5e08..ff2d528e5 100644 --- a/src/eepp/ui/uimarkdownview.cpp +++ b/src/eepp/ui/uimarkdownview.cpp @@ -29,7 +29,7 @@ bool UIMarkdownView::isType( const Uint32& type ) const { void UIMarkdownView::loadFromString( std::string_view markdown ) { closeAllChildren(); auto xhtml = Markdown::toXHTML( markdown ); - // printf( "%s", xhtml.c_str() ); + printf( "%s", xhtml.c_str() ); getUISceneNode()->loadLayoutFromString( xhtml, this ); } diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index 6005ec611..2577d0741 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -344,7 +344,24 @@ void UIRichText::loadFromXmlNode( const pugi::xml_node& node ) { UIWidget::loadFromXmlNode( node ); - auto collapseXmlWhitespace = []( const String& text ) -> String { + auto collapseXmlWhitespace = []( const String& text, const pugi::xml_node& node ) -> String { + auto isInlineNode = []( const pugi::xml_node& node ) { + if ( !node ) + return false; + if ( node.type() == pugi::node_pcdata ) + return true; + if ( node.type() != pugi::node_element ) + return false; + std::string_view name( node.name() ); + return String::iequals( name, "a" ) || String::iequals( name, "span" ) || + String::iequals( name, "textspan" ) || String::iequals( name, "b" ) || + String::iequals( name, "i" ) || String::iequals( name, "strong" ) || + String::iequals( name, "em" ) || String::iequals( name, "s" ) || + String::iequals( name, "u" ) || String::iequals( name, "br" ) || + String::iequals( name, "code" ) || String::iequals( name, "img" ) || + String::iequals( name, "mark" ); + }; + String res; res.reserve( text.size() ); bool inSpace = false; @@ -360,6 +377,16 @@ void UIRichText::loadFromXmlNode( const pugi::xml_node& node ) { inSpace = false; } } + + bool prevInline = isInlineNode( node.previous_sibling() ); + bool nextInline = isInlineNode( node.next_sibling() ); + + if ( !prevInline && !res.empty() && res[0] == ' ' ) + res = res.substr( 1 ); + + if ( !nextInline && !res.empty() && res.back() == ' ' ) + res = res.substr( 0, res.size() - 1 ); + return res; }; @@ -390,14 +417,22 @@ void UIRichText::loadFromXmlNode( const pugi::xml_node& node ) { } } else { // Let parent logic load standard child widget - UIWidget* widget = UIWidgetCreator::createFromName( child.name() ); - if ( widget ) { - widget->setParent( this ); - widget->loadFromXmlNode( child ); + UIWidget* uiwidget = UIWidgetCreator::createFromName( child.name() ); + if ( uiwidget ) { + uiwidget->setParent( this ); + uiwidget->loadFromXmlNode( child ); + + if ( !uiwidget->loadsItsChildren() ) { + if ( child.first_child() && !uiwidget->loadsItsChildren() ) { + getUISceneNode()->loadNode( child.first_child(), uiwidget, 0 ); + } + } + + uiwidget->onWidgetCreated(); } } } else if ( child.type() == pugi::node_pcdata ) { - String text = collapseXmlWhitespace( getTranslatorString( child.value() ) ); + String text = collapseXmlWhitespace( child.value(), child ); if ( !text.empty() ) { UITextSpan* span = UITextSpan::New(); span->setParent( this ); diff --git a/src/eepp/ui/uiscenenode.cpp b/src/eepp/ui/uiscenenode.cpp index ec1c35fb3..64619232a 100644 --- a/src/eepp/ui/uiscenenode.cpp +++ b/src/eepp/ui/uiscenenode.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -430,7 +431,8 @@ UIWidget* UISceneNode::loadLayoutFromFile( const std::string& layoutPath, Node* const Uint32& marker ) { if ( FileSystem::fileExists( layoutPath ) ) { pugi::xml_document doc; - pugi::xml_parse_result result = doc.load_file( layoutPath.c_str() ); + pugi::xml_parse_result result = + doc.load_file( layoutPath.c_str(), pugi::parse_default | pugi::parse_ws_pcdata ); if ( result ) { return loadLayoutNodes( doc.first_child(), NULL != parent ? parent : this, marker ); @@ -453,8 +455,19 @@ UIWidget* UISceneNode::loadLayoutFromFile( const std::string& layoutPath, Node* UIWidget* UISceneNode::loadLayoutFromString( const char* layoutString, Node* parent, const Uint32& marker ) { + thread_local static EE::System::RegEx voidTagsRegex( + "(<(?:img|br|hr|input|meta|link)\\b[^>]*?)(?" ); + pugi::xml_document doc; - pugi::xml_parse_result result = doc.load_string( layoutString ); + pugi::xml_parse_result result; + + if ( voidTagsRegex.matches( layoutString ) ) { + std::string fixedLayout = voidTagsRegex.gsub( layoutString, "%1 />" ); + result = + doc.load_string( fixedLayout.c_str(), pugi::parse_default | pugi::parse_ws_pcdata ); + } else { + result = doc.load_string( layoutString, pugi::parse_default | pugi::parse_ws_pcdata ); + } if ( result ) { return loadLayoutNodes( doc.first_child(), NULL != parent ? parent : this, marker ); @@ -474,8 +487,20 @@ UIWidget* UISceneNode::loadLayoutFromString( const std::string& layoutString, No UIWidget* UISceneNode::loadLayoutFromMemory( const void* buffer, Int32 bufferSize, Node* parent, const Uint32& marker ) { + thread_local static EE::System::RegEx voidTagsRegex( + "(<(?:img|br|hr|input|meta|link)\\b[^>]*?)(?" ); + pugi::xml_document doc; - pugi::xml_parse_result result = doc.load_buffer( buffer, bufferSize ); + pugi::xml_parse_result result; + + if ( voidTagsRegex.matches( static_cast( buffer ), 0, nullptr, bufferSize ) ) { + std::string strBuffer( static_cast( buffer ), bufferSize ); + std::string fixedLayout = voidTagsRegex.gsub( strBuffer, "%1 />" ); + result = doc.load_buffer( fixedLayout.c_str(), fixedLayout.size(), + pugi::parse_default | pugi::parse_ws_pcdata ); + } else { + result = doc.load_buffer( buffer, bufferSize, pugi::parse_default | pugi::parse_ws_pcdata ); + } if ( result ) { return loadLayoutNodes( doc.first_child(), NULL != parent ? parent : this, marker ); @@ -497,12 +522,26 @@ UIWidget* UISceneNode::loadLayoutFromStream( IOStream& stream, Node* parent, TScopedBuffer scopedBuffer( bufferSize ); stream.read( scopedBuffer.get(), scopedBuffer.length() ); + thread_local static EE::System::RegEx voidTagsRegex( + "(<(?:img|br|hr|input|meta|link)\\b[^>]*?)(?" ); + pugi::xml_document doc; - pugi::xml_parse_result result = doc.load_buffer( scopedBuffer.get(), scopedBuffer.length() ); + pugi::xml_parse_result result; + + if ( voidTagsRegex.matches( scopedBuffer.get(), 0, nullptr, scopedBuffer.length() ) ) { + std::string strBuffer( scopedBuffer.get(), scopedBuffer.length() ); + std::string fixedLayout = voidTagsRegex.gsub( strBuffer, "%1 />" ); + result = doc.load_buffer( fixedLayout.c_str(), fixedLayout.size(), + pugi::parse_default | pugi::parse_ws_pcdata ); + } else { + result = doc.load_buffer( scopedBuffer.get(), scopedBuffer.length(), + pugi::parse_default | pugi::parse_ws_pcdata ); + } if ( result ) { return loadLayoutNodes( doc.first_child(), NULL != parent ? parent : this, marker ); } else { + // Preserves the unique stream error log Log::error( "Couldn't load UI Layout from stream" ); Log::error( "Error description: %s", result.description() ); Log::error( "Error offset: %d", result.offset ); diff --git a/src/eepp/ui/uiwidgetcreator.cpp b/src/eepp/ui/uiwidgetcreator.cpp index 360515c19..a31abf80b 100644 --- a/src/eepp/ui/uiwidgetcreator.cpp +++ b/src/eepp/ui/uiwidgetcreator.cpp @@ -151,15 +151,19 @@ void UIWidgetCreator::createBaseWidgetList() { registeredWidget["h5"] = UIRichText::NewH5; registeredWidget["h6"] = UIRichText::NewH6; registeredWidget["br"] = UIRichText::NewBr; - registeredWidget["ul"] = UILinearLayout::NewVerticalWidthMatchParent; - registeredWidget["ol"] = UILinearLayout::NewVerticalWidthMatchParent; + registeredWidget["ul"] = [] { return UILinearLayout::NewVerticalWidthMatchParent( "ul" ); }; + registeredWidget["ol"] = [] { return UILinearLayout::NewVerticalWidthMatchParent( "ol" ); }; registeredWidget["li"] = UIRichText::NewListItem; registeredWidget["pre"] = UIRichText::NewPre; registeredWidget["img"] = [] { return UIImage::NewWithTag( "img" ); }; registeredWidget["input"] = UITextInput::New; - registeredWidget["html"] = UILinearLayout::NewVerticalWidthMatchParent; - registeredWidget["body"] = UILinearLayout::NewVerticalWidthMatchParent; + registeredWidget["html"] = [] { + return UILinearLayout::NewVerticalWidthMatchParent( "html" ); + }; + registeredWidget["body"] = [] { + return UILinearLayout::NewVerticalWidthMatchParent( "body" ); + }; sBaseListCreated = true; } diff --git a/src/examples/ui_markdownview/ui_markdownview.cpp b/src/examples/ui_markdownview/ui_markdownview.cpp index 6b4195c5d..0fa4e48f0 100644 --- a/src/examples/ui_markdownview/ui_markdownview.cpp +++ b/src/examples/ui_markdownview/ui_markdownview.cpp @@ -4,6 +4,10 @@ EE_MAIN_FUNC int main( int, char** ) { UIApplication app( { 1280, 720, "eepp - UIMarkdownView Example" } ); + Log::instance()->setLogLevelThreshold( LogLevel::Debug ); + Log::instance()->setLogToStdOut( true ); + Log::instance()->setLiveWrite( true ); + app.getUI()->loadLayoutFromString( R"xml( diff --git a/src/tests/unit_tests/richtext.cpp b/src/tests/unit_tests/richtext.cpp index 46fb5524c..61ed66513 100644 --- a/src/tests/unit_tests/richtext.cpp +++ b/src/tests/unit_tests/richtext.cpp @@ -635,3 +635,106 @@ UTEST( UIRichText, UIAnchorTest ) { eeDelete( sceneNode ); Engine::destroySingleton(); } + +UTEST( UIRichText, WhitespaceCollapseTest ) { + 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( + + Hello +
    +
  • Item
  • +
+ +
+ )xml"; + + sceneNode->loadLayoutFromString( xml ); + + UI::UIRichText* rt = sceneNode->find( "rt" ); + ASSERT_TRUE( rt != nullptr ); + + // force layout + sceneNode->update( Time::Zero ); + + int spanCount = 0; + Node* child = rt->getFirstChild(); + while ( child ) { + if ( child->isWidget() && child->isType( UI_TYPE_TEXTSPAN ) ) { + UI::UITextSpan* span = static_cast( child ); + if ( !span->getText().empty() ) { + spanCount++; + } + } + child = child->getNextNode(); + } + + // Only 1 text span ("Hello") should be generated, + // the whitespace between and
    , and after
      + // should be correctly collapsed into nothing since they aren't adjacent to inline elements on both sides. + EXPECT_EQ( spanCount, 1 ); + + eeDelete( sceneNode ); + Engine::destroySingleton(); +} + +UTEST( UIRichText, WhitespaceCollapseCodeTest ) { + 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( + + Hello World. HI in monospace! + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + + UI::UIRichText* rt = sceneNode->find( "rt" ); + ASSERT_TRUE( rt != nullptr ); + + // force layout + sceneNode->update( Time::Zero ); + + bool foundDotSpace = false; + Node* child = rt->getFirstChild(); + while ( child ) { + if ( child->isWidget() && child->isType( UI_TYPE_TEXTSPAN ) ) { + UI::UITextSpan* span = static_cast( child ); + if ( span->getText() == ". " ) { + foundDotSpace = true; + } + } + child = child->getNextNode(); + } + + EXPECT_TRUE( foundDotSpace ); + + eeDelete( sceneNode ); + Engine::destroySingleton(); +}