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