Fix anchors in UIRichText by using an special Anchor implementation for UITextSpan (extends from it).

Implemented proper hit-boxes for UITextSpan.
This commit is contained in:
Martín Lucas Golini
2026-03-07 02:07:56 -03:00
parent cebde979c9
commit 638608b48d
6 changed files with 275 additions and 35 deletions

View File

@@ -115,10 +115,19 @@ class EE_API UITextSpan : public UIWidget {
bool hasFontShadowOffset() const;
bool hasFontBackgroundColor() const;
std::vector<Rectf>& getHitBoxes();
const std::vector<Rectf>& getHitBoxes() const;
void setHitBoxes( std::vector<Rectf>&& hitBoxes );
virtual Node* overFind( const Vector2f& point );
protected:
Uint32 mStyleState{ StyleStateNone };
String mText;
UIFontStyleConfig mFontStyleConfig;
std::vector<Rectf> mHitBoxes;
explicit UITextSpan( const std::string& tag = "span" );
@@ -135,6 +144,29 @@ class EE_API UITextSpan : public UIWidget {
virtual Uint32 onMessage( const NodeMessage* Msg );
};
class EE_API UIAnchorSpan : public UITextSpan {
public:
static UIAnchorSpan* New();
virtual bool applyProperty( const StyleSheetProperty& attribute );
virtual std::string getPropertyString( const PropertyDefinition* propertyDef,
const Uint32& propertyIndex = 0 ) const;
virtual std::vector<PropertyId> getPropertiesImplemented() const;
void setHref( const std::string& href );
const std::string& getHref() const;
protected:
UIAnchorSpan( const std::string& tag = "a" );
std::string mHref;
virtual Uint32 onKeyDown( const KeyEvent& event );
};
}} // namespace EE::UI
#endif

View File

