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.
This commit is contained in:
Martín Lucas Golini
2026-07-03 01:33:47 -03:00
parent bc7498c7c4
commit e5751da1a0
13 changed files with 1663 additions and 143 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -1,7 +1,10 @@
#ifndef EE_UI_UIIMAGE_HPP
#define EE_UI_UIIMAGE_HPP
#include <eepp/network/uri.hpp>
#include <eepp/ui/uiwidget.hpp>
#include <atomic>
#include <memory>
namespace EE { namespace UI {
@@ -56,6 +59,8 @@ class EE_API UIImage : public UIWidget {
Vector2f mDestSize;
Uint32 mResourceChangeCb;
bool mDrawableOwner;
std::shared_ptr<std::atomic<bool>> 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

View File

@@ -1,11 +1,13 @@
#ifndef EE_UI_UINODEDRAWABLE_HPP
#define EE_UI_UINODEDRAWABLE_HPP
#include <atomic>
#include <eepp/graphics/drawable.hpp>
#include <eepp/math/ease.hpp>
#include <eepp/scene/action.hpp>
#include <eepp/ui/uibackgrounddrawable.hpp>
#include <map>
#include <memory>
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<std::atomic<bool>> 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 );

View File

@@ -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 };

View File

@@ -12,6 +12,8 @@
#include <eepp/ui/keyboardshortcut.hpp>
#include <eepp/ui/layoutinvalidation.hpp>
#include <atomic>
#include <functional>
#include <memory>
using namespace EE::Network;
@@ -833,6 +835,23 @@ class EE_API UISceneNode : public SceneNode {
void loadHTMLBaseCSS();
struct AsyncResourceLoadState {
std::atomic<UISceneNode*> owner{ nullptr };
std::atomic<bool> alive{ true };
std::atomic<Uint64> generation{ 0 };
};
using AsyncResourceMainThreadFunc = std::function<void( UISceneNode* )>;
std::shared_ptr<AsyncResourceLoadState> getAsyncResourceLoadState() const;
static bool isAsyncResourceLoadCurrent(
const std::shared_ptr<AsyncResourceLoadState>& resourceState, Uint64 generation );
static void runAsyncResourceOnMainThread(
const std::shared_ptr<AsyncResourceLoadState>& 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<Font*> mFontFaces;
UnorderedMap<std::string, Font*> mFontFaceAliases;
struct AsyncResourceLoadState {
bool alive{ true };
Uint64 generation{ 0 };
};
std::shared_ptr<AsyncResourceLoadState> mAsyncResourceLoadState;
KeyBindings mKeyBindings;
std::map<std::string, KeyBindingCommand> mKeyBindingCommands;

View File

@@ -1,6 +1,10 @@
#include <eepp/graphics/drawable.hpp>
#include <eepp/graphics/drawablesearcher.hpp>
#include <eepp/graphics/image.hpp>
#include <eepp/graphics/sprite.hpp>
#include <eepp/graphics/texture.hpp>
#include <eepp/graphics/texturefactory.hpp>
#include <eepp/network/http.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/ui/css/drawableimageparser.hpp>
#include <eepp/ui/css/propertydefinition.hpp>
@@ -25,7 +29,8 @@ UIImage::UIImage( const std::string& tag ) :
mColor(),
mAlignOffset( 0, 0 ),
mResourceChangeCb( 0 ),
mDrawableOwner( false ) {
mDrawableOwner( false ),
mAsyncImageAlive( std::make_shared<std::atomic<bool>>( 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<unsigned long long>( 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<const Uint8*>( 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 );

View File

@@ -1,7 +1,11 @@
#include <eepp/core/core.hpp>
#include <eepp/graphics/drawableresource.hpp>
#include <eepp/graphics/image.hpp>
#include <eepp/graphics/renderer/renderer.hpp>
#include <eepp/graphics/texture.hpp>
#include <eepp/graphics/texturefactory.hpp>
#include <eepp/math/easing.hpp>
#include <eepp/network/http.hpp>
#include <eepp/system/functionstring.hpp>
#include <eepp/ui/css/stylesheetlength.hpp>
#include <eepp/ui/css/stylesheetspecification.hpp>
@@ -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<std::atomic<bool>>( true ) ) {}
UINodeDrawable::LayerDrawable::~LayerDrawable() {
if ( mAsyncDrawableAlive )
mAsyncDrawableAlive->store( false, std::memory_order_release );
if ( NULL != mDrawable && 0 != mResourceChangeCbId && mDrawable->isDrawableResource() ) {
reinterpret_cast<DrawableResource*>( 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<unsigned long long>( 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<const Uint8*>( 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(

View File

@@ -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() {

View File

@@ -1,4 +1,5 @@
#include <algorithm>
#include <mutex>
#include <eepp/core/string.hpp>
#include <eepp/graphics/fontmanager.hpp>
#include <eepp/graphics/fonttruetype.hpp>
@@ -36,6 +37,50 @@ using namespace EE::Network;
namespace EE { namespace UI {
namespace {
struct PendingAsyncResourceMainThread {
std::shared_ptr<UISceneNode::AsyncResourceLoadState> resourceState;
Uint64 generation{ 0 };
UISceneNode::AsyncResourceMainThreadFunc func;
Time delay{ Time::Zero };
Clock clock;
};
std::mutex sAsyncResourceMainThreadMutex;
std::vector<PendingAsyncResourceMainThread> sAsyncResourceMainThreadQueue;
void drainAsyncResourceMainThreadQueue() {
std::vector<PendingAsyncResourceMainThread> pending;
{
std::lock_guard<std::mutex> lock( sAsyncResourceMainThreadMutex );
pending.swap( sAsyncResourceMainThreadQueue );
}
std::vector<PendingAsyncResourceMainThread> 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<std::mutex> 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<Time> defer ) {
if ( "file" == uri.getScheme() ||
( uri.getScheme().empty() && FileSystem::fileExists( uri.getFSPath() ) ) ) {
if ( defer && mThreadPool ) {
mThreadPool->run( [this, uri, url, defer] {
auto resourceState = mAsyncResourceLoadState;
Uint64 resourceGeneration =
resourceState ? resourceState->generation.load( std::memory_order_acquire ) : 0;
URI baseURL = getURIFromURL( url );
mThreadPool->run( [resourceState, resourceGeneration, uri, url, defer,
baseURL = std::move( baseURL )] {
Clock c;
std::string filePath( uri.getFSPath() );
std::string css;
@@ -1581,14 +1654,14 @@ void UISceneNode::loadCSS( URI uri, std::optional<Time> defer ) {
CSS::StyleSheetParser parser;
if ( parser.loadFromString( css ) ) {
parser.getStyleSheet().setMarker( String::hash( url ) );
auto baseURL = getURIFromURL( url );
auto delay = defer.has_value() ? *defer - c.getElapsedTime() : Time::Zero;
if ( delay < Time::Zero )
delay = Time::Zero;
runOnMainThread(
[this, url, baseURL = std::move( baseURL ),
parser = std::move( parser )]() mutable {
combineStyleSheet( parser.getStyleSheet(), true, baseURL );
UISceneNode::runAsyncResourceOnMainThread(
resourceState, resourceGeneration,
[url, baseURL, parser = std::move( parser )](
UISceneNode* scene ) mutable {
scene->combineStyleSheet( parser.getStyleSheet(), true, baseURL );
Log::debug( "UISceneNode::loadCSS: Loaded - %s", url );
},
delay );
@@ -1605,24 +1678,24 @@ void UISceneNode::loadCSS( URI uri, std::optional<Time> defer ) {
}
} else if ( "http" == uri.getScheme() || "https" == uri.getScheme() ) {
auto resourceState = mAsyncResourceLoadState;
Uint64 resourceGeneration = resourceState ? resourceState->generation : 0;
Uint64 resourceGeneration =
resourceState ? resourceState->generation.load( std::memory_order_acquire ) : 0;
URI baseURL = getURIFromURL( url );
Http::getAsync(
[this, resourceState, resourceGeneration, url]( const Http&, Http::Request&,
Http::Response& response ) {
if ( !resourceState || !resourceState->alive ||
resourceState->generation != resourceGeneration )
[resourceState, resourceGeneration, url, baseURL = std::move( baseURL )](
const Http&, Http::Request&, Http::Response& response ) {
if ( !UISceneNode::isAsyncResourceLoadCurrent( resourceState,
resourceGeneration ) )
return;
if ( !response.getBody().empty() &&
response.getStatus() == Http::Response::Status::Ok ) {
std::string css( response.getBody() );
runOnMainThread( [resourceState, resourceGeneration, css = std::move( css ),
url = std::move( url ), this] {
if ( !resourceState || !resourceState->alive ||
resourceState->generation != resourceGeneration )
return;
combineStyleSheet( css, true, String::hash( url ), getURIFromURL( url ) );
Log::debug( "UISceneNode::loadCSS: Loaded - %s", url );
} );
UISceneNode::runAsyncResourceOnMainThread(
resourceState, resourceGeneration,
[css = std::move( css ), url, baseURL]( UISceneNode* scene ) mutable {
scene->combineStyleSheet( css, true, String::hash( url ), baseURL );
Log::debug( "UISceneNode::loadCSS: Loaded - %s", url );
} );
} else {
Log::debug( "UISceneNode::loadCSS: Failed to load %s - %s", url,
response.getStatusDescription() );
@@ -1835,7 +1908,41 @@ void UISceneNode::navigate( const NavigationRequest& request ) {
void UISceneNode::invalidateAsyncResourceLoads() {
if ( mAsyncResourceLoadState )
mAsyncResourceLoadState->generation++;
mAsyncResourceLoadState->generation.fetch_add( 1, std::memory_order_acq_rel );
}
std::shared_ptr<UISceneNode::AsyncResourceLoadState>
UISceneNode::getAsyncResourceLoadState() const {
return mAsyncResourceLoadState;
}
bool UISceneNode::isAsyncResourceLoadCurrent(
const std::shared_ptr<AsyncResourceLoadState>& resourceState, Uint64 generation ) {
if ( !resourceState || !resourceState->alive.load( std::memory_order_acquire ) ||
resourceState->generation.load( std::memory_order_acquire ) != generation )
return false;
return resourceState->owner.load( std::memory_order_acquire ) != nullptr;
}
void UISceneNode::runAsyncResourceOnMainThread(
const std::shared_ptr<AsyncResourceLoadState>& resourceState, Uint64 generation,
AsyncResourceMainThreadFunc func, const Time& delay ) {
if ( !func || !isAsyncResourceLoadCurrent( resourceState, generation ) )
return;
if ( Engine::isMainThread() && delay <= Time::Zero ) {
UISceneNode* owner = resourceState->owner.load( std::memory_order_acquire );
if ( owner )
func( owner );
return;
}
{
std::lock_guard<std::mutex> lock( sAsyncResourceMainThreadMutex );
sAsyncResourceMainThreadQueue.push_back(
{ resourceState, generation, std::move( func ), delay, Clock() } );
}
}
void UISceneNode::invalidate( Node* invalidator ) {

View File

@@ -77,6 +77,67 @@ static void expandWidgetContentExtent( UIWidget* widget, const Vector2f& offset,
extent.y = eemax( extent.y, position.y + size.getHeight() );
}
static bool syncWidgetDocumentPixelsSize( UIWidget* widget, const Sizef& size ) {
if ( !widget || widget->isClosing() )
return false;
Sizef next( size.getWidth(), size.getHeight() );
if ( next == widget->getPixelsSize() )
return false;
widget->setPixelsSize( next );
return true;
}
static Float getDocumentBodyPixelsHeight( UIWidget* body, const Sizef& extent ) {
return extent.getHeight();
}
static void syncDocumentBoxesPixelsSize( UIWidget* container, const Sizef& extent ) {
if ( !container || extent == Sizef::Zero )
return;
UIWidget* html = nullptr;
UIWidget* body = nullptr;
if ( auto htmlNode = container->findByType( UI_TYPE_HTML_HTML ) ) {
if ( htmlNode->isWidget() )
html = htmlNode->asType<UIWidget>();
}
if ( auto bodyNode = container->findByType( UI_TYPE_HTML_BODY ) ) {
if ( bodyNode->isWidget() )
body = bodyNode->asType<UIWidget>();
}
syncWidgetDocumentPixelsSize( container, extent );
syncWidgetDocumentPixelsSize( html, extent );
if ( body ) {
if ( body->isType( UI_TYPE_HTML_BODY ) )
body->asType<UIHTMLBody>()->setDocumentCanvasMinHeight(
PixelDensity::pxToDp( extent.getHeight() ) );
syncWidgetDocumentPixelsSize(
body, { extent.getWidth(), getDocumentBodyPixelsHeight( body, extent ) } );
}
}
static Sizef computeDocumentContentExtent( UIWidget* container, const Sizef& viewport ) {
Sizef extent( viewport.getWidth(), viewport.getHeight() );
if ( !container )
return extent;
if ( auto htmlNode = container->findByType( UI_TYPE_HTML_HTML ) ) {
UIWidget* html = htmlNode->asType<UIWidget>();
expandWidgetContentExtent( html, Vector2f::Zero, extent );
}
if ( auto bodyNode = container->findByType( UI_TYPE_HTML_BODY ) ) {
UIWidget* body = bodyNode->asType<UIWidget>();
expandWidgetContentExtent( body, Vector2f::Zero, extent );
expandDocumentContentExtent( body, container->getPixelsPosition(), extent );
}
return extent;
}
static void resetViewportDependentDocumentWidths( UIWidget* container ) {
if ( !container )
return;
@@ -517,6 +578,9 @@ void UIWebView::updateDocumentMetricsIfNeeded() {
return;
mUpdatingDocumentContentExtent = true;
LayoutInvalidationFlags requestedReasons = mDocumentExtentDirtyReasons;
mDocumentExtentDirty = false;
mDocumentExtentDirtyReasons = 0;
bool viewportChanged = !mUpdatingDocumentViewportMetrics && updateDocumentViewportMetrics();
Sizef viewport = mDocumentScene->getViewportPixelsSize();
@@ -524,11 +588,8 @@ void UIWebView::updateDocumentMetricsIfNeeded() {
viewport = mContainer ? mContainer->getPixelsSize() : getPixelsSize();
const bool documentReset =
mDocumentExtentDirtyReasons &
toLayoutInvalidationFlags( LayoutInvalidationReason::DocumentExtent );
requestedReasons & toLayoutInvalidationFlags( LayoutInvalidationReason::DocumentExtent );
if ( !viewportChanged && !documentReset ) {
mDocumentExtentDirty = false;
mDocumentExtentDirtyReasons = 0;
mUpdatingDocumentContentExtent = false;
return;
}
@@ -547,30 +608,24 @@ void UIWebView::updateDocumentMetricsIfNeeded() {
mDocumentScene->flushDirtyStyleAndLayout();
Sizef extent( viewport.getWidth(), viewport.getHeight() );
auto applyDocumentExtent = [&]() {
Sizef extent = computeDocumentContentExtent( mDocContainer, viewport );
bool extentChanged = extent != Sizef::Zero && extent != mDocumentLayout->getPixelsSize();
if ( extentChanged ) {
mDocumentLayout->setPixelsSize( extent );
mDocumentScene->setPixelsSize( extent );
containerUpdate();
updateScroll();
}
syncDocumentBoxesPixelsSize( mDocContainer, extent );
return extentChanged;
};
if ( auto htmlNode = mDocContainer->findByType( UI_TYPE_HTML_HTML ) ) {
UIWidget* html = htmlNode->asType<UIWidget>();
expandWidgetContentExtent( html, Vector2f::Zero, extent );
if ( applyDocumentExtent() ) {
mDocumentScene->flushDirtyStyleAndLayout();
applyDocumentExtent();
}
if ( auto bodyNode = mDocContainer->findByType( UI_TYPE_HTML_BODY ) ) {
UIWidget* body = bodyNode->asType<UIWidget>();
expandWidgetContentExtent( body, Vector2f::Zero, extent );
expandDocumentContentExtent( body, mDocContainer->getPixelsPosition(), extent );
}
bool extentChanged = extent != Sizef::Zero && extent != mDocumentLayout->getPixelsSize();
if ( extentChanged ) {
mDocumentLayout->setPixelsSize( extent );
mDocumentScene->setPixelsSize( extent );
containerUpdate();
updateScroll();
}
mDocumentExtentDirty = false;
mDocumentExtentDirtyReasons = 0;
mUpdatingDocumentContentExtent = false;
}

File diff suppressed because it is too large Load Diff