diff --git a/include/eepp/ui/uilayouter.hpp b/include/eepp/ui/uilayouter.hpp index 79a604cdc..5dffcbf24 100644 --- a/include/eepp/ui/uilayouter.hpp +++ b/include/eepp/ui/uilayouter.hpp @@ -25,12 +25,16 @@ class EE_API UILayouter { virtual bool isPacking() const { return mPacking; } + /** True while the inline formatting owner assigns its computed fragment boxes. */ + bool isPositioningInlineFragments() const { return mPositioningInlineFragments; } + protected: UIWidget* mContainer; bool mPacking{ false }; // These flags occupy existing padding, preserving the size and member offsets of layouters. bool mInlineContentDirty{ true }; bool mInlineContentReusable{ false }; + bool mPositioningInlineFragments{ false }; size_t mResizedCount{ 0 }; bool mIntrinsicWidthsDirty{ true }; Float mMinIntrinsicWidth{ 0 }; diff --git a/include/eepp/ui/uirichtext.hpp b/include/eepp/ui/uirichtext.hpp index fab06bdcd..9e395e4dd 100644 --- a/include/eepp/ui/uirichtext.hpp +++ b/include/eepp/ui/uirichtext.hpp @@ -34,6 +34,9 @@ class EE_API UIRichText : public UIHTMLWidget { static void rebuildRichText( UILayout* container, RichText& richText, IntrinsicMode mode = IntrinsicMode::None ); + /** Whether the nearest inline formatting owner is assigning this child's fragment bounds. */ + static bool isAssigningInlineFragments( const UIWidget* child ); + /** Resolves the wrapping constraint independently of constructing the inline content stream. */ static Float getLayoutMaxWidth( UILayout* container, IntrinsicMode mode = IntrinsicMode::None ); diff --git a/include/eepp/ui/uitextnode.hpp b/include/eepp/ui/uitextnode.hpp index e317ec47a..8482b6bc2 100644 --- a/include/eepp/ui/uitextnode.hpp +++ b/include/eepp/ui/uitextnode.hpp @@ -50,6 +50,8 @@ class EE_API UITextNode : public UIWidget { Uint32 mTextHintsOverrideMask{ 0 }; UITextNode(); + + virtual void onSizeChange(); }; }} // namespace EE::UI diff --git a/include/eepp/ui/uitextspan.hpp b/include/eepp/ui/uitextspan.hpp index d366e1a5c..4e6b73a16 100644 --- a/include/eepp/ui/uitextspan.hpp +++ b/include/eepp/ui/uitextspan.hpp @@ -159,6 +159,8 @@ class EE_API UITextSpan : public UIRichText { explicit UITextSpan( const std::string& tag = "span" ); + virtual void onSizeChange(); + virtual void onTextChanged(); virtual void onFontChanged(); diff --git a/src/eepp/ui/blocklayouter.cpp b/src/eepp/ui/blocklayouter.cpp index f3c7d83ad..933130c72 100644 --- a/src/eepp/ui/blocklayouter.cpp +++ b/src/eepp/ui/blocklayouter.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -295,6 +296,8 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) { } void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt, bool reuseFragments ) { + BoolScopedOpOptional positioning( !mPositioningInlineFragments, mPositioningInlineFragments, + true ); const auto& lines = rt->getLines(); const auto& fragments = rt->getInlineFragments(); Node* child = mContainer->getFirstChild(); diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index 9f144f889..e30818b90 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -1728,6 +1728,19 @@ static Drawable* getInlineBorderDrawable( UIWidget* widget ) { : nullptr; } +bool UIRichText::isAssigningInlineFragments( const UIWidget* child ) { + for ( Node* parent = child->getParent(); parent && parent->isType( UI_TYPE_HTML_WIDGET ); + parent = parent->getParent() ) { + auto* owner = parent->asType(); + if ( auto* layouter = owner->getLayouter(); + layouter && layouter->isPositioningInlineFragments() ) + return true; + if ( !owner->isInline() ) + break; + } + return false; +} + Float UIRichText::getLayoutMaxWidth( UILayout* container, IntrinsicMode mode ) { if ( mode != IntrinsicMode::None ) return 0.f; diff --git a/src/eepp/ui/uitextnode.cpp b/src/eepp/ui/uitextnode.cpp index 5b14ca7ed..6d9273da7 100644 --- a/src/eepp/ui/uitextnode.cpp +++ b/src/eepp/ui/uitextnode.cpp @@ -28,6 +28,12 @@ bool UITextNode::isType( const Uint32& type ) const { return UITextNode::getType() == type ? true : UIWidget::isType( type ); } +void UITextNode::onSizeChange() { + // Raw text-node bounds are also outputs of the nearest inline formatting owner. Changes + // to the source text still emit TextFormatting invalidations through setText(). + UIWidget::onSizeChange( !UIRichText::isAssigningInlineFragments( this ) ); +} + void UITextNode::draw() { if ( mText.empty() ) return; diff --git a/src/eepp/ui/uitextspan.cpp b/src/eepp/ui/uitextspan.cpp index adb1cbdcd..19f6faf65 100644 --- a/src/eepp/ui/uitextspan.cpp +++ b/src/eepp/ui/uitextspan.cpp @@ -72,6 +72,18 @@ bool UITextSpan::isInlineBlock() const { return mDisplay == CSSDisplay::InlineBlock && !isOutOfFlow(); } +void UITextSpan::onSizeChange() { + if ( isInline() && UIRichText::isAssigningInlineFragments( this ) ) { + // CSS inline fragment bounds are results of the owning formatting pass, not new + // intrinsic content inputs. Keep size events/drawing current without feeding those + // bounds back into the stream. Callback-created text/style changes still emit their + // own formatting invalidations while the owner is measuring. + UIWidget::onSizeChange( false ); + } else { + UIRichText::onSizeChange(); + } +} + void UITextSpan::onDisplayChange() { bool nowInline = isInline(); if ( nowInline && getFontBackgroundColor() == Color::Transparent ) { diff --git a/src/examples/ui_html/ui_html.cpp b/src/examples/ui_html/ui_html.cpp index 1d1446672..025c48ce2 100644 --- a/src/examples/ui_html/ui_html.cpp +++ b/src/examples/ui_html/ui_html.cpp @@ -176,7 +176,7 @@ EE_MAIN_FUNC int main( int argc, char** argv ) { } - + @@ -189,7 +189,7 @@ EE_MAIN_FUNC int main( int argc, char** argv ) { + hint="@string(enter_address, Enter Address)" /> diff --git a/src/tests/unit_tests/richtext_tests.cpp b/src/tests/unit_tests/richtext_tests.cpp index c3dd6cb43..dc0c3924b 100644 --- a/src/tests/unit_tests/richtext_tests.cpp +++ b/src/tests/unit_tests/richtext_tests.cpp @@ -1993,6 +1993,81 @@ UTEST( UIHTMLTable, tableCellAnchorHoverRelayoutsRichText ) { destroyRichTextScene( sceneNode ); } +UTEST( UIRichText, inlineFragmentResizeKeepsContentReusable ) { + auto* sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + auto* host = UIWidget::New(); + host->setParent( sceneNode->getRoot() ); + host->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + host->enableReportSizeChangeToChildren(); + host->setPixelsSize( 240, 200 ); + sceneNode->loadLayoutFromString( R"xml( +

+ Several inline words must wrap when the available width changes. +

+ )xml", + host ); + sceneNode->flushDirtyStyleAndLayout(); + auto* text = sceneNode->find( "text" ); + auto* link = sceneNode->find( "link" ); + ASSERT_TRUE( text && link ); + text->updateLayout(); + const auto widerLines = text->getRichText().getLines().size(); + host->setPixelsSize( 120, 200 ); + sceneNode->flushDirtyStyleAndLayout(); + EXPECT_NEAR( 120.f, text->getPixelsSize().getWidth(), 1.f ); + EXPECT_GT( text->getRichText().getLines().size(), widerLines ); + EXPECT_GT( link->getHitBoxes().size(), 1u ); + + // A new font changes both the content measurement and its assigned fragment bounds. + // The following owner pass must consume that input without invalidating it again merely + // because the newly measured bounds were committed. The container's size is already settled. + link->setFontSize( 24 ); + text->updateLayout(); + UILayout::resetMetrics(); + text->updateLayout(); + auto metrics = UILayout::getMetrics(); + UILayout::setMetricsEnabled( false ); + EXPECT_EQ( 0u, metrics.richTextRebuilds ); + destroyRichTextScene( sceneNode ); +} + +UTEST( UIHTMLTable, contentChangedByInlineFragmentSizeEventIsMeasured ) { + auto* sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + auto* host = UIWidget::New(); + host->setParent( sceneNode->getRoot() ); + host->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + host->enableReportSizeChangeToChildren(); + host->setPixelsSize( 240, 400 ); + sceneNode->loadLayoutFromString( R"xml( + + +
Inline words that wrap in a narrower column.
+ )xml", + host ); + sceneNode->flushDirtyStyleAndLayout(); + auto* cell = sceneNode->find( "cell" ); + auto* link = sceneNode->find( "link" ); + ASSERT_TRUE( cell && link ); + bool changedContent = false; + link->on( Event::OnSizeChange, [link, &changedContent]( const Event* ) { + if ( !changedContent ) { + changedContent = true; + link->setText( "Replacement content created by an inline size event must be measured " + "and enclosed by its table row after the column becomes narrower." ); + } + } ); + host->setPixelsSize( 120, 400 ); + sceneNode->flushDirtyStyleAndLayout(); + EXPECT_TRUE( changedContent ); + EXPECT_TRUE( richTextRenderedText( cell->getRichText() ).find( "Replacement" ) != + String::InvalidPos ); + EXPECT_GE( cell->getPixelsSize().getHeight(), cell->getRichTextPtr()->getSize().getHeight() ); + EXPECT_GT( link->getHitBoxes().size(), 1u ); + destroyRichTextScene( sceneNode ); +} + UTEST( UIRichText, unchangedInlineContentIsReused ) { auto* sceneNode = createRichTextScene(); ASSERT_TRUE( sceneNode != nullptr );