@@ -492,7 +492,6 @@ void UIRichText::rebuildRichText() {
void UIRichText::positionChildren() {
const auto& lines = mRichText.getLines();
Node* child = mChild;
size_t currentLine = 0;
@@ -514,42 +513,80 @@ void UIRichText::positionChildren() {
return nullptr;
};
Int64 curCharIdx = 0;
auto processWidget = [&]( UIWidget* widget, auto& processWidgetRef ) -> Rectf {
Rectf bounds( std::numeric_limits<Float>::max(), std::numeric_limits<Float>::max(),
std::numeric_limits<Float>::min(), std::numeric_limits<Float>::min() );
constexpr Float maxF = std::numeric_limits<Float>::max();
constexpr Float lowF = std::numeric_limits<Float>::lowest();
Rectf bounds( maxF, maxF, lowF, lowF );
Vector2f offset( 0, 0 );
Node* p = widget->getParent();
while ( p && p != this ) {
offset += p->isWidget() ? p->asType<UIWidget>()->getPixelsPosition() : p->getPosition();
p = p->getParent();
}
if ( widget->isType( UI_TYPE_TEXTSPAN ) ) {
UITextSpan* textSpan = static_cast<UITextSpan*>( widget );
Int64 startChar = curCharIdx;
Int64 endChar = curCharIdx;
if ( !textSpan->getText().empty() ) {
endChar += textSpan->getText().length();
curCharIdx = endChar;
}
std::vector<Rectf>& hitBoxes = textSpan->getHitBoxes();
hitBoxes.clear();
if ( startChar < endChar ) {
for ( const auto& line : lines ) {
bool passedText = false;
for ( const auto& rspan : line.spans ) {
if ( rspan.startCharIndex >= startChar && rspan.endCharIndex <= endChar ) {
Rectf hb( mPaddingPx.Left + rspan.position.x,
mPaddingPx.Top + line.y + rspan.position.y,
mPaddingPx.Left + rspan.position.x + rspan.size.getWidth(),
mPaddingPx.Top + line.y + rspan.position.y +
rspan.size.getHeight() );
hitBoxes.push_back( hb );
bounds.expand( hb );
} else if ( rspan.startCharIndex > endChar ) {
passedText = true;
break;
}
}
if ( passedText )
break;
}
}
Node* spanChild = widget->getFirstChild();
while ( spanChild != NULL ) {
if ( spanChild->isWidget() ) {
Rectf childBounds =
processWidgetRef( static_cast<UIWidget*>( spanChild ), processWidgetRef );
if ( childBounds.Left < bounds.Left )
bounds.Left = childBounds.Left;
if ( childBounds.Top < bounds.Top )
bounds.Top = childBounds.Top;
if ( childBounds.Right > bounds.Right )
bounds.Right = childBounds.Right;
if ( childBounds.Bottom > bounds.Bottom )
bounds.Bottom = childBounds.Bottom;
bounds.expand(
processWidgetRef( static_cast<UIWidget*>( spanChild ), processWidgetRef ) );
}
spanChild = spanChild->getNextNode();
}
// Ensure the parent span at least has enough size to cover its children
if ( bounds.Left <= bounds.Right && bounds.Top <= bounds.Bottom ) {
Vector2f offset( 0, 0 );
Node* p = widget->getParent();
while ( p && p != this ) {
offset += p->isWidget() ? p->asType<UIWidget>()->getPixelsPosition()
: p->getPosition();
p = p->getParent();
}
widget->setPixelsPosition( Vector2f( bounds.Left, bounds.Top ) - offset );
widget->setPixelsSize(
Sizef( bounds.Right - bounds.Left, bounds.Bottom - bounds.Top ) );
Vector2f boundsPos = bounds.getPosition();
widget->setPixelsPosition( boundsPos - offset );
widget->setPixelsSize( bounds.getSize() );
for ( auto& hb : hitBoxes )
hb.move( -boundsPos );
} else {
hitBoxes.clear();
}
} else {
curCharIdx += 1;
const auto* span = getNextCustomSpan();
if ( span ) {
size_t lineIdx = currentSpan > 0 ? currentLine : currentLine - 1;
@@ -558,18 +595,9 @@ void UIRichText::positionChildren() {
Vector2f targetPos( mPaddingPx.Left + span->position.x,
mPaddingPx.Top + lineY + span->position.y );
Vector2f offset( 0, 0 );
Node* p = widget->getParent();
while ( p && p != this ) {
offset += p->isWidget() ? p->asType<UIWidget>()->getPixelsPosition()
: p->getPosition();
p = p->getParent();
}
widget->setPixelsPosition( targetPos - offset );
bounds = Rectf( targetPos.x, targetPos.y,
targetPos.x + widget->getPixelsSize().getWidth(),
targetPos.y + widget->getPixelsSize().getHeight() );
bounds = Rectf( targetPos, widget->getPixelsSize() );
}
}
return bounds;

View File

@@ -5,6 +5,7 @@
#include <eepp/ui/uitextspan.hpp>
#include <eepp/ui/uithememanager.hpp>
#include <eepp/ui/uiwidgetcreator.hpp>
#include <eepp/window/engine.hpp>
#define PUGIXML_HEADER_ONLY
#include <pugixml/pugixml.hpp>
@@ -483,4 +484,130 @@ bool UITextSpan::hasFontBackgroundColor() const {
return 0 != ( mStyleState & StyleStateFontBackgroundColor );
}
std::vector<Rectf>& UITextSpan::getHitBoxes() {
return mHitBoxes;
}
const std::vector<Rectf>& UITextSpan::getHitBoxes() const {
return mHitBoxes;
}
void UITextSpan::setHitBoxes( std::vector<Rectf>&& hitBoxes ) {
mHitBoxes = std::move( hitBoxes );
}
Node* UITextSpan::overFind( const Vector2f& point ) {
Node* pOver = NULL;
if ( ( mNodeFlags & NODE_FLAG_OVER_FIND_ALLOWED ) && mEnabled && mVisible ) {
updateWorldPolygon();
if ( mWorldBounds.contains( point ) && mPoly.pointInside( point ) ) {
bool hit = false;
if ( !mHitBoxes.empty() ) {
Vector2f localPoint = convertToNodeSpace( point );
for ( const auto& rect : mHitBoxes ) {
if ( rect.contains( localPoint ) ) {
hit = true;
break;
}
}
} else {
hit = true;
}
if ( hit ) {
writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 1 );
mSceneNode->addMouseOverNode( this );
Node* child = mChildLast;
while ( NULL != child ) {
Node* childOver = child->overFind( point );
if ( NULL != childOver ) {
pOver = childOver;
break;
}
child = child->getPrevNode();
}
if ( NULL == pOver )
pOver = this;
}
}
}
return pOver;
}
UIAnchorSpan* UIAnchorSpan::New() {
return eeNew( UIAnchorSpan, () );
}
UIAnchorSpan::UIAnchorSpan( const std::string& tag ) : UITextSpan( tag ) {
onClick(
[this]( const MouseEvent* ) {
if ( !mHref.empty() )
Engine::instance()->openURI( mHref );
},
EE_BUTTON_LEFT );
}
bool UIAnchorSpan::applyProperty( const StyleSheetProperty& attribute ) {
if ( !checkPropertyDefinition( attribute ) )
return false;
switch ( attribute.getPropertyDefinition()->getPropertyId() ) {
case PropertyId::Href:
setHref( attribute.asString() );
break;
default:
UITextSpan::applyProperty( attribute );
break;
}
return true;
}
void UIAnchorSpan::setHref( const std::string& href ) {
if ( href != mHref ) {
mHref = href;
}
}
const std::string& UIAnchorSpan::getHref() const {
return mHref;
}
Uint32 UIAnchorSpan::onKeyDown( const KeyEvent& event ) {
if ( event.getKeyCode() == KEY_KP_ENTER || event.getKeyCode() == KEY_RETURN ) {
if ( !mHref.empty() ) {
Engine::instance()->openURI( mHref );
return 1;
}
}
return UIWidget::onKeyDown( event );
}
std::string UIAnchorSpan::getPropertyString( const PropertyDefinition* propertyDef,
const Uint32& propertyIndex ) const {
if ( NULL == propertyDef )
return "";
switch ( propertyDef->getPropertyId() ) {
case PropertyId::Href:
return mHref;
default:
return UITextSpan::getPropertyString( propertyDef, propertyIndex );
}
}
std::vector<PropertyId> UIAnchorSpan::getPropertiesImplemented() const {
auto props = UITextSpan::getPropertiesImplemented();
auto local = { PropertyId::Href };
props.insert( props.end(), local.begin(), local.end() );
return props;
}
}} // namespace EE::UI

View File

@@ -129,7 +129,7 @@ void UIWidgetCreator::createBaseWidgetList() {
registeredWidget["tv"] = UITextView::New;
// HTML elements
registeredWidget["a"] = UIAnchor::NewA;
registeredWidget["a"] = UIAnchorSpan::New;
registeredWidget["span"] = UITextSpan::New;
registeredWidget["em"] = UITextSpan::NewEmphasis;
registeredWidget["b"] = UITextSpan::NewBold;

View File

@@ -29,11 +29,14 @@ This is a **bold** text and this is an *italic* text.
`inline code`
[this is a link](https://eepp.ensoft.dev)
```cpp
void main() {
printf("Hello World");
}
```
</MarkdownView>
</ScrollView>
</vbox>

View File

@@ -584,3 +584,53 @@ UTEST( UIRichText, RichTextTest ) {
runTest();
}
}
UTEST( UIRichText, UIAnchorTest ) {
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" font-size="24dp" color="#FF0000" layout_width="300dp" layout_height="wrap_content">Default size <a id="anchor1" href="https://example.com" color="#00FF00">Link text</a> and <a id="anchor2" href="https://example.org">Another link</a></RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "rt" );
ASSERT_TRUE( rt != nullptr );
// force layout
sceneNode->update( Time::Zero );
UI::UIAnchorSpan* anchor1 = sceneNode->find<UI::UIAnchorSpan>( "anchor1" );
ASSERT_TRUE( anchor1 != nullptr );
EXPECT_STRINGEQ( anchor1->getHref(), "https://example.com" );
EXPECT_TRUE( anchor1->getHitBoxes().size() >= 1 );
UI::UIAnchorSpan* anchor2 = sceneNode->find<UI::UIAnchorSpan>( "anchor2" );
ASSERT_TRUE( anchor2 != nullptr );
EXPECT_STRINGEQ( anchor2->getHref(), "https://example.org" );
EXPECT_TRUE( anchor2->getHitBoxes().size() >= 1 );
// Test that overFind correctly returns the anchor
if ( !anchor1->getHitBoxes().empty() ) {
Vector2f hitPos = anchor1->convertToWorldSpace(
{ anchor1->getHitBoxes()[0].Left + 1, anchor1->getHitBoxes()[0].Top + 1 } );
Node* hitNode = rt->overFind( hitPos );
EXPECT_EQ( hitNode, anchor1 );
}
eeDelete( sceneNode );
Engine::destroySingleton();
}