More improvements in Markdown and basic HTML support (WIP!).

This commit is contained in:
Martín Lucas Golini
2026-03-08 16:00:36 -03:00
parent d56ee554f4
commit e8f4c9c8f7
12 changed files with 230 additions and 39 deletions
+5 -1
View File
@@ -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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='40' fill='currentColor'/></svg>");
background-image: url("data:image/svg,<svg viewBox='0 0 24 24' width='12' height='12' fill='#ffffff'><path d='M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z'></path></svg>");
background-tint: var(--font);
background-position: 0.6em 0.5em;
background-size: 0.8em 0.8em;
+1 -1
View File
@@ -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;
+12 -12
View File
@@ -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<UIWidget*> 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<UIWidget*> loadNode( pugi::xml_node node, Node* parent, const Uint32& marker );
/**
* @brief Applies a theme to a node and its subtree.
*
+9 -8
View File
@@ -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.
*
+1
View File
@@ -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;
}
+2 -2
View File
@@ -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<UILinearLayout>();
}
+1 -1
View File
@@ -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 );
}
+41 -6
View File
@@ -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 );
+43 -4
View File
@@ -9,6 +9,7 @@
#include <eepp/system/filesystem.hpp>
#include <eepp/system/functionstring.hpp>
#include <eepp/system/packmanager.hpp>
#include <eepp/system/regex.hpp>
#include <eepp/system/virtualfilesystem.hpp>
#include <eepp/ui/css/mediaquery.hpp>
#include <eepp/ui/css/stylesheetparser.hpp>
@@ -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<const char*>( buffer ), 0, nullptr, bufferSize ) ) {
std::string strBuffer( static_cast<const char*>( 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<char> 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 );
+8 -4
View File
@@ -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;
}
@@ -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(
<ScrollView layout_width="match_parent" layout_height="match_parent">
<MarkdownView id="markdown_view" layout_width="match_parent" layout_height="wrap_content" padding="16dp">
+103
View File
@@ -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(
<RichText id="rt">
<span>Hello</span>
<ul>
<li>Item</li>
</ul>
</RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "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<UI::UITextSpan*>( child );
if ( !span->getText().empty() ) {
spanCount++;
}
}
child = child->getNextNode();
}
// Only 1 text span ("Hello") should be generated,
// the whitespace between <span> and <ul>, and after <ul>
// 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(
<vbox lw="mp" lh="mp">
<RichText id="rt">Hello <a href="#">World</a>. <code>HI in monospace!</code></RichText>
</vbox>
)xml";
sceneNode->loadLayoutFromString( xml );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "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<UI::UITextSpan*>( child );
if ( span->getText() == ". " ) {
foundDotSpace = true;
}
}
child = child->getNextNode();
}
EXPECT_TRUE( foundDotSpace );
eeDelete( sceneNode );
Engine::destroySingleton();
}