diff --git a/include/eepp/ui/uitextspan.hpp b/include/eepp/ui/uitextspan.hpp index 0b2eb32a9..e58cc733f 100644 --- a/include/eepp/ui/uitextspan.hpp +++ b/include/eepp/ui/uitextspan.hpp @@ -115,10 +115,19 @@ class EE_API UITextSpan : public UIWidget { bool hasFontShadowOffset() const; bool hasFontBackgroundColor() const; + std::vector& getHitBoxes(); + + const std::vector& getHitBoxes() const; + + void setHitBoxes( std::vector&& hitBoxes ); + + virtual Node* overFind( const Vector2f& point ); + protected: Uint32 mStyleState{ StyleStateNone }; String mText; UIFontStyleConfig mFontStyleConfig; + std::vector 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 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 diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index b16d8bc24..4f2318d21 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -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::max(), std::numeric_limits::max(), - std::numeric_limits::min(), std::numeric_limits::min() ); + constexpr Float maxF = std::numeric_limits::max(); + constexpr Float lowF = std::numeric_limits::lowest(); + Rectf bounds( maxF, maxF, lowF, lowF ); + + Vector2f offset( 0, 0 ); + Node* p = widget->getParent(); + while ( p && p != this ) { + offset += p->isWidget() ? p->asType()->getPixelsPosition() : p->getPosition(); + p = p->getParent(); + } + if ( widget->isType( UI_TYPE_TEXTSPAN ) ) { + UITextSpan* textSpan = static_cast( widget ); + Int64 startChar = curCharIdx; + Int64 endChar = curCharIdx; + if ( !textSpan->getText().empty() ) { + endChar += textSpan->getText().length(); + curCharIdx = endChar; + } + + std::vector& 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( 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( 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()->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()->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; diff --git a/src/eepp/ui/uitextspan.cpp b/src/eepp/ui/uitextspan.cpp index 1870675b1..db1531422 100644 --- a/src/eepp/ui/uitextspan.cpp +++ b/src/eepp/ui/uitextspan.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #define PUGIXML_HEADER_ONLY #include @@ -483,4 +484,130 @@ bool UITextSpan::hasFontBackgroundColor() const { return 0 != ( mStyleState & StyleStateFontBackgroundColor ); } +std::vector& UITextSpan::getHitBoxes() { + return mHitBoxes; +} + +const std::vector& UITextSpan::getHitBoxes() const { + return mHitBoxes; +} + +void UITextSpan::setHitBoxes( std::vector&& 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 UIAnchorSpan::getPropertiesImplemented() const { + auto props = UITextSpan::getPropertiesImplemented(); + auto local = { PropertyId::Href }; + props.insert( props.end(), local.begin(), local.end() ); + return props; +} + }} // namespace EE::UI diff --git a/src/eepp/ui/uiwidgetcreator.cpp b/src/eepp/ui/uiwidgetcreator.cpp index 9b50e8acf..86017fa67 100644 --- a/src/eepp/ui/uiwidgetcreator.cpp +++ b/src/eepp/ui/uiwidgetcreator.cpp @@ -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; diff --git a/src/examples/ui_markdownview/ui_markdownview.cpp b/src/examples/ui_markdownview/ui_markdownview.cpp index f71e47bec..0ff6bb2f3 100644 --- a/src/examples/ui_markdownview/ui_markdownview.cpp +++ b/src/examples/ui_markdownview/ui_markdownview.cpp @@ -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"); } ``` + diff --git a/src/tests/unit_tests/richtext.cpp b/src/tests/unit_tests/richtext.cpp index d7ce96f7a..4b14e5a15 100644 --- a/src/tests/unit_tests/richtext.cpp +++ b/src/tests/unit_tests/richtext.cpp @@ -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( + Default size Link text and Another link + )xml"; + + sceneNode->loadLayoutFromString( xml ); + + UI::UIRichText* rt = sceneNode->find( "rt" ); + ASSERT_TRUE( rt != nullptr ); + + // force layout + sceneNode->update( Time::Zero ); + + UI::UIAnchorSpan* anchor1 = sceneNode->find( "anchor1" ); + ASSERT_TRUE( anchor1 != nullptr ); + EXPECT_STRINGEQ( anchor1->getHref(), "https://example.com" ); + EXPECT_TRUE( anchor1->getHitBoxes().size() >= 1 ); + + UI::UIAnchorSpan* anchor2 = sceneNode->find( "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(); +}