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
+
+
+
+ )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();
+}