From e5751da1a06543f3305e40fa5bfddaa56f841cbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Fri, 3 Jul 2026 01:33:47 -0300 Subject: [PATCH] Refactor UIWebView document scene isolation and async resource lifetime. Move UIWebView documents into isolated UISceneNode instances, keep document viewport and extent metrics synchronized, and fix shrink/grow behavior after navigation, async CSS, and viewport changes. Scope author styles and font-face resources per document scene, ignore stale async callbacks after navigation or destruction, and add deterministic coverage for CSS, fonts, images, cookies, hit testing, and document extent updates. Fixes in UIWebView -> doc -> html -> body resizing issues. Added a few tests to catch regressions. --- ...iwebview_document_scene_layout_refactor.md | 29 +- .agent/plans/uiwebview_document_scene_plan.md | 202 +++- .agent/rules/html-layout-architecture.md | 16 +- include/eepp/ui/uiimage.hpp | 7 + include/eepp/ui/uinodedrawable.hpp | 6 + include/eepp/ui/uirichtext.hpp | 1 + include/eepp/ui/uiscenenode.hpp | 23 +- src/eepp/ui/uiimage.cpp | 62 +- src/eepp/ui/uinodedrawable.cpp | 91 +- src/eepp/ui/uirichtext.cpp | 8 +- src/eepp/ui/uiscenenode.cpp | 201 +++- src/eepp/ui/uiwebview.cpp | 105 +- src/tests/unit_tests/uiwebview_tests.cpp | 1055 ++++++++++++++++- 13 files changed, 1663 insertions(+), 143 deletions(-) diff --git a/.agent/plans/uiwebview_document_scene_layout_refactor.md b/.agent/plans/uiwebview_document_scene_layout_refactor.md index bc457c6d1..a27c7e13b 100644 --- a/.agent/plans/uiwebview_document_scene_layout_refactor.md +++ b/.agent/plans/uiwebview_document_scene_layout_refactor.md @@ -176,18 +176,23 @@ application UISceneNode - Tests cover the new topology, viewport-vs-extent behavior, scrolling, two-scene style isolation, navigation supersession, and a resize metric regression that guards against no-op queued viewport churn rebuilding RichText. They also cover document - root hit testing below the layout viewport. + root hit testing below the layout viewport and fixed/sticky positioning against the + visible web-view viewport while scrolling. ### Pending / Follow-Up -- **Subresource lifetime coverage** should be completed for every async path described - in Phase 6, including deferred CSS, fonts, images, redirects, cookies, and destruction. -- **Example and documentation integration** should be completed after the code shape - settles, especially `.agent/rules/html-layout-architecture.md` and the HTML example - stylesheet injection path. -- **Fixed/sticky positioning coverage** is still listed as required test coverage for - the final architecture. Existing viewport tests cover the core sizing behavior, but - fixed/sticky document behavior should remain an explicit acceptance item. +- **Subresource lifetime coverage** is implemented for the current resource paths. Deferred/remote + CSS, remote author fonts, HTML `img` remote resources, CSS background/foreground image URLs, and + stale top-level redirect cookies now use document-scene lifetime admission. Tests cover stale + callbacks after navigation and mixed pending CSS/font/image/background-image callbacks during + `UIWebView` destruction. Future cache/session integration remains a follow-up layer. +- **Example and documentation integration** is implemented for the current architecture. The + architecture rule documents the document-scene boundary and viewport/layout/extent split, the + HTML example injects document CSS through `getDocumentSceneNode()`, and integration tests cover + two WebViews resolving the same relative stylesheet path against independent document URIs. +- **Fixed/sticky positioning coverage** is implemented for the document-scene WebView topology. + The acceptance tests load real HTML through `UIWebView`, scroll the WebView, and verify that + fixed/sticky elements use the visible viewport rather than the measured document extent. --- @@ -406,6 +411,12 @@ Tests: - `include/eepp/ui/uiscenenode.hpp` - `src/eepp/ui/uiscenenode.cpp` +The detailed implementation roadmap for this phase lives in +`.agent/plans/uiwebview_document_scene_plan.md`. This layout refactor only tracks the +architecture-level acceptance item: document subresources must use document-scene lifetime admission, +not raw scene/widget callbacks, and the design must remain compatible with a future local cache layer +and shared-pointer resource ownership. + Steps: 1. Add navigation generation state for top-level document loads. diff --git a/.agent/plans/uiwebview_document_scene_plan.md b/.agent/plans/uiwebview_document_scene_plan.md index 0340b3f0d..b4c519b31 100644 --- a/.agent/plans/uiwebview_document_scene_plan.md +++ b/.agent/plans/uiwebview_document_scene_plan.md @@ -2,8 +2,7 @@ > Status: IMPLEMENTED WITH FOLLOW-UPS - the owned document scene, real scroll-target > layout widget, viewport/extent split, root-scoped hit-test traversal, and focused -> UIWebView coverage are implemented. Remaining work is broader async subresource -> coverage, examples/docs, and fixed/sticky acceptance tests. +> UIWebView coverage are implemented. Remaining work is future cache/session integration. ## Goal @@ -480,6 +479,8 @@ Tests: - `src/eepp/ui/uiwebview.cpp` - `include/eepp/ui/uiscenenode.hpp` - `src/eepp/ui/uiscenenode.cpp` +- `src/eepp/ui/uiimage.cpp` +- `src/eepp/graphics/drawablesearcher.cpp` - `src/tests/unit_tests/uiwebview_tests.cpp` The current async/deferred callbacks can capture raw `this` and raw scene pointers. Owning a scene @@ -495,40 +496,171 @@ cover every deferred document resource path, not only HTTP: - async image/replaced-resource loads, - main-thread reposts created by any of the above. -Steps: +The browser-engine model to follow is: -1. Add a small shared navigation/load state containing an alive owner pointer and monotonically - increasing navigation generation. -2. Async top-level navigation callbacks capture a weak load state, not raw `this` or raw scene - pointers. -3. Add a scene-local async resource state for document subresources. It must be invalidated on - navigation, scene destruction, and explicit document resource reset. -4. Subresource callbacks capture the scene-local state and a generation. They may capture immutable - values such as resolved URLs and parsed buffers, but must not dereference the scene or widget - until the generation/alive check has passed on the main thread. -5. Before storing cookies, posting to the main thread, applying a response, combining a stylesheet, - registering a font, mutating an image/replaced widget, or marking document extent dirty, verify - that the owner still exists and the generation is current. -6. A newer navigation invalidates older document responses and all old document subresources. - Destruction invalidates all pending callbacks. -7. Keep history mutation and navigation events deterministic when requests fail or are superseded. -8. Give document-scene async resource loads a scene-lifetime guard before they enqueue main-thread - work or mutate the stylesheet. Audit other document-triggered async loaders and use the same - pattern where they retain scene/widget pointers. -9. When a stale callback is ignored, it must not store cookies, mutate style/font/image state, mark +- A document owns the resource clients and lifetime tokens. +- Shared caches may outlive the document. +- Network, file, and decode work may finish after navigation or destruction. +- Finished work may mutate stylesheet, font, image, cookie, layout, or widget state only if the + owning document is still alive and still represents the same navigation generation. +- Cancellation is useful for saving work, but stale-callback rejection is the correctness mechanism. + +This must be compatible with the current global `ResourceManager`/`TextureFactory`/`FontManager` +model and with the planned future resource refactor to shared pointers. Do not solve this by adding +heavy ownership to `Node` or by making global managers document-aware. The document-scene resource +context should be a narrow compatibility layer today, and later it should naturally become a set of +`weak_ptr`/`shared_ptr` resource clients once eepp resources stop being lifetime-owned by global +managers. + +Also keep the future local cache layer in mind. The lifetime checks below must distinguish +**resource cache lifetime** from **document client lifetime**: + +- A cached CSS/font/image response may be reusable by a later document. +- A cached cookie may be preserved by a future browsing-session/domain-cookie store. +- A stale document client must still be unable to apply the cached result or store cookies into the + wrong document/session. + +Current Phase 6 implementation progress: + +- Implemented: shared document-scene subresource admission state with atomic owner/alive/generation + checks and a safe main-thread admission queue that is not scheduled through a possibly destroyed + document node. +- Implemented: deferred local CSS and remote CSS callbacks are generation-checked before applying + stylesheets. +- Implemented: remote author `@font-face` callbacks construct/register scene-owned fonts only after + current-generation main-thread admission. +- Implemented: HTML `img` / `UIImage src` remote loads use document-scene admission and per-widget + load ids before replacing textures. +- Implemented: CSS background/foreground image URLs loaded through `UINodeDrawable` use + document-scene admission, scene-unique placeholder textures, and per-layer load ids before + replacing texture pixels. +- Implemented: stale top-level redirect cookies are ignored after a newer navigation. +- Implemented: explicit mixed-resource destruction coverage exercises pending CSS, font, HTML image, + and CSS background-image callbacks while the `UIWebView` is destroyed. +- Pending: cache/session integration remains a future layer; current work only preserves the + document-client lifetime boundary that a cache will need. + +#### Phase 6.1: Shared Lifetime Primitive And Main-Thread Admission + +1. Keep top-level navigation guarded by `UIWebView` navigation generation state. +2. Keep/add a scene-local document subresource state with: + - `alive`, cleared during `UISceneNode` destruction; + - a monotonically increasing generation, incremented on navigation and explicit document resource + reset. +3. Subresource workers may capture immutable values such as resolved URLs, marker hashes, parsed CSS, + downloaded bytes, and generation numbers. +4. Subresource workers must not dereference `UISceneNode*`, `UIWidget*`, `Texture*`, or `Font*` + unless that lifetime is independently guaranteed. +5. All document mutation must happen on the main thread after checking `alive` and generation. +6. Audit `Node::runOnMainThread()` usage carefully. Because it queues an action on a node, using it + through a possibly destroyed document node is not a complete lifetime guard. Prefer a safe + scheduler or a host/document lookup that performs the lifetime check before touching the node. + +Tests: + +- Destroying a web view before any pending subresource callback completes is safe. +- A stale callback can complete without touching a destroyed node, stylesheet, font, texture, widget, + cookie jar, navigation events, or document extent state. + +#### Phase 6.2: CSS Resources + +1. For external CSS, resolve URI and marker before dispatch. +2. For deferred local CSS, file IO and parsing may happen off the main thread, but only immutable + data and parsed stylesheet state may cross the worker boundary. +3. For remote CSS, HTTP callbacks must check the resource generation before accepting the response + and again before applying it on the main thread. +4. `combineStyleSheet()`, media-query updates, relative URL resolution, font-face processing, and + document extent dirtying must run only for the current document generation. +5. VFS CSS is currently synchronous, but it should use the same admission helper if it later becomes + deferred. + +Tests: + +- A delayed local stylesheet from page A completes after navigating to page B and does not affect B. +- A slow remote stylesheet from an old navigation cannot change the current document's style or + document extent. +- Destroying a web view with a pending stylesheet load is safe. + +#### Phase 6.3: Author Fonts + +1. Keep author family names as document-scoped aliases before global font fallback. +2. For remote fonts, do not register a global `FontManager` resource or alias until the current + generation is admitted on the main thread. `Font` construction currently registers globally, so + avoid constructing scene-owned fonts on a worker unless that behavior is changed first. +3. Local-file, data URI, VFS, and remote font paths should share the same scene-owned registration + and cleanup path. +4. Font load completion may call `reloadFontFamily()` and mark document extent dirty only if the + owning document is current. +5. Navigation/destruction must remove only this document scene's internally registered author fonts. + +Tests: + +- A stale remote `@font-face` response cannot register an alias or global font resource. +- A stale font response cannot replace the current document's text metrics or dirty its extent. +- Local-file, data URI, VFS, and remote author fonts still clean up on navigation/destruction. + +#### Phase 6.4: Images And Replaced Resources + +1. Audit HTML `img`, SVG/image widgets, CSS background/foreground images, and any replaced resource + whose intrinsic size can affect layout. +2. Avoid using bare global URL names as the document lifetime boundary for remote document images. + Use a document image loader path with scene-unique internal texture/resource names or explicit + document clients. +3. A remote image response from an old generation must not replace a current document texture, + repaint a current widget, notify a closed widget, or mark current document extent dirty. +4. Widget resource-change subscriptions must be disconnected before scene-owned resources are + removed. +5. If a pending HTTP image already owns a placeholder texture, stale completion must either drop the + decoded image or clean up the placeholder on the main thread without notifying stale document + clients. +6. This path should be designed so a future cache can store decoded bytes or textures separately + from document clients. A cache hit is still applied only after document-generation admission. + +Tests: + +- A slow image response from page A cannot mutate page B after navigation. +- Destroying a web view with a pending image load is safe. +- An image intrinsic-size change from an admitted current resource still invalidates layout and + document extent. +- The same remote image URL used by two web views does not let one document's cleanup break the + other's visible resource. + +#### Phase 6.5: Redirects, Cookies, And Future Cache Boundary + +1. Treat redirects as part of the active navigation or subresource load, not as separate document + mutations. +2. Progress callbacks that observe redirects must check the current owner/generation before storing + cookies or continuing work where cancellation is available. +3. Final response callbacks must check generation before storing `Set-Cookie`. +4. Preserve the current per-document cookie behavior for now, but shape the code so a future + browsing-session/domain-cookie cache can be introduced without weakening stale-response checks. +5. Cache admission and document admission are separate decisions: a stale response may be eligible + for a future cache, but it must not be applied to or store cookies through a stale document. + +Tests: + +- A stale top-level redirect cannot store cookies into the current document. +- A stale subresource redirect cannot store cookies or affect the current document. +- A newer navigation supersedes an older redirected load deterministically. + +#### Phase 6.6: Destruction And Cross-Resource Cleanup + +This phase is cross-cutting and should be implemented partially inside each resource phase above, not +left until the end. + +1. On navigation, invalidate document responses and subresources before clearing children/resources. +2. On destruction, mark the resource state dead before destroying children or resource managers. +3. Pending callbacks may finish later, but they must have no live document client to call into. +4. Cleanup order should be: invalidate, disconnect widget/resource clients, clear document children, + clear document-scoped aliases/resources, then allow late stale callbacks to drop results. +5. When a stale callback is ignored, it must not store cookies, mutate style/font/image state, mark extent dirty, send navigation events, or trigger layout. Tests: -- Destroying a web view before an HTTP response completes is safe. -- A slow old response cannot replace a newer document. -- Stale redirects/cookies do not mutate the current document scene. -- Destroying a web view while external CSS is loading is safe and cannot mutate another scene. -- Deferred local CSS that completes after navigation/destruction is ignored safely. -- A remote or deferred `@font-face` response from an old navigation cannot register a font alias, - replace the current document's font, or remeasure the current document. -- An async image/replaced-resource response from an old navigation cannot mutate a closed widget or - dirty the current document extent. +- Destroying a web view with pending CSS, font, and image loads is safe. +- Navigating repeatedly while mixed subresources are pending leaves only the newest document's + styles, fonts, images, cookies, and extents visible. ### Phase 7: Integration And Documentation @@ -542,7 +674,9 @@ Steps: 1. Document the web-document scene boundary, viewport/content split, and host-service inheritance. 2. Add a realistic two-web-view integration test with visibly conflicting CSS and independent - relative resources. + relative resources. **Implemented:** `DocumentScenesIsolateStylesUriAndLookup` loads two + documents from separate directories that use the same relative stylesheet path and verifies each + document resolves it through its own URI. 3. Run the existing old Reddit `UIWebView` smoke test against the new document scene. 4. Verify the UI editor nested scene still behaves correctly after nested-scene hardening. 5. Verify inspector/debug tooling can target either the application scene or @@ -559,10 +693,10 @@ Steps: | `src/eepp/ui/uirichtext.cpp` | HTML/body viewport minimum-height handling | | `include/eepp/ui/uiwebview.hpp` | Owned scene, getter, scheduled update, lifetime state | | `src/eepp/ui/uiwebview.cpp` | Document layout scroll target, viewport updates, isolated loading/navigation | -| `src/examples/ui_html/ui_html.cpp` | Inject document CSS through the document scene | +| `src/examples/ui_html/ui_html.cpp` | Inject document CSS through the document scene; already uses `getDocumentSceneNode()` | | `src/tests/unit_tests/uiwebview_tests.cpp` | New focused isolation/lifecycle tests | | `src/tests/unit_tests/uihtml_tests.cpp` | Existing realistic web-view fixture adjustments | -| `.agent/rules/html-layout-architecture.md` | Document final architecture after implementation | +| `.agent/rules/html-layout-architecture.md` | Documents final document-scene boundary and metrics | No new production source file is required for the initial implementation. If the embedded-scene service policy grows beyond the narrow helper above, introduce a dedicated browsing/document context diff --git a/.agent/rules/html-layout-architecture.md b/.agent/rules/html-layout-architecture.md index 0cb9d0cf9..8cd172d57 100644 --- a/.agent/rules/html-layout-architecture.md +++ b/.agent/rules/html-layout-architecture.md @@ -38,8 +38,8 @@ The application scene and document scene are separate style and resource boundar - Application stylesheets must not match web-view document nodes. - Document stylesheets must not match application widgets or sibling web views. -- URI, referer, navigation interception, cookies, dirty style/layout queues, actions, keyframes, and - author `@font-face` aliases are document-scoped state. +- URI, referer, relative-resource resolution, navigation interception, cookies, dirty style/layout + queues, actions, keyframes, and author `@font-face` aliases are document-scoped state. - Application code that intentionally injects document CSS must use `UIWebView::getDocumentSceneNode()->combineStyleSheet(...)`. @@ -54,11 +54,15 @@ Nested document scenes inherit only host services needed to operate inside the e Do not copy the host stylesheet, URI, referer, navigation callback, cookies, dirty queues, roots, actions, or icon-theme ownership into a document scene. -The document scene has two independent sizes: +The WebView document topology keeps three document metrics distinct: -- its actual scene extent is content-sized and is the `UIScrollView` scroll target; -- its explicit viewport size is the visible web-view viewport and is used for media queries, - viewport units, HTML/body minimum height, and fixed-position layout. +- **CSS viewport:** the visible web-view viewport, used for media queries, viewport units, + HTML/body minimum height, fixed positioning, and sticky positioning. +- **Layout viewport / initial containing block:** the viewport-sized root layout reference used for + normal root/body layout and percentage descendants. +- **Scrollable overflow extent:** the measured document overflow size. This belongs to the + `UIWebView` document layout scroll target and the nested document scene extent, not to the root + containing block. Document scenes are owner-updated by `UIWebView::scheduledUpdate()`. Do not register them with `SceneManager`, and do not add a second update subscription for the same document scene. diff --git a/include/eepp/ui/uiimage.hpp b/include/eepp/ui/uiimage.hpp index 69a476343..420f7b075 100644 --- a/include/eepp/ui/uiimage.hpp +++ b/include/eepp/ui/uiimage.hpp @@ -1,7 +1,10 @@ #ifndef EE_UI_UIIMAGE_HPP #define EE_UI_UIIMAGE_HPP +#include #include +#include +#include namespace EE { namespace UI { @@ -56,6 +59,8 @@ class EE_API UIImage : public UIWidget { Vector2f mDestSize; Uint32 mResourceChangeCb; bool mDrawableOwner; + std::shared_ptr> mAsyncImageAlive; + Uint64 mRemoteImageLoadId{ 0 }; UIImage(); @@ -76,6 +81,8 @@ class EE_API UIImage : public UIWidget { void safeDeleteDrawable(); void onDrawableResourceEvent( DrawableResource::Event event, DrawableResource* ); + + void loadRemoteDrawable( const Network::URI& uri ); }; }} // namespace EE::UI diff --git a/include/eepp/ui/uinodedrawable.hpp b/include/eepp/ui/uinodedrawable.hpp index c47cc7aec..c9b7f47c8 100644 --- a/include/eepp/ui/uinodedrawable.hpp +++ b/include/eepp/ui/uinodedrawable.hpp @@ -1,11 +1,13 @@ #ifndef EE_UI_UINODEDRAWABLE_HPP #define EE_UI_UINODEDRAWABLE_HPP +#include #include #include #include #include #include +#include using namespace EE::Graphics; using namespace EE::Scene; @@ -139,6 +141,8 @@ class EE_API UINodeDrawable : public Drawable { Origin mOrigin{ Origin::PaddingBox }; Clip mClip{ Clip::BorderBox }; Attachment mAttachment{ Attachment::Scroll }; + std::shared_ptr> mAsyncDrawableAlive; + Uint64 mRemoteDrawableLoadId{ 0 }; virtual void onPositionChange(); @@ -147,6 +151,8 @@ class EE_API UINodeDrawable : public Drawable { void update(); Drawable* createDrawable( const std::string& value, const Sizef& size, bool& ownIt ); + + bool loadRemoteDrawable( const std::string& value ); }; static UINodeDrawable* New( UINode* owner ); diff --git a/include/eepp/ui/uirichtext.hpp b/include/eepp/ui/uirichtext.hpp index 8c178c372..8f8758000 100644 --- a/include/eepp/ui/uirichtext.hpp +++ b/include/eepp/ui/uirichtext.hpp @@ -245,6 +245,7 @@ class EE_API UIHTMLBody : public UIRichText { bool applyProperty( const StyleSheetProperty& attribute ); virtual void updateLayout(); void setDocumentViewportMinHeight( const Float& height ); + void setDocumentCanvasMinHeight( const Float& height ); protected: bool mPropagatedBackground{ false }; diff --git a/include/eepp/ui/uiscenenode.hpp b/include/eepp/ui/uiscenenode.hpp index ea1bf5d97..299564b14 100644 --- a/include/eepp/ui/uiscenenode.hpp +++ b/include/eepp/ui/uiscenenode.hpp @@ -12,6 +12,8 @@ #include #include +#include +#include #include using namespace EE::Network; @@ -833,6 +835,23 @@ class EE_API UISceneNode : public SceneNode { void loadHTMLBaseCSS(); + struct AsyncResourceLoadState { + std::atomic owner{ nullptr }; + std::atomic alive{ true }; + std::atomic generation{ 0 }; + }; + + using AsyncResourceMainThreadFunc = std::function; + + std::shared_ptr getAsyncResourceLoadState() const; + + static bool isAsyncResourceLoadCurrent( + const std::shared_ptr& resourceState, Uint64 generation ); + + static void runAsyncResourceOnMainThread( + const std::shared_ptr& resourceState, Uint64 generation, + AsyncResourceMainThreadFunc func, const Time& delay = Seconds( 0 ) ); + protected: friend class EE::UI::UIWindow; friend class EE::UI::UIWidget; @@ -850,10 +869,6 @@ class EE_API UISceneNode : public SceneNode { UIIconThemeManager* mUIIconThemeManager{ nullptr }; std::vector mFontFaces; UnorderedMap mFontFaceAliases; - struct AsyncResourceLoadState { - bool alive{ true }; - Uint64 generation{ 0 }; - }; std::shared_ptr mAsyncResourceLoadState; KeyBindings mKeyBindings; std::map mKeyBindingCommands; diff --git a/src/eepp/ui/uiimage.cpp b/src/eepp/ui/uiimage.cpp index af5f4f0e8..9aa2fa1b8 100644 --- a/src/eepp/ui/uiimage.cpp +++ b/src/eepp/ui/uiimage.cpp @@ -1,6 +1,10 @@ #include #include +#include #include +#include +#include +#include #include #include #include @@ -25,7 +29,8 @@ UIImage::UIImage( const std::string& tag ) : mColor(), mAlignOffset( 0, 0 ), mResourceChangeCb( 0 ), - mDrawableOwner( false ) { + mDrawableOwner( false ), + mAsyncImageAlive( std::make_shared>( true ) ) { mFlags |= UI_AUTO_SIZE; applyDefaultTheme(); @@ -34,6 +39,8 @@ UIImage::UIImage( const std::string& tag ) : UIImage::UIImage() : UIImage( "image" ) {} UIImage::~UIImage() { + if ( mAsyncImageAlive ) + mAsyncImageAlive->store( false, std::memory_order_release ); safeDeleteDrawable(); } @@ -286,6 +293,54 @@ void UIImage::onDrawableResourceEvent( DrawableResource::Event event, DrawableRe } } +void UIImage::loadRemoteDrawable( const Network::URI& uri ) { + UISceneNode* scene = getUISceneNode(); + if ( !scene ) + return; + + auto resourceState = scene->getAsyncResourceLoadState(); + Uint64 resourceGeneration = + resourceState ? resourceState->generation.load( std::memory_order_acquire ) : 0; + Uint64 loadId = ++mRemoteImageLoadId; + auto alive = mAsyncImageAlive; + const std::string url = uri.toString(); + const std::string textureName = + String::format( "__eepp_ui_image_%p_%llu_%u", this, + static_cast( loadId ), String::hash( url ) ); + + Texture* texture = TextureFactory::instance()->createEmptyTexture( + 1, 1, 4, Color::Transparent, false, Texture::ClampMode::ClampToEdge, false, false, + textureName ); + if ( texture ) + setDrawable( texture, true ); + + Http::getAsync( + [resourceState, resourceGeneration, alive, loadId, texture, + this]( const Http&, Http::Request&, Http::Response& response ) { + if ( !UISceneNode::isAsyncResourceLoadCurrent( resourceState, resourceGeneration ) || + !alive || !alive->load( std::memory_order_acquire ) || texture == nullptr ) + return; + + if ( response.isOK() && !response.getBody().empty() ) { + std::string imageData( response.getBody() ); + UISceneNode::runAsyncResourceOnMainThread( + resourceState, resourceGeneration, + [alive, loadId, texture, imageData = std::move( imageData ), + this]( UISceneNode* ) mutable { + if ( !alive || !alive->load( std::memory_order_acquire ) || + loadId != mRemoteImageLoadId || mDrawable != texture ) + return; + + Image image( reinterpret_cast( imageData.data() ), + imageData.size() ); + if ( image.getPixels() != nullptr ) + texture->replace( &image ); + } ); + } + }, + uri, Seconds( 5 ), {}, {}, "", true, Http::getEnvProxyURI() ); +} + void UIImage::onSizeChange() { onAutoSize(); calcDestSize(); @@ -386,6 +441,11 @@ bool UIImage::applyProperty( const StyleSheetProperty& attribute ) { path = uri.toString(); } + if ( uri.getScheme() == "http" || uri.getScheme() == "https" ) { + loadRemoteDrawable( uri ); + break; + } + Drawable* createdDrawable = StyleSheetSpecification::instance()->getDrawableImageParser().createDrawable( path, mSize, ownIt, this ); diff --git a/src/eepp/ui/uinodedrawable.cpp b/src/eepp/ui/uinodedrawable.cpp index 66ceba561..39c4a72a9 100644 --- a/src/eepp/ui/uinodedrawable.cpp +++ b/src/eepp/ui/uinodedrawable.cpp @@ -1,7 +1,11 @@ #include #include +#include #include +#include +#include #include +#include #include #include #include @@ -416,9 +420,13 @@ UINodeDrawable::LayerDrawable::LayerDrawable( UINodeDrawable* container ) : mAttachmentEq( "scroll" ), mOrigin( Origin::PaddingBox ), mClip( Clip::BorderBox ), - mAttachment( Attachment::Scroll ) {} + mAttachment( Attachment::Scroll ), + mAsyncDrawableAlive( std::make_shared>( true ) ) {} UINodeDrawable::LayerDrawable::~LayerDrawable() { + if ( mAsyncDrawableAlive ) + mAsyncDrawableAlive->store( false, std::memory_order_release ); + if ( NULL != mDrawable && 0 != mResourceChangeCbId && mDrawable->isDrawableResource() ) { reinterpret_cast( mDrawable ) ->popResourceChangeCallback( mResourceChangeCbId ); @@ -645,6 +653,11 @@ void UINodeDrawable::LayerDrawable::setDrawable( Drawable* drawable, const bool& } void UINodeDrawable::LayerDrawable::setDrawable( const std::string& drawableRef ) { + if ( loadRemoteDrawable( drawableRef ) ) { + mDrawableRef = drawableRef; + return; + } + bool ownIt; Drawable* drawable = createDrawable( drawableRef, mSize, ownIt ); @@ -653,6 +666,82 @@ void UINodeDrawable::LayerDrawable::setDrawable( const std::string& drawableRef mDrawableRef = drawableRef; } +bool UINodeDrawable::LayerDrawable::loadRemoteDrawable( const std::string& value ) { + FunctionString functionType = FunctionString::parse( value ); + std::string path; + + if ( !functionType.isEmpty() && functionType.getName() == "url" && + !functionType.getParameters().empty() ) { + path = functionType.getParameters().at( 0 ); + } else { + path = value; + } + + if ( !path.empty() && path.front() == '\'' && path.back() == '\'' ) + String::trimInPlace( path, '\'' ); + else if ( !path.empty() && path.front() == '"' && path.back() == '"' ) + String::trimInPlace( path, '"' ); + + UINode* owner = mContainer ? mContainer->getOwner() : nullptr; + UISceneNode* scene = owner ? owner->getUISceneNode() : nullptr; + if ( !scene ) + return false; + + URI uri( path ); + if ( uri.getScheme().empty() ) + uri = scene->solveRelativePath( uri ); + + if ( uri.getScheme() != "http" && uri.getScheme() != "https" ) + return false; + + if ( value == mDrawableRef && mDrawable != nullptr ) + return true; + + auto resourceState = scene->getAsyncResourceLoadState(); + Uint64 resourceGeneration = + resourceState ? resourceState->generation.load( std::memory_order_acquire ) : 0; + Uint64 loadId = ++mRemoteDrawableLoadId; + auto alive = mAsyncDrawableAlive; + const std::string url = uri.toString(); + const std::string textureName = + String::format( "__eepp_ui_layer_image_%p_%llu_%u", this, + static_cast( loadId ), String::hash( url ) ); + + Texture* texture = TextureFactory::instance()->createEmptyTexture( + 1, 1, 4, Color::Transparent, false, Texture::ClampMode::ClampToEdge, false, false, + textureName ); + if ( texture ) + setDrawable( texture, true ); + + Http::getAsync( + [resourceState, resourceGeneration, alive, loadId, texture, + this]( const Http&, Http::Request&, Http::Response& response ) { + if ( !UISceneNode::isAsyncResourceLoadCurrent( resourceState, resourceGeneration ) || + !alive || !alive->load( std::memory_order_acquire ) || texture == nullptr ) + return; + + if ( response.isOK() && !response.getBody().empty() ) { + std::string imageData( response.getBody() ); + UISceneNode::runAsyncResourceOnMainThread( + resourceState, resourceGeneration, + [alive, loadId, texture, imageData = std::move( imageData ), + this]( UISceneNode* ) mutable { + if ( !alive || !alive->load( std::memory_order_acquire ) || + loadId != mRemoteDrawableLoadId || mDrawable != texture ) + return; + + Image image( reinterpret_cast( imageData.data() ), + imageData.size() ); + if ( image.getPixels() != nullptr ) + texture->replace( &image ); + } ); + } + }, + uri, Seconds( 5 ), {}, {}, "", true, Http::getEnvProxyURI() ); + + return true; +} + Drawable* UINodeDrawable::LayerDrawable::createDrawable( const std::string& value, const Sizef& size, bool& ownIt ) { return CSS::StyleSheetSpecification::instance()->getDrawableImageParser().createDrawable( diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index a245961b1..296519c9d 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -281,6 +281,10 @@ void UIHTMLBody::setDocumentViewportMinHeight( const Float& height ) { updateDocumentMinHeight(); } +void UIHTMLBody::setDocumentCanvasMinHeight( const Float& height ) { + setDocumentContentMinHeight( height ); +} + Float UIHTMLBody::getLocalMinHeight() const { if ( !getParent() ) return 0; @@ -310,10 +314,6 @@ void UIHTMLBody::updateDocumentMinHeight() { // Lowering min-height does not shrink the current box by itself. Reapply size through // min/max fitting so the body can settle at the new floor. setPixelsSize( { getPixelsSize().getWidth(), 0 } ); - else if ( minHeight > oldMinHeight && - getPixelsSize().getHeight() < PixelDensity::dpToPx( minHeight ) ) - // Raising min-height must expand the body to the new minimum when it is currently smaller. - setPixelsSize( { getPixelsSize().getWidth(), PixelDensity::dpToPx( minHeight ) } ); } void UIHTMLBody::updateDocumentContentMinHeightFromChildren() { diff --git a/src/eepp/ui/uiscenenode.cpp b/src/eepp/ui/uiscenenode.cpp index 6025ef6d1..452c77bbd 100644 --- a/src/eepp/ui/uiscenenode.cpp +++ b/src/eepp/ui/uiscenenode.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -36,6 +37,50 @@ using namespace EE::Network; namespace EE { namespace UI { +namespace { + +struct PendingAsyncResourceMainThread { + std::shared_ptr resourceState; + Uint64 generation{ 0 }; + UISceneNode::AsyncResourceMainThreadFunc func; + Time delay{ Time::Zero }; + Clock clock; +}; + +std::mutex sAsyncResourceMainThreadMutex; +std::vector sAsyncResourceMainThreadQueue; + +void drainAsyncResourceMainThreadQueue() { + std::vector pending; + { + std::lock_guard lock( sAsyncResourceMainThreadMutex ); + pending.swap( sAsyncResourceMainThreadQueue ); + } + + std::vector delayed; + for ( auto& item : pending ) { + if ( !UISceneNode::isAsyncResourceLoadCurrent( item.resourceState, item.generation ) ) + continue; + + if ( item.delay > Time::Zero && item.clock.getElapsedTime() < item.delay ) { + delayed.emplace_back( std::move( item ) ); + continue; + } + + UISceneNode* owner = item.resourceState->owner.load( std::memory_order_acquire ); + if ( owner && item.func ) + item.func( owner ); + } + + if ( !delayed.empty() ) { + std::lock_guard lock( sAsyncResourceMainThreadMutex ); + for ( auto& item : delayed ) + sAsyncResourceMainThreadQueue.emplace_back( std::move( item ) ); + } +} + +} // namespace + static void refreshWebViewDocumentLayoutAfterStyleChange( UIWidget* root ) { if ( !root ) return; @@ -101,14 +146,16 @@ UISceneNode::UISceneNode( EE::Window::Window* window ) : mRoot = UIRoot::New(); mRoot->setParent( this )->setPosition( 0, 0 )->setId( "uiscenenode_root_node" ); mRoot->enableReportSizeChangeToChildren(); + mAsyncResourceLoadState->owner.store( this, std::memory_order_release ); resizeNode( mWindow ); } UISceneNode::~UISceneNode() { if ( mAsyncResourceLoadState ) { - mAsyncResourceLoadState->alive = false; - mAsyncResourceLoadState->generation++; + mAsyncResourceLoadState->owner.store( nullptr, std::memory_order_release ); + mAsyncResourceLoadState->alive.store( false, std::memory_order_release ); + mAsyncResourceLoadState->generation.fetch_add( 1, std::memory_order_acq_rel ); } clearFontFaces(); @@ -121,8 +168,11 @@ UISceneNode::~UISceneNode() { // since its children could be consuming it and need to uninitialize gracefully. childDeleteAll(); - if ( !mOwnsEventDispatcher ) + if ( mOwnsEventDispatcher ) { + eeSAFE_DELETE( mEventDispatcher ); + } else { mEventDispatcher = nullptr; + } } void UISceneNode::resizeNode( EE::Window::Window* ) { @@ -232,7 +282,10 @@ void UISceneNode::initializeEmbeddedFromHost( UISceneNode* hostScene ) { mWindow = hostScene->getWindow(); mDPI = hostScene->getDPI(); - mEventDispatcher = hostScene->getEventDispatcher(); + EventDispatcher* hostDispatcher = hostScene->getEventDispatcher(); + if ( mOwnsEventDispatcher && mEventDispatcher != hostDispatcher ) + eeSAFE_DELETE( mEventDispatcher ); + mEventDispatcher = hostDispatcher; mOwnsEventDispatcher = false; mThreadPool = hostScene->getThreadPool(); mColorSchemePreference = hostScene->getColorSchemePreference(); @@ -883,6 +936,8 @@ void UISceneNode::flushDirtyStyleAndLayout() { void UISceneNode::update( const Time& elapsed ) { UISceneNode* uiSceneNode = SceneManager::instance()->getUISceneNode(); + drainAsyncResourceMainThreadQueue(); + if ( mFirstUpdate && mVerbose ) { mClock.restart(); } @@ -1471,32 +1526,45 @@ void UISceneNode::loadFontFaces( const StyleSheetStyleVector& styles, URI baseUR std::string internalFontName( makeInternalFontName( authorFamily, fontStyle, fontWeight ) ); auto resourceState = mAsyncResourceLoadState; - Uint64 resourceGeneration = resourceState ? resourceState->generation : 0; + Uint64 resourceGeneration = + resourceState ? resourceState->generation.load( std::memory_order_acquire ) : 0; Http::getAsync( - [this, resourceState, resourceGeneration, internalFontName, registerLoadedFont, - path]( const Http&, Http::Request&, Http::Response& response ) { - if ( !resourceState || !resourceState->alive || - resourceState->generation != resourceGeneration ) + [resourceState, resourceGeneration, internalFontName, authorFamily, fontStyle, + fontWeight, path]( const Http&, Http::Request&, Http::Response& response ) { + if ( !UISceneNode::isAsyncResourceLoadCurrent( resourceState, + resourceGeneration ) ) return; - FontTrueType* font = FontTrueType::New( internalFontName ); - if ( response.isOK() && !response.getBody().empty() && - font->loadFromMemory( &response.getBody()[0], - response.getBody().size() ) ) { - runOnMainThread( [resourceState, resourceGeneration, registerLoadedFont, - font]() mutable { - if ( resourceState && resourceState->alive && - resourceState->generation == resourceGeneration ) { - registerLoadedFont( font ); - } else { - eeSAFE_DELETE( font ); - } - } ); + + if ( response.isOK() && !response.getBody().empty() ) { + std::string fontData( response.getBody() ); + UISceneNode::runAsyncResourceOnMainThread( + resourceState, resourceGeneration, + [fontData = std::move( fontData ), internalFontName, authorFamily, + fontStyle, fontWeight]( UISceneNode* scene ) mutable { + FontTrueType* font = FontTrueType::New( internalFontName ); + if ( font->loadFromMemory( &fontData[0], fontData.size() ) && + font->loaded() ) { + scene->registerFontFaceAlias( authorFamily, fontStyle, + fontWeight, font ); + scene->mFontFaces.push_back( font ); + if ( scene->mRoot ) + scene->mRoot->reloadFontFamily(); + } else { + eeSAFE_DELETE( font ); + } + } ); } else { - eeSAFE_DELETE( font ); - Log::error( "UISceneNode::loadFontFaces: Failed to load font \"%s\", from: " - "%s. Request response status code: %d (%s)", - internalFontName, path, response.getStatus(), - response.getStatusDescription() ); + UISceneNode::runAsyncResourceOnMainThread( + resourceState, resourceGeneration, + [internalFontName, path, status = response.getStatus(), + statusDescription = + std::string( response.getStatusDescription() )]( UISceneNode* ) { + Log::error( "UISceneNode::loadFontFaces: Failed to load font " + "\"%s\", from: %s. Request response status code: %d " + "(%s)", + internalFontName, path, status, + statusDescription.c_str() ); + } ); } }, URI( path ), Seconds( 5 ) ); @@ -1573,7 +1641,12 @@ void UISceneNode::loadCSS( URI uri, std::optional