diff --git a/.agent/plans/uiwebview_document_scene_layout_refactor.md b/.agent/plans/uiwebview_document_scene_layout_refactor.md new file mode 100644 index 000000000..d920f3d3e --- /dev/null +++ b/.agent/plans/uiwebview_document_scene_layout_refactor.md @@ -0,0 +1,491 @@ +# UIWebView Document Scene Layout Refactor Plan + +> Status: REVISED - use a real layout widget as the scroll target, while keeping the +> owned `UISceneNode` nested under that widget as the document boundary. +> +> This supersedes the earlier "make `UISceneNode` behave as a layout" attempt and +> corrects the overcorrection where the scene was removed from the widget tree entirely. + +--- + +## Lessons From Failed Attempts + +The first approach tried to make `UISceneNode` participate directly in the layout system: + +1. Override `isLayout()` on `UISceneNode`. +2. Give it layout policies such as `MatchParent` width and `WrapContent` height. +3. Let the scene root (`mRoot`, a `UIRoot`) act as the document container. + +That approach hit real class-hierarchy dead ends. + +### Dead End 1: UISceneNode Is Not A UIWidget + +`UISceneNode` inherits from `SceneNode`. `UILayout` inherits from `UIWidget`. Returning +`true` from `isLayout()` does not make layout APIs available: `updateLayoutTree()`, +`setInternalPixelsSize()`, layout margins, padding, and size policies are widget/layout +contracts that `SceneNode` does not implement. + +### Dead End 2: UIRoot Is Not A UILayout + +Making `mRoot` the document container also fails. `UIRoot` extends `UIWidget`, not +`UILayout`; it does not auto-pack children. Once content grows, manual size propagation +is still required. + +### Dead End 3: Scroll Extent Became Layout Viewport + +The document has at least three separate metrics: + +- **CSS viewport:** the visible `UIScrollView::mContainer` size. Media queries, + `vw`/`vh`, fixed positioning, sticky positioning, and document minimum height use it. +- **Layout viewport / initial containing block:** the viewport-sized reference used by + root/body auto-width layout and percentage descendants. +- **Scrollable overflow extent:** the measured document overflow size used by + `UIScrollView` for scroll ranges. + +When the scene itself is the scroll target, `UISceneNode::onSizeChange()` naturally +resizes `mRoot` to the scene extent. That makes scroll extent become the containing +block, so a previous wide document can keep responsive content artificially wide after +the web view shrinks. + +### Dead End 4: Removing Viewport Style Invalidation Breaks CSS Units + +Viewport-dependent style values such as `height: 100vh` and media query results must +recompute when the viewport changes. A full `reloadStyle(true, true, true)` is expensive, +but removing viewport invalidation entirely is incorrect. The implementation needs a +targeted viewport/style/layout dirty path. + +### Dead End 5: Removing Event-Driven Viewport Sync Causes Multi-Step Resize + +Container size and scrollbar visibility changes must be observed promptly. Relying only +on a later scheduled update can produce visible intermediate states at the old viewport +width. The fix is not per-frame polling; it is a dirty, synchronous style/layout flush +for viewport geometry changes that does not run actions or arbitrary node updates. + +### Dead End 6: Scene Outside The Widget Tree Breaks Ownership + +The inverted plan originally proposed a scene owned internally by a layout widget but +not present in the widget tree. That avoids `UISceneNode` as scroll target, but creates +new hard problems: + +- document widgets no longer naturally return the document scene from `getUISceneNode()` + and `getSceneNode()`; +- rendering must be manually forwarded or redirected; +- input hit testing must be manually bridged; +- focus and invalidation need custom routing; +- inspector/debug traversal becomes special-case heavy. + +That is too much new machinery for the first correct `UIWebView` implementation. + +--- + +## Corrected Architecture + +Use a real layout widget as the scroll target, but keep the document scene nested in +the widget tree below that scroll target. + +```text +application UISceneNode +└── ... application widgets ... + └── UIWebView + └── UIScrollView::mContainer host-scene clipped viewport + └── UIWebView::mDocumentLayout UILayout scroll target, scroll-extent sized + └── UIWebView::mDocumentScene owned UISceneNode, document boundary + └── UISceneNode::mRoot root sized by layout viewport policy + └── UIWebView::mDocContainer document layout container + └── html + └── body + └── document content +``` + +### Key Decisions + +1. **`mDocumentLayout` is the scroll target.** It is a normal layout/widget child of + `UIWebView`, so `UIScrollView::onChildCountChange()` reparents it into + `mContainer` and uses it as `mScrollView`. + +2. **`mDocumentScene` remains in the widget tree.** It is a child of + `mDocumentLayout`, not a direct child of `UIWebView`. This preserves rendering, + hit testing, invalidation propagation, focus routing through the shared dispatcher, + and the core invariant that document widgets resolve to the document scene. + +3. **`mDocumentLayout` owns scroll extent.** Its pixel size is the size observed by + `UIScrollView` for scrollbar calculations. It must be at least the visible viewport + and may grow from measured overflow. + +4. **`mDocumentScene` owns document state.** Stylesheet, URI, referer, cookies, + navigation callback, dirty queues, actions, keyframes, and author font aliases remain + scene-local. + +5. **The scene root must not inherit scroll extent as its layout viewport.** The scene + may need a scroll-target size for world bounds and hit testing, but `mRoot`/HTML/body + layout must use the layout viewport, not the measured overflow extent. Implement this + with explicit viewport/layout-viewport metrics or a root-sizing policy on embedded + document scenes. + +6. **Manual extent measurement remains, but it is dirty-driven.** Horizontal overflow, + images, fonts, tables, fixed-width content, and replaced controls still need measured + scroll extent. The difference is that extent measurement is tied to explicit dirty + signals, not a full descendant-tree scan every frame. + +--- + +## Why This Architecture Is Better + +| Problem | Failed scene-as-layout approach | Corrected approach | +|---|---|---| +| `UISceneNode` is not a layout | Tried to make it act like `UILayout` | `mDocumentLayout` is the layout/scroll target | +| `UIRoot` is not a layout | Tried to make root auto-pack content | `mDocContainer` remains a real `UILinearLayout` | +| Scroll extent vs viewport | Scene size resized root to extent | Layout viewport and scroll extent are separate | +| Document isolation | Owned scene provided isolation | Owned nested scene still provides isolation | +| Rendering/input | Worked because scene was in tree | Still works because scene remains in tree | +| Resize responsiveness | Needed event sync | Uses event dirtying plus narrow style/layout flush | +| Performance | Tended toward per-frame scans/cascades | Dirty-driven extent measurement | + +--- + +## Current Implementation Status + +### Implemented + +- `UIWebView` now creates `mDocumentLayout` as the real `UIScrollView` scroll target. +- The owned `mDocumentScene` is nested under `mDocumentLayout`, so document widgets + still resolve to the document scene while normal scroll-view clipping and scrolling + operate on a widget/layout node. +- `UISceneNode` has explicit CSS viewport and layout viewport metrics. Embedded + document scenes can keep `mRoot` viewport-sized while the scene/layout scroll extent + grows to the measured document overflow. +- `UISceneNode::flushDirtyStyleAndLayout()` exists as the narrow dirty flush. It + processes dirty styles, style states, and layouts without running actions, timers, + scheduled updates, or a full scene update. +- `UIWebView` has dirty-driven document metric updates through + `markDocumentExtentDirty()` / `updateDocumentMetricsIfNeeded()`. +- Viewport changes are coalesced into the scheduled update path. The CSS viewport is + derived from current web-view geometry and scrollbar visibility, not from queued + `UIScrollView::mContainer` size updates. +- Scroll extent measurement resizes `mDocumentLayout` and `mDocumentScene`, while + root/html/body layout remains viewport-sized. +- 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. + +### Pending / Follow-Up + +- **Cleaner hit-test bounds model.** The current implementation overrides + `UISceneNode::overFind()` for scenes with an explicit layout viewport. This is a + narrow compatibility hook: when the viewport-sized `mRoot` rejects a point, the scene + searches root children directly so visible scrolled document descendants can still be + hit. The cleaner long-term shape is to split layout bounds from hit-test traversal + bounds, likely on `UIRoot` or `Node`, so root can remain the layout viewport while + hit testing can intentionally traverse visible overflow without a `UISceneNode` + special case. +- **Author `@font-face` isolation and cleanup** still needs the full scene-local alias + registry and navigation cleanup described in Phase 5. +- **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. + +--- + +## Required Supporting API + +### UISceneNode Viewport And Root Sizing + +Keep or add explicit viewport metrics: + +```cpp +void setViewportPixelsSize( const Sizef& size ); +void clearViewportPixelsSize(); +const Sizef& getViewportPixelsSize() const; +``` + +Add one of the following: + +```cpp +void setLayoutViewportPixelsSize( const Sizef& size ); +void clearLayoutViewportPixelsSize(); +const Sizef& getLayoutViewportPixelsSize() const; +``` + +or an equivalent embedded-document root sizing policy. The invariant is more important +than the exact API: setting the scrollable extent must not resize `mRoot` into the +normal-flow containing block. + +### Narrow Dirty Flush + +Add a narrow flush API: + +```cpp +void UISceneNode::flushDirtyStyleAndLayout(); +``` + +It should process dirty styles, style states, and layouts using the same invalidation +depth semantics as `UISceneNode::update()`, but it must not call `SceneNode::update()` +and must not run actions, timers, scheduled updates, or arbitrary node update callbacks. + +`UIWebView` uses this after viewport geometry changes and before measuring scroll +extent. + +### Document Extent Dirtying + +Add dirty state owned by `UIWebView`: + +```cpp +bool mDocumentExtentDirty{ true }; +LayoutInvalidationFlags mDocumentExtentDirtyReasons{}; +void markDocumentExtentDirty( LayoutInvalidationFlags reasons ); +void updateDocumentMetricsIfNeeded(); +``` + +Mark extent dirty when: + +- `mContainer` size changes; +- scrollbar visibility changes and the scroll view type consumes viewport space; +- document children are loaded or closed; +- inline or external CSS applies; +- media queries change; +- images/replaced controls change intrinsic size; +- author fonts load; +- layout invalidation includes document, viewport, or overflow-affecting reasons; +- table/flex/grid/rich-text layout reports size-affecting changes. + +Do not recompute document extent from the normal scheduled update path unless this flag +is set. + +--- + +## Implementation Plan + +### Phase 1: Add DocumentLayout Scroll Target + +**Files:** + +- `include/eepp/ui/uiwebview.hpp` +- `src/eepp/ui/uiwebview.cpp` + +Steps: + +1. Add `UILayout* mDocumentLayout` or a narrow internal subclass if a hook is needed. +2. Construct `mDocumentLayout` as the only direct scroll child of `UIWebView`. +3. Give `mDocumentLayout` `MatchParent` width and `WrapContent` height defaults. +4. Move `mDocumentScene` under `mDocumentLayout`, not directly under `UIWebView`. +5. Keep `mDocContainer` under `mDocumentScene->getRoot()`. +6. Ensure `mDocContainer`, `html`, `body`, and descendants still return + `mDocumentScene` from `getUISceneNode()` and `getSceneNode()`. + +Tests: + +- `UIScrollView::getScrollView()` returns `mDocumentLayout`. +- `mDocumentScene->getParent()` is `mDocumentLayout`. +- Document descendants belong to the document scene. +- The document scene is not registered with `SceneManager`. + +### Phase 2: Separate Viewport, Layout Viewport, And Scroll Extent + +**Files:** + +- `include/eepp/ui/uiscenenode.hpp` +- `src/eepp/ui/uiscenenode.cpp` +- `src/eepp/ui/uinode.cpp` +- `src/eepp/ui/uiwebview.cpp` + +Steps: + +1. Keep `getViewportPixelsSize()` for media queries and `vw`/`vh`. +2. Add layout-viewport/root-sizing support so `mRoot` and normal root/body layout use + the visible viewport even when scroll extent is larger. +3. Update `UINode::convertLength()` so viewport units resolve through the document + scene viewport. +4. Update HTML/body minimum-height handling to use the CSS viewport. +5. Ensure fixed/sticky positioning uses the visible scroll viewport, not measured + document extent. + +Tests: + +- `100vh` resolves to the visible viewport height while scroll extent is much taller. +- After growing wide and shrinking, `html`, `body`, `width:100vw`, and `width:100%` + content resolve to the new viewport. +- Explicit fixed-width overflow still creates horizontal scroll. +- `position: fixed` remains pinned to the web-view viewport while scrolling. +- `position: sticky` uses the web-view viewport while scrolling. + +### Phase 3: Add Narrow Dirty Flush And Dirty-Driven Extent Measurement + +**Files:** + +- `include/eepp/ui/uiscenenode.hpp` +- `src/eepp/ui/uiscenenode.cpp` +- `include/eepp/ui/uiwebview.hpp` +- `src/eepp/ui/uiwebview.cpp` + +Steps: + +1. Add `UISceneNode::flushDirtyStyleAndLayout()`. +2. Add `UIWebView::markDocumentExtentDirty()`. +3. Add `UIWebView::updateDocumentMetricsIfNeeded()`. +4. On viewport geometry changes, update scene viewport/layout viewport, dirty + viewport-dependent document layout, flush style/layout, measure overflow, resize + `mDocumentLayout`, then update scrollbars. +5. In `scheduledUpdate()`, call `UITouchDraggableWidget::scheduledUpdate(time)`, then + update the document scene exactly once. After that, call + `updateDocumentMetricsIfNeeded()` only if dirty. +6. Remove any use of `mDocumentScene->update(Time::Zero)` for synchronous layout + settlement. +7. Avoid full descendant-tree extent scans when no dirty signal occurred. + +Tests: + +- Viewport change settles in one frame without a second full scene update. +- Actions/timers/scheduled node updates are not run by `flushDirtyStyleAndLayout()`. +- A stable large document does not remeasure extent every frame. +- Hidden and clipped wide descendants do not create horizontal scroll. + +### Phase 4: Route Document Operations Through The Owned Scene + +**Files:** + +- `src/eepp/ui/uiwebview.cpp` +- `src/examples/ui_html/ui_html.cpp` + +Steps: + +1. Use `mDocumentScene` for stylesheet cleanup, URI/referer, cookie manager, + navigation interception, document loading, relative URL resolution, and HTML/body + lookup. +2. On navigation, close only `mDocContainer` children. +3. Remove only document-scene nonpersistent stylesheet rules. +4. Clear previous document-local author font aliases/resources. +5. Mark document extent dirty after navigation and after document CSS injection. +6. Install the document navigation interceptor once during scene setup, not on every + document load. +7. Update examples to combine document CSS into + `webView->getDocumentSceneNode()`. + +Tests: + +- Two web views with conflicting selectors, IDs, CSS variables, keyframes, and + `@font-face` names remain isolated. +- Navigating one web view does not mutate the other web view or the host scene. +- Application-level HTML lookup does not find web-view document nodes. +- Document-scene lookup does find document nodes. +- External CSS with relative URLs resolves from the correct document URI. + +### Phase 5: Author Font-Face Isolation And Cleanup + +**Files:** + +- `include/eepp/ui/uiscenenode.hpp` +- `src/eepp/ui/uiscenenode.cpp` + +Steps: + +1. Add a scene-local font-face alias registry keyed by CSS family, style, and weight. +2. Register loaded author fonts under scene-unique internal names. +3. Resolve author font aliases before global `FontManager` fallback. +4. Add `clearAuthorFontFaces()` and call it during navigation. +5. Remove only this scene's internally registered author fonts during scene destruction. +6. Mark document extent dirty after a font load can affect metrics. + +Tests: + +- Two documents can declare the same family name with different files. +- Navigating one document clears and replaces only its own author font alias. +- Destroying one web view does not remove sibling fonts. +- Global application/system font fallback still works. + +### Phase 6: Lifetime-Safe Navigation And Subresources + +**Files:** + +- `include/eepp/ui/uiwebview.hpp` +- `src/eepp/ui/uiwebview.cpp` +- `include/eepp/ui/uiscenenode.hpp` +- `src/eepp/ui/uiscenenode.cpp` + +Steps: + +1. Add navigation generation state for top-level document loads. +2. Add scene-local async resource generation state for subresources. +3. Invalidate document responses and subresources on navigation. +4. Invalidate everything on destruction. +5. Apply generation checks before cookies, style mutation, font registration, image + mutation, main-thread repost mutation, navigation events, or extent dirtying. +6. Cover HTTP, redirects, external CSS, deferred local CSS, VFS CSS, remote/local/data + fonts, and async image/replaced resources. + +Tests: + +- A stale document response cannot replace a newer document. +- Stale redirects/cookies do not mutate the current document. +- Deferred local CSS after navigation/destruction is ignored safely. +- Old `@font-face` and image responses cannot mutate current document state. + +### Phase 7: Cleanup, Documentation, And Integration + +**Files:** + +- `.agent/rules/html-layout-architecture.md` +- `.agent/plans/uiwebview_document_scene_plan.md` +- `src/tests/unit_tests/uiwebview_tests.cpp` +- `src/tests/unit_tests/uihtml_tests.cpp` + +Steps: + +1. Document the final tree and the three document metrics. +2. Remove obsolete viewport/extent sync methods and state booleans. +3. Keep only event listeners that mark viewport/extent dirty. +4. Update tests that assumed the document scene itself was the scroll target. +5. Verify inspector/debug tooling can target either the host scene or + `getDocumentSceneNode()`. + +--- + +## Files Expected To Change + +| File | Change | +|---|---| +| `include/eepp/ui/uiwebview.hpp` | Add document layout scroll target, dirty extent state, lifetime state | +| `src/eepp/ui/uiwebview.cpp` | Use layout widget as scroll target; route document operations to owned scene; dirty-driven extent measurement | +| `include/eepp/ui/uiscenenode.hpp` | Viewport/layout viewport metrics, narrow dirty flush, resource lifetime helpers | +| `src/eepp/ui/uiscenenode.cpp` | Root sizing policy, dirty flush, author font aliases, guarded subresource callbacks | +| `src/eepp/ui/uinode.cpp` | Resolve viewport units from document viewport | +| `src/examples/ui_html/ui_html.cpp` | Inject document CSS through document scene | +| `src/tests/unit_tests/uiwebview_tests.cpp` | Scroll target, isolation, viewport, fixed/sticky, performance, lifecycle tests | +| `src/tests/unit_tests/uihtml_tests.cpp` | Realistic smoke/regression fixture adjustments | +| `.agent/rules/html-layout-architecture.md` | Final architecture documentation | + +--- + +## Validation + +```sh +make -C make/linux -j$(nproc) +projects/scripts/xvfb-run-eepp bin/unit_tests/eepp-unit_tests-debug --filter="UIWebView.*" +projects/scripts/xvfb-run-eepp \ + bin/unit_tests/eepp-unit_tests-debug --filter="UIHTML.redditOldThreadWebViewSmoke" +git diff --check +``` + +Run the full unit suite after focused viewport, scrolling, positioning, resource, and +two-web-view isolation tests pass: + +```sh +projects/scripts/xvfb-run-eepp bin/unit_tests/eepp-unit_tests-debug +``` + +--- + +## Completion Criteria + +Two `UIWebView` instances can coexist in one application scene with independent DOM, +styles, URI/referer, navigation, cookies, font faces, resources, input, focus, layout, +and scrolling. + +The scroll target is a real layout widget. The owned document scene remains nested as +the document state boundary. CSS viewport, layout viewport, and scrollable overflow +extent are distinct. Viewport changes settle through a narrow style/layout flush, not a +second scene update. Stable documents do not rescan full content every frame. diff --git a/.agent/plans/uiwebview_document_scene_plan.md b/.agent/plans/uiwebview_document_scene_plan.md index 9637afdff..b459bcfee 100644 --- a/.agent/plans/uiwebview_document_scene_plan.md +++ b/.agent/plans/uiwebview_document_scene_plan.md @@ -11,8 +11,13 @@ media-query evaluation, dirty style/layout queues, and document resources must u instead of the application `UISceneNode`. The owned scene remains attached inside the application scene tree so it renders and receives input -as part of the embedding UI. This establishes the document boundary needed for multiple independent -web views and is the foundation for future `iframe` support. +as part of the embedding UI. A real layout widget, not the scene node itself, is the scroll target. +This establishes the document boundary needed for multiple independent web views and is the +foundation for future `iframe` support. + +This plan should be implemented together with +`.agent/plans/uiwebview_document_scene_layout_refactor.md`, which details the corrected scroll-target +architecture. ## Required Invariants @@ -24,11 +29,17 @@ After implementation: referer, navigation callback, dirty queues, or document lookup results. - Every loaded HTML widget returns the web view's owned scene from `getUISceneNode()` and `getSceneNode()`. -- The owned scene is attached to the application tree but is not registered as a top-level - `SceneManager` scene. -- The owned scene size follows the scrollable HTML/body document extent. +- The owned scene is attached below the web view document layout widget but is not registered as a + top-level `SceneManager` scene. +- The web view document layout widget is the `UIScrollView` scroll target and follows the scrollable + HTML/body document extent. - The document scene separately stores the visible web-view viewport size for viewport units, media queries, document minimum height, and fixed-position semantics. +- The document scene distinguishes CSS viewport, layout viewport / initial containing block, and + scrollable overflow extent. Scroll extent must not become the containing block used for normal + root/body layout. +- Document scroll extent is recomputed only from explicit dirty signals, not by scanning the full + document every frame. - Input and focus continue through the embedding application's shared `UIEventDispatcher`. - Closing or destroying the web view destroys its owned scene and document without leaving async callbacks, actions, listeners, or dirty pointers behind. @@ -70,21 +81,32 @@ An owned web-view scene must not require top-level `SceneManager` registration. ### Viewport And Scroll Extent Are Currently Conflated -The owned scene must be the `UIScrollView` scroll target and must grow to the HTML/body document -extent. For example, a 600px-tall web view displaying a 3000px-tall page needs a 3000px-tall -`mDocumentScene` so `UIScrollView` can derive the correct scroll range. +The owned document scene must expose the HTML/body scrollable overflow extent to a real layout-widget +scroll target. For example, a 600px-tall web view displaying a 3000px-tall page needs a 3000px-tall +scroll target so `UIScrollView` can derive the correct scroll range. The scroll target should be a +`UILayout`/`UIWidget` owned by `UIWebView`, not `UISceneNode` itself. -`UISceneNode` currently also uses its own size for viewport-dependent CSS behavior. Once the scene is -content-sized, those uses must not read the scene extent: +`UISceneNode` currently also uses its own size for viewport-dependent CSS behavior. Once the document +has a separately measured scroll extent, those uses must not read the scroll extent: - `UISceneNode::getMediaFeatures()` must report the visible web-view viewport. - `vw`/`vh` and related viewport-relative lengths must resolve against the visible viewport. - HTML/body minimum height must use the visible viewport. -- Fixed-position layout must remain relative to the web-view viewport while the content-sized scene - is translated by scrolling. +- Fixed-position layout must remain relative to the web-view viewport while the scroll target is + translated by scrolling. -The design therefore needs separate scene extent and viewport metrics. The scene extent participates -in scrolling; the viewport size is provided by the embedding `UIWebView`. +The design therefore needs separate metrics: + +- **CSS viewport:** the visible `UIScrollView::mContainer` size. Media queries, `vw`/`vh`, fixed + positioning, sticky positioning, and document minimum height use this size. +- **Layout viewport / initial containing block:** the viewport-sized root layout reference used for + normal root/body auto-width layout. This prevents a previously measured scroll extent from becoming + the containing block and keeping responsive content artificially wide or tall. +- **Scrollable overflow extent:** the measured document overflow size used only as the scroll target + size observed by `UIScrollView`. + +The scroll extent participates in scrolling; the CSS and layout viewport are provided by the +embedding `UIWebView`. ## Proposed Scene Tree @@ -93,20 +115,23 @@ application UISceneNode └── ... application widgets ... └── UIWebView └── UIScrollView::mContainer host-scene clipping shell - └── UIWebView::mDocumentScene owned UISceneNode, content-sized scroll target - └── UISceneNode::mRoot owned scene root, content-sized - └── UIWebView::mDocContainer document layout container - └── html - └── body - └── document content + └── UIWebView::mDocumentLayout UILayout scroll target, scroll-extent sized + └── UIWebView::mDocumentScene owned UISceneNode, document boundary + └── UISceneNode::mRoot owned scene root, layout-viewport sized + └── UIWebView::mDocContainer + └── html + └── body + └── document content ``` -`mDocumentScene` is added as the `UIWebView` child through the normal `UIScrollView` path. +`mDocumentLayout` is added as the `UIWebView` child through the normal `UIScrollView` path. `UIScrollView::onChildCountChange()` reparents it into the clipped `mContainer` and selects it as the -scroll target. `mDocumentScene` grows to the document extent and is translated when scrolling. +scroll target. `mDocumentLayout` exposes the measured scrollable extent and is translated when +scrolling. The visible `mContainer` size is separately passed to `mDocumentScene` as its CSS viewport size. -Every document node, including `mDocContainer`, belongs to the owned scene. +The document scene root keeps a layout-viewport size unless an explicit API says a caller is setting +the scroll target extent. Every document node, including `mDocContainer`, belongs to the owned scene. ## Ownership And Service Policy @@ -172,7 +197,7 @@ persist across navigations inside this web view. The default remains the HTML de ## Implementation Plan -### Phase 1: Separate UISceneNode Extent From Viewport Metrics +### Phase 1: Separate Viewport, Layout Viewport, And Scroll Extent **Files:** @@ -195,24 +220,45 @@ Steps: When no override is set, `getViewportPixelsSize()` returns the scene's own pixel size so existing top-level and UI editor behavior remains unchanged. -2. Add a narrow way to disable the nested scene's automatic parent-size following. Preserve the - current default for existing nested scenes; `UIWebView` disables it because its document scene - extent is content-driven. -3. Make `UISceneNode::getMediaFeatures()` use `getViewportPixelsSize()` for viewport width/height +2. Add explicit layout-viewport metrics or an equivalent root-sizing policy for embedded document + scenes: + + ```cpp + void setLayoutViewportPixelsSize( const Sizef& size ); + void clearLayoutViewportPixelsSize(); + const Sizef& getLayoutViewportPixelsSize() const; + ``` + + If a separate field is not needed, this can be implemented as a document-scene root sizing policy + that keeps `UISceneNode::mRoot` viewport-sized while the document layout widget carries the scroll + target extent. The important invariant is that setting the scrollable extent must not resize the + root containing block used by normal HTML/body layout. +3. Add a narrow way to disable the nested scene's automatic parent-size following or override root + sizing for embedded document scenes. Preserve the current default for existing nested scenes; + `UIWebView` must prevent automatic parent-size following from turning scroll extent into the root + layout viewport. +4. Make `UISceneNode::getMediaFeatures()` use `getViewportPixelsSize()` for viewport width/height while keeping device metrics from the window. -4. Audit viewport-relative length conversion. Replace direct `getSceneNode()->getPixelsSize()` use +5. Audit viewport-relative length conversion. Replace direct `getSceneNode()->getPixelsSize()` use for CSS viewport units with the owning `UISceneNode` viewport metrics. -5. Make HTML/body viewport minimum-height calculations use the document scene viewport metrics. -6. Keep ordinary scene/root sizing and world bounds based on the actual content-sized scene extent. +6. Make HTML/body viewport minimum-height calculations use the document scene viewport metrics. +7. Keep ordinary top-level scene/root sizing unchanged, but for embedded document scenes make root + sizing use the layout viewport while world bounds and scroll target size use the scrollable extent. +8. Document the split close to `UISceneNode::onSizeChange()` or the new root-sizing policy because + future changes to scene sizing can easily reintroduce root-as-scroll-extent behavior. Tests: - A normal scene without a viewport override preserves existing size-based behavior. - A 3000px-tall scene with a 600px viewport reports 600px media height and resolves `100vh` to 600px. -- HTML/body minimum height uses the viewport while document/root extent can grow beyond it. -- Changing viewport width re-evaluates media queries without forcing the scene extent to viewport +- HTML/body minimum height uses the viewport while the document scroll extent can grow beyond it. +- Changing viewport width re-evaluates media queries without forcing the scroll extent to viewport height. +- Growing the scroll extent does not make `html`, `body`, or viewport-width descendants keep a stale + wide containing block after the web view later shrinks. +- Explicit overflow content can grow the scroll extent while `width: 100vw`, `width: 100%`, and root + auto-width layout continue resolving against the layout viewport. ### Phase 2: Harden Embedded UISceneNode Behavior @@ -251,38 +297,92 @@ Tests: Steps: -1. Add `UISceneNode* mDocumentScene`. -2. Construct the owned scene with the host window, initialize its embedded services, and add it as - the `UIWebView` scroll child. Let the normal `UIScrollView` path reparent it into the clipped - `mContainer` and select it as the scroll target. -3. Create `mDocContainer` under `mDocumentScene->getRoot()`, preserving its current vertical, +1. Add `UISceneNode* mDocumentScene` and `UILayout* mDocumentLayout` (or a narrow internal + `DocumentLayout` subclass if hooks are needed). +2. Construct `mDocumentLayout` as the only direct `UIWebView` scroll child. Let the normal + `UIScrollView` path reparent it into the clipped `mContainer` and select it as the scroll target. +3. Construct the owned scene with the host window, initialize its embedded services, and parent it + under `mDocumentLayout` so rendering, hit testing, invalidation, and document-scene ownership flow + through the normal tree. +4. Create `mDocContainer` under `mDocumentScene->getRoot()`, preserving its current vertical, match-width, wrap-content behavior and white initial background. -4. Disable automatic parent-size following for `mDocumentScene`. -5. Feed the visible `UIScrollView::mContainer` size into - `mDocumentScene->setViewportPixelsSize()`. -6. Size `mDocumentScene` to at least the viewport width/height and grow it to the laid-out - HTML/body/document extent. Its size is the size observed by `UIScrollView` for scrollbar - calculations. -7. Listen for document extent and visible-container changes so scene extent, viewport metrics, - media queries, and scrollbar state are updated in the correct order without layout loops. -8. Override `scheduledUpdate()` in `UIWebView`, call the inherited +5. Disable automatic parent-size following for `mDocumentScene` if following would make scene/root + sizing consume the scroll extent. Prefer an explicit embedded-document root sizing policy that + keeps root layout viewport-sized. +6. Feed the visible `UIScrollView::mContainer` size into + `mDocumentScene->setViewportPixelsSize()` and the document root/layout viewport sizing API. +7. Size `mDocumentLayout` to at least the viewport width/height and grow it to the measured + scrollable overflow extent. This size is the size observed by `UIScrollView` for scrollbar + calculations, but it must not become the root layout containing block. +8. Add a narrow scene flush API that processes dirty styles, dirty style states, and dirty layouts + without running actions, scheduled updates, timers, or arbitrary node updates: + + ```cpp + void UISceneNode::flushDirtyStyleAndLayout(); + ``` + + Internally this should reuse the existing dirty queues and invalidation-depth behavior used by + `UISceneNode::update()`, but stop before `SceneNode::update(elapsed)`. `UIWebView` uses this when + viewport geometry changes need a synchronous style/layout settlement before measuring scroll + extent. Do not call `mDocumentScene->update(Time::Zero)` for this purpose. +9. Add a document-extent dirty flag owned by `UIWebView`, for example: + + ```cpp + bool mDocumentExtentDirty{ true }; + LayoutInvalidationFlags mDocumentExtentDirtyReasons{}; + void markDocumentExtentDirty( LayoutInvalidationFlags reasons ); + ``` + + Mark it dirty when viewport metrics change, document children are loaded/closed, external CSS + applies, images/fonts/replaced controls change intrinsic size, layout invalidation includes + document/viewport/overflow-affecting reasons, or the scroll target size itself changes. +10. Recompute scrollable extent only when the document-extent dirty flag is set. The recomputation + sequence should be: + + - update CSS/layout viewport metrics from `mContainer`, + - if viewport changed, mark root/html/body dirty and invalidate viewport-dependent intrinsic + widths, + - call `mDocumentScene->flushDirtyStyleAndLayout()`, + - measure visible/unclipped scrollable overflow from html/body/document descendants, + - set only `mDocumentLayout` / scroll-target size to the measured extent, + - call `UIScrollView::containerUpdate()` / `updateScroll()` as needed, + - clear the dirty flag only after the measured extent is stable for that pass. + + Avoid full-document extent scans from the normal per-frame path when no dirty signal occurred. +11. Listen for document extent and visible-container changes so scroll-target extent, viewport + metrics, media queries, scrollbar state, and root/body layout are updated in the correct order + without layout loops. Treat scrollbar visibility changes as viewport changes only when the scroll + view type makes scrollbars consume viewport space. +12. Override `scheduledUpdate()` in `UIWebView`, call the inherited `UITouchDraggableWidget::scheduledUpdate()` behavior, and then call `mDocumentScene->update(elapsed)` exactly once per host-scene frame. `UIScrollView` is already subscribed through `UITouchDraggableWidget`; do not add a second subscription or add the document - scene to `SceneManager`. -9. Expose `getDocumentSceneNode()`. -10. On web-view scene changes, reinitialize/rebind the document scene's inherited services without + scene to `SceneManager`. If document extent is dirty after the update, run the narrow + dirty-style/layout flush and extent measurement path; do not run a second full scene update. +13. Expose `getDocumentSceneNode()`. +14. On web-view scene changes, reinitialize/rebind the document scene's inherited services without touching document-owned state. Tests: - The document scene is attached below the host scene but absent from `SceneManager`. +- The `UIScrollView` scroll target is `mDocumentLayout`, not `mDocumentScene`. +- `mDocumentScene` is parented below `mDocumentLayout` and remains the scene owner for descendants. - `mDocContainer`, `html`, `body`, and descendants all belong to the document scene. -- A page taller than the viewport grows the document scene and produces the correct scroll range. -- Scrolling translates the content-sized document scene inside the fixed `UIScrollView` viewport. +- A page taller than the viewport grows the document layout scroll target and produces the correct + scroll range. +- Scrolling translates the document layout, including the nested document scene, inside the fixed + `UIScrollView` viewport. - `vh`/`vw`, viewport media queries, fixed elements, and sticky elements continue using the visible - web-view viewport rather than the document scene extent while the scene is scrolled. + web-view viewport rather than the document scroll extent while the document is scrolled. - Document actions/animations and dirty style/layout queues update through the web view. +- A viewport change can synchronously settle style/layout through `flushDirtyStyleAndLayout()` but + does not run actions, timers, scheduled updates, or arbitrary node update callbacks twice in one + host frame. +- A large stable document does not rescan its full descendant tree every frame when no style, layout, + viewport, image, font, or replaced-control dirty signal occurred. +- After growing to a very wide viewport and then shrinking, root/body/`width:100vw` descendants + resolve against the new viewport, while explicit wide overflow still creates horizontal scroll. ### Phase 4: Route All Document Operations To The Owned Scene @@ -303,13 +403,17 @@ Steps: widget. 4. Install the navigation interceptor once during document-scene setup. It must resolve and navigate through the owned scene and web view, not replace a host callback on every load. -5. On navigation, close only `mDocContainer` children and remove only nonpersistent rules from the - document scene. +5. On navigation, close only `mDocContainer` children, remove only nonpersistent rules from the + document scene, clear document-local author font aliases/resources from the previous document, and + mark document extent dirty. 6. Update the HTML example's injected Hacker News stylesheet to combine into `webView->getDocumentSceneNode()`. 7. Audit document descendants that use `getUISceneNode()` for links, forms, images, inline styles, external CSS, fonts, and relative URLs. They should work without special cases once parentage is correct. +8. Ensure any document-scoped style injection, including application-provided user CSS, calls the + document-scene style API and then marks the owning `UIWebView` document extent dirty when layout + may be affected. Tests: @@ -323,6 +427,10 @@ Tests: correct document URI. - Application-level `findByType(UI_TYPE_HTML_HTML)` no longer returns a web-view document node; document-scene lookup does. +- Two web views can load external CSS files with the same selectors and relative URLs without either + stylesheet, URL base, or late style application leaking into the other document. +- Injecting document CSS through `getDocumentSceneNode()->combineStyleSheet(...)` relayouts and + remeasures the document extent, while injecting host CSS does not affect document descendants. ### Phase 5: Isolate Author Font Faces @@ -343,14 +451,23 @@ Steps: 3. Register author fonts under an internal scene-unique resource name if `FontManager` registration remains required, while preserving the author-visible family only in the scene-local alias. 4. Keep generic/system fonts and explicitly shared application defaults as global fallbacks. -5. Remove only the scene's internally registered author fonts during scene destruction. -6. Apply the async scene-lifetime guard from the next phase to remote `@font-face` loads. +5. Add an explicit `clearDocumentFontFaces()` / `clearAuthorFontFaces()` operation used during + navigation before new document CSS is loaded. It removes only this scene's internally registered + author fonts and clears aliases; it must not remove application/system fonts or sibling-document + author fonts. +6. Remove any remaining scene-owned author fonts during scene destruction. +7. Apply the async scene-lifetime guard from the next phase to remote, local deferred, and VFS + `@font-face` loads. +8. Mark the owning web-view document extent dirty after a loaded font is registered because font + metrics can change line wrapping, table sizing, and scroll extent. Tests: - Two web views can declare the same family name with different font files and each resolves its own font. - Destroying or navigating one web view does not remove or replace the sibling's author font. +- Navigating one web view clears its previous document-local author font alias and replaces it with + the new document's face without leaving the old alias visible to CSS. - Application/system font lookup still works when no document-local face matches. ### Phase 6: Make Navigation And Resource Loading Lifetime-Safe @@ -363,23 +480,41 @@ Tests: - `src/eepp/ui/uiscenenode.cpp` - `src/tests/unit_tests/uiwebview_tests.cpp` -The current async HTTP callbacks capture raw `this` and a raw scene pointer. Owning a scene makes -correct cancellation and stale-response handling part of the document lifecycle. The same audit -must cover async document resources such as `UISceneNode::loadCSS()`, which currently captures the -scene directly. +The current async/deferred callbacks can capture raw `this` and raw scene pointers. Owning a scene +makes correct cancellation and stale-response handling part of the document lifecycle. The audit must +cover every deferred document resource path, not only HTTP: + +- top-level HTTP document navigation, +- HTTP redirects and cookies, +- external HTTP CSS, +- deferred local-file CSS through the thread pool, +- VFS CSS if it can become deferred later, +- HTTP, local-file, data URI, and VFS `@font-face`, +- async image/replaced-resource loads, +- main-thread reposts created by any of the above. Steps: 1. Add a small shared navigation/load state containing an alive owner pointer and monotonically increasing navigation generation. -2. Async callbacks capture a weak load state, not raw `this` or raw scene pointers. -3. Before storing cookies, posting to the main thread, or applying a response, verify that the web - view still exists and the generation is current. -4. A newer navigation invalidates older responses. Destruction invalidates all pending callbacks. -5. Keep history mutation and navigation events deterministic when requests fail or are superseded. -6. Give document-scene async resource loads a scene-lifetime guard before they enqueue main-thread +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 + extent dirty, send navigation events, or trigger layout. Tests: @@ -387,6 +522,11 @@ Tests: - 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. ### Phase 7: Integration And Documentation @@ -416,7 +556,7 @@ Steps: | `src/eepp/ui/uinode.cpp` | Resolve viewport units from document viewport instead of scene extent | | `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` | Content-sized scene scroll target, viewport updates, isolated loading/navigation | +| `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/tests/unit_tests/uiwebview_tests.cpp` | New focused isolation/lifecycle tests | | `src/tests/unit_tests/uihtml_tests.cpp` | Existing realistic web-view fixture adjustments | @@ -435,9 +575,9 @@ media state, dirty queues, cookies, font faces, or DOM lookup. This does not cre ### Keep UISceneNode Size As Both Document Extent And CSS Viewport -The document scene must be content-sized to scroll, but that size cannot also define viewport units -and media queries. Keep one content-sized scene and add explicit viewport metrics instead of adding -an extra nested scroll target or treating the content extent as the viewport. +The document needs a scrollable extent, but that size cannot also define viewport units, media +queries, or the root/body containing block. Keep a real document layout scroll target and explicit +viewport/layout-viewport metrics instead of treating content extent as the viewport. ### Add Every Document Scene To SceneManager diff --git a/include/eepp/ui/uiscenenode.hpp b/include/eepp/ui/uiscenenode.hpp index 4a4235be2..64c198f29 100644 --- a/include/eepp/ui/uiscenenode.hpp +++ b/include/eepp/ui/uiscenenode.hpp @@ -118,6 +118,20 @@ class EE_API UISceneNode : public SceneNode { /** @return The explicit viewport size, or the scene extent when no override is set. */ const Sizef& getViewportPixelsSize() const; + /** + * @brief Sets the layout viewport size used by the scene root as the initial containing block. + * + * Embedded document scenes can have a scroll extent larger than the CSS/layout viewport. This + * keeps root/body normal-flow layout viewport-sized while the scroll target owns overflow. + */ + void setLayoutViewportPixelsSize( const Sizef& size ); + + /** Clears the explicit layout viewport size so the CSS viewport is used. */ + void clearLayoutViewportPixelsSize(); + + /** @return The explicit layout viewport size, or the CSS viewport when no override is set. */ + const Sizef& getLayoutViewportPixelsSize() const; + /** * @brief Controls whether a nested scene automatically follows its direct parent's size. * @@ -157,6 +171,15 @@ class EE_API UISceneNode : public SceneNode { */ virtual void update( const Time& elapsed ); + /** + * @brief Flushes dirty styles, style states, and layouts without running scene/node updates. + * + * This is intended for embedded document scenes that need synchronous style/layout settlement + * before measuring scroll extent. It must not run actions, timers, scheduled updates, or + * arbitrary node update callbacks. + */ + void flushDirtyStyleAndLayout(); + /** * @brief Sets the translator for internationalization. * @@ -692,6 +715,8 @@ class EE_API UISceneNode : public SceneNode { */ void nodeToWorldTranslation( Vector2f& Pos ) const; + Node* overFind( const Vector2f& point ); + /** * @brief Reloads the UI styles. * @@ -846,6 +871,8 @@ class EE_API UISceneNode : public SceneNode { Uint32 mCurrentMarker{ 0 }; Sizef mViewportPixelsSize; bool mHasViewportPixelsSize{ false }; + Sizef mLayoutViewportPixelsSize; + bool mHasLayoutViewportPixelsSize{ false }; bool mFollowParentSize{ true }; bool mOwnsEventDispatcher{ true }; std::shared_ptr mThreadPool; @@ -900,6 +927,7 @@ class EE_API UISceneNode : public SceneNode { void updateParentSizeListener(); void onViewportPixelsSizeChange(); + const Sizef& getRootPixelsSize() const; UISceneNode* getHostUISceneNode() const; /** diff --git a/include/eepp/ui/uiwebview.hpp b/include/eepp/ui/uiwebview.hpp index 158c76a5a..b650c4aa8 100644 --- a/include/eepp/ui/uiwebview.hpp +++ b/include/eepp/ui/uiwebview.hpp @@ -19,6 +19,7 @@ namespace EE { namespace UI { class UIHTMLHtml; class UIHTMLBody; +class UILayout; class UISceneNode; class EE_API UIWebView : public UIScrollView { @@ -95,13 +96,15 @@ class EE_API UIWebView : public UIScrollView { UIWebView(); UISceneNode* mDocumentScene{ nullptr }; + UILayout* mDocumentLayout{ nullptr }; UIWidget* mDocContainer{ nullptr }; Uint32 mScrollContainerSizeChangeCb{ 0 }; Uint32 mVerticalScrollVisibleChangeCb{ 0 }; Uint32 mHorizontalScrollVisibleChangeCb{ 0 }; bool mUpdatingDocumentViewportMetrics{ false }; bool mUpdatingDocumentContentExtent{ false }; - bool mDocumentViewportSyncQueued{ false }; + bool mDocumentExtentDirty{ true }; + LayoutInvalidationFlags mDocumentExtentDirtyReasons{ 0 }; struct NavigationLoadState { UIWebView* owner{ nullptr }; bool alive{ true }; @@ -142,9 +145,9 @@ class EE_API UIWebView : public UIScrollView { void onDocumentViewportGeometryChanged(); void updateHTMLMinHeight( UIHTMLHtml* html, UIHTMLBody* body ); void updateHTMLMinHeightForDocument(); + void markDocumentExtentDirty( LayoutInvalidationFlags reasons ); bool updateDocumentViewportMetrics(); - void updateDocumentSceneContentExtent(); - void updateDocumentSceneMetrics(); + void updateDocumentMetricsIfNeeded(); }; }} // namespace EE::UI diff --git a/src/eepp/ui/uiscenenode.cpp b/src/eepp/ui/uiscenenode.cpp index 4cdb31d74..bdb424a8d 100644 --- a/src/eepp/ui/uiscenenode.cpp +++ b/src/eepp/ui/uiscenenode.cpp @@ -47,6 +47,17 @@ static void refreshWebViewDocumentLayoutAfterStyleChange( UIWidget* root ) { // viewport did not move. Keeping it scoped to WebView documents avoids reopening the generic // RichText parent-recompute storm, while the final html dirty mark gives the normal layout // queue one coalesced pass from the document root. + Node* parent = root; + while ( parent ) { + if ( parent->isType( UI_TYPE_WEBVIEW ) ) { + parent->asType()->invalidateDocumentLayout( + LayoutInvalidation::Document | + toLayoutInvalidationFlags( LayoutInvalidationReason::Style ) ); + break; + } + parent = parent->getParent(); + } + auto webViews = root->findAllByType( UI_TYPE_WEBVIEW ); for ( auto webViewNode : webViews ) { auto* webView = webViewNode->asType(); @@ -168,6 +179,54 @@ void UISceneNode::nodeToWorldTranslation( Vector2f& Pos ) const { } } +Node* UISceneNode::overFind( const Vector2f& point ) { + Node* pOver = NULL; + + if ( ( mNodeFlags & NODE_FLAG_OVER_FIND_ALLOWED ) && mEnabled && mVisible ) { + updateWorldPolygon(); + + if ( mWorldBounds.contains( point ) && mPoly.pointInside( point ) ) { + writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 1 ); + mSceneNode->addMouseOverNode( this ); + + Node* child = getLastChild(); + + while ( NULL != child ) { + Node* childOver = child->overFind( point ); + + if ( NULL == childOver && child == mRoot && mHasLayoutViewportPixelsSize ) { + mRoot->updateWorldPolygon(); + mRoot->writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 1 ); + mSceneNode->addMouseOverNode( mRoot ); + + Node* rootChild = mRoot->getLastChild(); + while ( NULL != rootChild ) { + childOver = rootChild->overFind( point ); + if ( NULL != childOver ) + break; + rootChild = rootChild->getPrevNode(); + } + + if ( NULL == childOver ) + childOver = mRoot; + } + + if ( NULL != childOver ) { + pOver = childOver; + break; + } + + child = child->getPrevNode(); + } + + if ( NULL == pOver ) + pOver = this; + } + } + + return pOver; +} + void UISceneNode::onParentChange() { SceneNode::onParentChange(); @@ -789,7 +848,7 @@ void UISceneNode::setViewportPixelsSize( const Sizef& size ) { mViewportPixelsSize = size; mHasViewportPixelsSize = true; - mRoot->setPixelsSize( getPixelsSize() ); + mRoot->setPixelsSize( getRootPixelsSize() ); onViewportPixelsSizeChange(); } @@ -798,7 +857,7 @@ void UISceneNode::clearViewportPixelsSize() { return; mHasViewportPixelsSize = false; - mRoot->setPixelsSize( getPixelsSize() ); + mRoot->setPixelsSize( getRootPixelsSize() ); onViewportPixelsSizeChange(); } @@ -812,6 +871,29 @@ const Sizef& UISceneNode::getViewportPixelsSize() const { return mHasViewportPixelsSize ? mViewportPixelsSize : getPixelsSize(); } +void UISceneNode::setLayoutViewportPixelsSize( const Sizef& size ) { + if ( mHasLayoutViewportPixelsSize && mLayoutViewportPixelsSize == size ) + return; + + mLayoutViewportPixelsSize = size; + mHasLayoutViewportPixelsSize = true; + mRoot->setPixelsSize( getRootPixelsSize() ); + sendMsg( this, NodeMessage::WindowResize ); +} + +void UISceneNode::clearLayoutViewportPixelsSize() { + if ( !mHasLayoutViewportPixelsSize ) + return; + + mHasLayoutViewportPixelsSize = false; + mRoot->setPixelsSize( getRootPixelsSize() ); + sendMsg( this, NodeMessage::WindowResize ); +} + +const Sizef& UISceneNode::getLayoutViewportPixelsSize() const { + return mHasLayoutViewportPixelsSize ? mLayoutViewportPixelsSize : getViewportPixelsSize(); +} + void UISceneNode::setFollowParentSize( bool followParentSize ) { if ( mFollowParentSize == followParentSize ) return; @@ -828,6 +910,21 @@ bool UISceneNode::followsParentSize() const { return mFollowParentSize; } +void UISceneNode::flushDirtyStyleAndLayout() { + updateDirtyStyles(); + updateDirtyStyleStates(); + updateDirtyLayouts(); + + int invalidationDepth = mMaxInvalidationDepth; + while ( ( !mDirtyStyle.empty() || !mDirtyStyleState.empty() || !mDirtyLayouts.empty() ) && + invalidationDepth > 0 ) { + updateDirtyStyles(); + updateDirtyStyleStates(); + updateDirtyLayouts(); + invalidationDepth--; + } +} + void UISceneNode::update( const Time& elapsed ) { UISceneNode* uiSceneNode = SceneManager::instance()->getUISceneNode(); @@ -1196,7 +1293,11 @@ void UISceneNode::onChildCountChange( Node* child, const bool& removed ) { void UISceneNode::onSizeChange() { SceneNode::onSizeChange(); - mRoot->setPixelsSize( getPixelsSize() ); + mRoot->setPixelsSize( getRootPixelsSize() ); +} + +const Sizef& UISceneNode::getRootPixelsSize() const { + return mHasLayoutViewportPixelsSize ? mLayoutViewportPixelsSize : getViewportPixelsSize(); } void UISceneNode::processStyleSheetAtRules( const StyleSheet& styleSheet, URI baseURI ) { diff --git a/src/eepp/ui/uiwebview.cpp b/src/eepp/ui/uiwebview.cpp index 8c4e0f04f..4b96a8f19 100644 --- a/src/eepp/ui/uiwebview.cpp +++ b/src/eepp/ui/uiwebview.cpp @@ -13,6 +13,16 @@ namespace EE { namespace UI { +class UIWebViewDocumentContainer : public UILinearLayout { + public: + static UIWebViewDocumentContainer* New() { return eeNew( UIWebViewDocumentContainer, () ); } + + void clearDocumentChildren() { childDeleteAll(); } + + protected: + UIWebViewDocumentContainer() : UILinearLayout( "webview::doc", UIOrientation::Vertical ) {} +}; + static void expandDocumentContentExtent( Node* node, Vector2f offset, Sizef& extent, bool hasClip = false, const Sizef& clipExtent = Sizef::Zero ) { @@ -55,7 +65,6 @@ static void expandDocumentContentExtent( Node* node, Vector2f offset, Sizef& ext child = child->getNextNode(); } } - static void expandWidgetContentExtent( UIWidget* widget, const Vector2f& offset, Sizef& extent ) { if ( !widget || widget->isClosing() ) return; @@ -84,9 +93,6 @@ static void resetViewportDependentDocumentWidths( UIWidget* container ) { if ( !parentWidget ) continue; - const Float containerWidth = eemax( 0.f, parentWidget->getPixelsSize().getWidth() - - parentWidget->getPixelsContentOffset().Left - - parentWidget->getPixelsContentOffset().Right ); bool normalFlow = true; Rectf margin = widget->getLayoutPixelsMargin(); if ( widget->isType( UI_TYPE_HTML_WIDGET ) ) { @@ -97,29 +103,15 @@ static void resetViewportDependentDocumentWidths( UIWidget* container ) { if ( normalFlow ) { widget->invalidateIntrinsicSize(); - /* if ( widget->getLayoutWidthPolicy() == SizePolicy::MatchParent ) { - widget->setPixelsSize( eemax( 0.f, containerWidth - margin.Left - margin.Right ), - widget->getPixelsSize().getHeight() ); - } else if ( widget->getLayoutWidthPolicy() == SizePolicy::Fixed && - widget->getUIStyle() ) { - const StyleSheetProperty* wprop = - widget->getUIStyle()->getProperty( PropertyId::Width ); - if ( wprop && StyleSheetLength::isPercentage( wprop->value() ) ) { - widget->setPixelsSize( { widget->lengthFromValue( *wprop ), - widget->getPixelsSize().getHeight() } ); - } - } */ } } } Sizef UIWebView::getDocumentViewportPixelsSize() const { - Sizef contentBox = getPixelsSize(); + Sizef viewport = getPixelsSize(); const Rectf& padding = getPixelsPadding(); - contentBox.x -= padding.Left + padding.Right; - contentBox.y -= padding.Top + padding.Bottom; - - Sizef viewport = contentBox; + viewport.x -= padding.Left + padding.Right; + viewport.y -= padding.Top + padding.Bottom; if ( getViewType() == ScrollViewType::Outside ) { if ( getVerticalScrollBar()->isVisible() ) @@ -128,24 +120,6 @@ Sizef UIWebView::getDocumentViewportPixelsSize() const { viewport.y -= getHorizontalScrollBar()->getPixelsSize().getHeight(); } - if ( mContainer && mContainer->getPixelsSize() != Sizef::Zero ) { - Sizef container = mContainer->getPixelsSize(); - Float verticalScrollWidth = getVerticalScrollBar()->getPixelsSize().getWidth(); - Float horizontalScrollHeight = getHorizontalScrollBar()->getPixelsSize().getHeight(); - if ( eeabs( container.getWidth() - viewport.getWidth() ) <= 0.5f || - ( getVerticalScrollBar()->isVisible() && - container.getWidth() < contentBox.getWidth() && - eeabs( contentBox.getWidth() - container.getWidth() - verticalScrollWidth ) <= - 0.5f ) ) - viewport.x = container.getWidth(); - if ( eeabs( container.getHeight() - viewport.getHeight() ) <= 0.5f || - ( getHorizontalScrollBar()->isVisible() && - container.getHeight() < contentBox.getHeight() && - eeabs( contentBox.getHeight() - container.getHeight() - horizontalScrollHeight ) <= - 0.5f ) ) - viewport.y = container.getHeight(); - } - viewport.x = eemax( 0.f, viewport.x ); viewport.y = eemax( 0.f, viewport.y ); return viewport; @@ -158,11 +132,18 @@ UIWebView* UIWebView::New() { UIWebView::UIWebView() : UIScrollView( "webview" ) { mNavigationLoadState = std::make_shared(); mNavigationLoadState->owner = this; + + mDocumentLayout = UILinearLayout::NewVerticalWidthMatchParent( "webview::document_layout" ); + mDocumentLayout->setClipType( ClipType::None ); + mDocumentLayout->setFlags( UI_OWNS_CHILDREN_POSITION ); + mDocumentLayout->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + mDocumentLayout->setParent( this ); + mDocumentScene = UISceneNode::New(); mDocumentScene->setFollowParentSize( false ); - mDocumentScene->setParent( this ); + mDocumentScene->setParent( mDocumentLayout ); - mDocContainer = UILinearLayout::NewVerticalWidthMatchParent( "webview::doc" ); + mDocContainer = UIWebViewDocumentContainer::New(); mDocContainer->setClipType( ClipType::None ); mDocContainer->setFlags( UI_OWNS_CHILDREN_POSITION ); mDocContainer->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::WrapContent ); @@ -223,7 +204,7 @@ void UIWebView::scheduledUpdate( const Time& time ) { UITouchDraggableWidget::scheduledUpdate( time ); if ( mDocumentScene ) { mDocumentScene->update( time ); - updateDocumentSceneContentExtent(); + updateDocumentMetricsIfNeeded(); } } @@ -378,67 +359,53 @@ void UIWebView::loadDocumentData( URI url, std::string data, Uint64 generation ) } std::weak_ptr loadState( mNavigationLoadState ); - ensureMainThread( - [loadState, generation, url = std::move( url ), data = std::move( data )]() mutable { - UIWebView* self = resolveNavigationLoad( loadState, generation ); - if ( !self ) - return; + ensureMainThread( [loadState, generation, url = std::move( url ), + data = std::move( data )]() mutable { + UIWebView* self = resolveNavigationLoad( loadState, generation ); + if ( !self ) + return; - auto ui = self->getDocumentSceneNode(); - if ( !ui || !self->isNavigationLoadCurrent( generation ) ) - return; + auto ui = self->getDocumentSceneNode(); + if ( !ui || !self->isNavigationLoadCurrent( generation ) ) + return; - self->getVerticalScrollBar()->setValue( 0 ); - self->mDocContainer->closeAllChildren(); - ui->invalidateAsyncResourceLoads(); - ui->getStyleSheet().removeAllWithoutMarker( self->mStyleSheetDefaultMarker ); - ui->setURIFromURL( url ); + self->getVerticalScrollBar()->setValue( 0 ); + self->getHorizontalScrollBar()->setValue( 0 ); + static_cast( self->mDocContainer )->clearDocumentChildren(); + ui->invalidateAsyncResourceLoads(); + ui->getStyleSheet().removeAllWithoutMarker( self->mStyleSheetDefaultMarker ); + ui->setURIFromURL( url ); - auto hash = String::hash( url.toString() ); - ui->loadLayoutFromString( Tools::HTMLFormatter::HTMLtoXML( data ), self->mDocContainer, - hash ); + auto hash = String::hash( url.toString() ); + ui->loadLayoutFromString( Tools::HTMLFormatter::HTMLtoXML( data ), self->mDocContainer, + hash ); - ui->setNavigationInterceptorCb( [loadState]( const NavigationRequest& request ) { - auto locked = loadState.lock(); - if ( !locked || !locked->alive || locked->owner == nullptr ) - return true; - UIWebView* self = locked->owner; - UISceneNode* docScene = self->getDocumentSceneNode(); - if ( !docScene ) - return true; - URI uri = docScene->solveRelativePath( request.uri ); - self->loadURI( uri, request.method != "GET", request.method, request.body, - request.extraHeaders ); + ui->setNavigationInterceptorCb( [loadState]( const NavigationRequest& request ) { + auto locked = loadState.lock(); + if ( !locked || !locked->alive || locked->owner == nullptr ) return true; - } ); - - if ( !self->isNavigationLoadCurrent( generation ) ) - return; - self->mIsLoading = false; - NavigationEvent ev( self, (Uint32)Event::OnNavigationCompleted, url, true ); - self->sendEvent( &ev ); - self->updateDocumentViewportMetrics(); - self->updateHTMLMinHeightForDocument(); - self->updateDocumentSceneContentExtent(); + UIWebView* self = locked->owner; + UISceneNode* docScene = self->getDocumentSceneNode(); + if ( !docScene ) + return true; + URI uri = docScene->solveRelativePath( request.uri ); + self->loadURI( uri, request.method != "GET", request.method, request.body, + request.extraHeaders ); + return true; } ); + + if ( !self->isNavigationLoadCurrent( generation ) ) + return; + self->mIsLoading = false; + NavigationEvent ev( self, (Uint32)Event::OnNavigationCompleted, url, true ); + self->sendEvent( &ev ); + self->markDocumentExtentDirty( LayoutInvalidation::Document ); + self->updateDocumentMetricsIfNeeded(); + } ); } void UIWebView::onDocumentViewportGeometryChanged() { - if ( updateDocumentViewportMetrics() ) { - updateHTMLMinHeightForDocument(); - if ( mDocContainer && mDocContainer->isLayout() ) - mDocContainer->asType()->setLayoutDirty( LayoutInvalidation::Document ); - if ( auto htmlNode = mDocumentScene->findByType( UI_TYPE_HTML_HTML ) ) { - if ( htmlNode->isWidget() ) - resetViewportDependentDocumentWidths( htmlNode->asType() ); - if ( htmlNode->isLayout() ) - htmlNode->asType()->setLayoutDirty( LayoutInvalidation::Document ); - } - mDocumentScene->update( Time::Zero ); - updateDocumentSceneContentExtent(); - return; - } - updateDocumentSceneContentExtent(); + markDocumentExtentDirty( toLayoutInvalidationFlags( LayoutInvalidationReason::Viewport ) ); } void UIWebView::updateHTMLMinHeightForDocument() { @@ -516,7 +483,7 @@ UISceneNode* UIWebView::getDocumentSceneNode() const { } bool UIWebView::updateDocumentViewportMetrics() { - if ( !mDocumentScene || !mDocContainer || mUpdatingDocumentViewportMetrics ) + if ( !mDocumentScene || !mDocumentLayout || !mDocContainer || mUpdatingDocumentViewportMetrics ) return false; mUpdatingDocumentViewportMetrics = true; @@ -524,34 +491,63 @@ bool UIWebView::updateDocumentViewportMetrics() { Sizef viewport = getDocumentViewportPixelsSize(); bool changed = viewport != Sizef::Zero && viewport != mDocumentScene->getViewportPixelsSize(); - if ( viewport != Sizef::Zero ) + if ( viewport != Sizef::Zero ) { mDocumentScene->setViewportPixelsSize( viewport ); + mDocumentScene->setLayoutViewportPixelsSize( viewport ); + } - if ( changed && !mUpdatingDocumentContentExtent && viewport != mDocumentScene->getPixelsSize() ) - mDocumentScene->setPixelsSize( viewport ); + if ( mDocumentLayout->getPixelsSize() == Sizef::Zero && viewport != Sizef::Zero ) + mDocumentLayout->setPixelsSize( viewport ); mUpdatingDocumentViewportMetrics = false; return changed; } -void UIWebView::updateDocumentSceneContentExtent() { - if ( !mDocumentScene || !mDocContainer || mUpdatingDocumentContentExtent ) +void UIWebView::markDocumentExtentDirty( LayoutInvalidationFlags reasons ) { + mDocumentExtentDirty = true; + mDocumentExtentDirtyReasons |= reasons; +} + +void UIWebView::updateDocumentMetricsIfNeeded() { + if ( !mDocumentScene || !mDocumentLayout || !mDocContainer || mUpdatingDocumentContentExtent || + !mDocumentExtentDirty ) return; mUpdatingDocumentContentExtent = true; - if ( !mUpdatingDocumentViewportMetrics ) - updateDocumentViewportMetrics(); + bool viewportChanged = !mUpdatingDocumentViewportMetrics && updateDocumentViewportMetrics(); Sizef viewport = mDocumentScene->getViewportPixelsSize(); if ( viewport == Sizef::Zero ) viewport = mContainer ? mContainer->getPixelsSize() : getPixelsSize(); + const bool documentReset = + mDocumentExtentDirtyReasons & + toLayoutInvalidationFlags( LayoutInvalidationReason::DocumentExtent ); + if ( !viewportChanged && !documentReset ) { + mDocumentExtentDirty = false; + mDocumentExtentDirtyReasons = 0; + mUpdatingDocumentContentExtent = false; + return; + } + + if ( viewportChanged || documentReset ) { + updateHTMLMinHeightForDocument(); + if ( mDocContainer->isLayout() ) + mDocContainer->asType()->setLayoutDirty( LayoutInvalidation::Document ); + if ( auto htmlNode = mDocumentScene->findByType( UI_TYPE_HTML_HTML ) ) { + if ( htmlNode->isWidget() ) + resetViewportDependentDocumentWidths( htmlNode->asType() ); + if ( htmlNode->isLayout() ) + htmlNode->asType()->setLayoutDirty( LayoutInvalidation::Document ); + } + } + + mDocumentScene->flushDirtyStyleAndLayout(); + Sizef extent( viewport.getWidth(), viewport.getHeight() ); if ( auto htmlNode = mDocContainer->findByType( UI_TYPE_HTML_HTML ) ) { UIWidget* html = htmlNode->asType(); - resetViewportDependentDocumentWidths( html ); - mDocumentScene->updateDirtyLayouts(); expandWidgetContentExtent( html, Vector2f::Zero, extent ); } @@ -562,31 +558,17 @@ void UIWebView::updateDocumentSceneContentExtent() { expandDocumentContentExtent( body, mDocContainer->getPixelsPosition(), extent ); } - bool extentChanged = extent != Sizef::Zero && extent != mDocumentScene->getPixelsSize(); + bool extentChanged = extent != Sizef::Zero && extent != mDocumentLayout->getPixelsSize(); if ( extentChanged ) { + mDocumentLayout->setPixelsSize( extent ); mDocumentScene->setPixelsSize( extent ); - updateHTMLMinHeightForDocument(); + containerUpdate(); + updateScroll(); } + mDocumentExtentDirty = false; + mDocumentExtentDirtyReasons = 0; mUpdatingDocumentContentExtent = false; - - viewport = getDocumentViewportPixelsSize(); - bool viewportChanged = - viewport != Sizef::Zero && viewport != mDocumentScene->getViewportPixelsSize(); - if ( ( viewportChanged || extentChanged ) && !mDocumentViewportSyncQueued ) { - mDocumentViewportSyncQueued = true; - runOnMainThread( [this] { - runOnMainThread( [this] { - mDocumentViewportSyncQueued = false; - onDocumentViewportGeometryChanged(); - } ); - } ); - } -} - -void UIWebView::updateDocumentSceneMetrics() { - updateDocumentViewportMetrics(); - updateDocumentSceneContentExtent(); } void UIWebView::setStyleSheetDefaultMarker( Uint32 marker ) { @@ -615,8 +597,7 @@ void UIWebView::invalidateDocumentLayout( LayoutInvalidationFlags reasons ) { reasons & ( toLayoutInvalidationFlags( LayoutInvalidationReason::DocumentExtent ) | toLayoutInvalidationFlags( LayoutInvalidationReason::Viewport ) ); if ( docExtent ) { - containerUpdate(); - updateHTMLMinHeightForDocument(); + markDocumentExtentDirty( reasons ); } if ( mDocContainer && mDocContainer->isLayout() ) mDocContainer->asType()->setLayoutDirty( reasons ); diff --git a/src/tests/unit_tests/uiwebview_tests.cpp b/src/tests/unit_tests/uiwebview_tests.cpp index 743ccc364..4746be7e9 100644 --- a/src/tests/unit_tests/uiwebview_tests.cpp +++ b/src/tests/unit_tests/uiwebview_tests.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -55,7 +56,8 @@ UTEST( UIWebView, OwnedDocumentSceneScrollTarget ) { UISceneNode* documentScene = webView->getDocumentSceneNode(); ASSERT_TRUE( documentScene != nullptr ); - ASSERT_TRUE( documentScene->getParent() == webView->getContainer() ); + ASSERT_TRUE( webView->getScrollView() == documentScene->getParent() ); + ASSERT_TRUE( documentScene->getParent()->getParent() == webView->getContainer() ); EXPECT_EQ( SceneManager::instance()->count(), (size_t)1 ); Node* htmlNode = nullptr; @@ -82,8 +84,10 @@ UTEST( UIWebView, OwnedDocumentSceneScrollTarget ) { EXPECT_GT( documentScene->getPixelsSize().getHeight(), 1000.f ); EXPECT_TRUE( webView->getVerticalScrollBar()->isVisible() ); + ASSERT_TRUE( documentScene->getParent() != nullptr && documentScene->getParent()->isUINode() ); + UINode* scrollTarget = documentScene->getParent()->asType(); webView->getVerticalScrollBar()->setValue( 1.f ); - EXPECT_LT( documentScene->getPosition().y, -500.f ); + EXPECT_LT( scrollTarget->getPixelsPosition().y, -500.f ); Engine::destroySingleton(); } @@ -163,10 +167,12 @@ UTEST( UIWebView, VerticalScrollbarViewportDoesNotCreateHorizontalScroll ) { EXPECT_NEAR( documentScene->getPixelsSize().getWidth(), stableExtent.getWidth(), 0.5f ); EXPECT_NEAR( documentScene->getPixelsSize().getHeight(), stableExtent.getHeight(), 0.5f ); + ASSERT_TRUE( documentScene->getParent() != nullptr && documentScene->getParent()->isUINode() ); + UINode* scrollTarget = documentScene->getParent()->asType(); webView->getVerticalScrollBar()->setValue( 1.f ); - EXPECT_NEAR( -documentScene->getPosition().y + + EXPECT_NEAR( -scrollTarget->getPixelsPosition().y + webView->getContainer()->getPixelsSize().getHeight(), - documentScene->getPixelsSize().getHeight(), 1.f ); + scrollTarget->getPixelsSize().getHeight(), 1.f ); Engine::destroySingleton(); } @@ -278,9 +284,11 @@ UTEST( UIWebView, ExplicitWideDocumentHorizontalScrollReachesRightEdgeAtPixelDen webView->getHorizontalScrollBar()->setValue( 1.f ); pump(); - EXPECT_NEAR( -documentScene->getPosition().x + + ASSERT_TRUE( documentScene->getParent() != nullptr && documentScene->getParent()->isUINode() ); + UINode* scrollTarget = documentScene->getParent()->asType(); + EXPECT_NEAR( -scrollTarget->getPixelsPosition().x + webView->getContainer()->getPixelsSize().getWidth(), - documentScene->getPixelsSize().getWidth(), 1.f ); + scrollTarget->getPixelsSize().getWidth(), 1.f ); Engine::destroySingleton(); EE::Graphics::PixelDensity::setPixelDensity( 1.0f ); @@ -431,7 +439,9 @@ UTEST( UIWebView, HorizontalScrollDisappearsAfterResponsiveShrink ) { webView->getHorizontalScrollBar()->setValue( 1.f ); pump(); - ASSERT_LT( documentScene->getPosition().x, -100.f ); + ASSERT_TRUE( documentScene->getParent() != nullptr && documentScene->getParent()->isUINode() ); + UINode* scrollTarget = documentScene->getParent()->asType(); + ASSERT_LT( scrollTarget->getPixelsPosition().x, -100.f ); webView->loadURI( URI( "file://" + fitPath ) ); pump(); @@ -443,17 +453,17 @@ UTEST( UIWebView, HorizontalScrollDisappearsAfterResponsiveShrink ) { ASSERT_TRUE( html != nullptr ); ASSERT_TRUE( body != nullptr ); EXPECT_NEAR( documentScene->getRoot()->getPixelsSize().getWidth(), - documentScene->getPixelsSize().getWidth(), 0.5f ); + documentScene->getViewportPixelsSize().getWidth(), 0.5f ); EXPECT_NEAR( fit->getPixelsSize().getWidth(), documentScene->getViewportPixelsSize().getWidth(), 0.5f ); - EXPECT_NEAR( html->getPixelsSize().getWidth(), documentScene->getPixelsSize().getWidth(), - 0.5f ); + EXPECT_NEAR( html->getPixelsSize().getWidth(), + documentScene->getViewportPixelsSize().getWidth(), 0.5f ); EXPECT_NEAR( body->getPixelsSize().getWidth(), documentScene->getViewportPixelsSize().getWidth(), 0.5f ); EXPECT_NEAR( documentScene->getPixelsSize().getWidth(), documentScene->getViewportPixelsSize().getWidth(), 0.5f ); EXPECT_FALSE( webView->getHorizontalScrollBar()->isVisible() ); - EXPECT_NEAR( documentScene->getPosition().x, 0.f, 0.5f ); + EXPECT_NEAR( scrollTarget->getPixelsPosition().x, 0.f, 0.5f ); Engine::destroySingleton(); } @@ -708,6 +718,8 @@ UTEST( UIWebView, HackerNewsFrontPageBottomIsReachable ) { ASSERT_TRUE( html != nullptr ); ASSERT_TRUE( body != nullptr ); ASSERT_TRUE( webView->getVerticalScrollBar()->isVisible() ); + ASSERT_TRUE( documentScene->getParent() != nullptr && documentScene->getParent()->isUINode() ); + UINode* scrollTarget = documentScene->getParent()->asType(); Float viewportHeight = webView->getContainer()->getPixelsSize().getHeight(); const Float inputBottomInDocument = @@ -717,9 +729,9 @@ UTEST( UIWebView, HackerNewsFrontPageBottomIsReachable ) { EXPECT_GE( documentScene->getPixelsSize().getWidth(), documentScene->getViewportPixelsSize().getWidth() ); EXPECT_NEAR( documentScene->getRoot()->getPixelsSize().getWidth(), - documentScene->getPixelsSize().getWidth(), 0.5f ); - EXPECT_NEAR( html->getPixelsSize().getWidth(), documentScene->getPixelsSize().getWidth(), - 0.5f ); + documentScene->getViewportPixelsSize().getWidth(), 0.5f ); + EXPECT_NEAR( html->getPixelsSize().getWidth(), + documentScene->getViewportPixelsSize().getWidth(), 0.5f ); EXPECT_NEAR( documentScene->getPixelsSize().getHeight(), html->fitMinMaxSizePx( html->getPixelsSize() ).getHeight(), 0.5f ); @@ -739,9 +751,9 @@ UTEST( UIWebView, HackerNewsFrontPageBottomIsReachable ) { EXPECT_GE( documentScene->getPixelsSize().getWidth(), documentScene->getViewportPixelsSize().getWidth() ); EXPECT_NEAR( documentScene->getRoot()->getPixelsSize().getWidth(), - documentScene->getPixelsSize().getWidth(), 0.5f ); - EXPECT_NEAR( html->getPixelsSize().getWidth(), documentScene->getPixelsSize().getWidth(), - 0.5f ); + documentScene->getViewportPixelsSize().getWidth(), 0.5f ); + EXPECT_NEAR( html->getPixelsSize().getWidth(), + documentScene->getViewportPixelsSize().getWidth(), 0.5f ); EXPECT_NEAR( documentScene->getPixelsSize().getHeight(), html->fitMinMaxSizePx( html->getPixelsSize() ).getHeight(), 0.5f ); viewportHeight = webView->getContainer()->getPixelsSize().getHeight(); @@ -757,9 +769,9 @@ UTEST( UIWebView, HackerNewsFrontPageBottomIsReachable ) { EXPECT_GE( documentScene->getPixelsSize().getWidth(), documentScene->getViewportPixelsSize().getWidth() ); EXPECT_NEAR( documentScene->getRoot()->getPixelsSize().getWidth(), - documentScene->getPixelsSize().getWidth(), 0.5f ); - EXPECT_NEAR( html->getPixelsSize().getWidth(), documentScene->getPixelsSize().getWidth(), - 0.5f ); + documentScene->getViewportPixelsSize().getWidth(), 0.5f ); + EXPECT_NEAR( html->getPixelsSize().getWidth(), + documentScene->getViewportPixelsSize().getWidth(), 0.5f ); EXPECT_NEAR( documentScene->getPixelsSize().getHeight(), html->fitMinMaxSizePx( html->getPixelsSize() ).getHeight(), 0.5f ); viewportHeight = webView->getContainer()->getPixelsSize().getHeight(); @@ -771,10 +783,10 @@ UTEST( UIWebView, HackerNewsFrontPageBottomIsReachable ) { const Rectf containerRect = webView->getContainer()->getScreenRect(); EXPECT_LE( inputRect.Bottom, containerRect.Bottom + 1.f ); EXPECT_GT( inputRect.Bottom, containerRect.Top ); - EXPECT_NEAR( -documentScene->getPosition().y + viewportHeight, - documentScene->getPixelsSize().getHeight(), 1.f ); + EXPECT_NEAR( -scrollTarget->getPixelsPosition().y + viewportHeight, + scrollTarget->getPixelsSize().getHeight(), 1.f ); EXPECT_NEAR( documentScene->getRoot()->getPixelsSize().getHeight(), - documentScene->getPixelsSize().getHeight(), 0.5f ); + documentScene->getViewportPixelsSize().getHeight(), 0.5f ); const Vector2f inputCenter( inputRect.Left + inputRect.getWidth() * 0.5f, inputRect.Top + inputRect.getHeight() * 0.5f ); @@ -846,7 +858,7 @@ UTEST( UIWebView, NavigationAfterGrowDoesNotKeepMaximizedWidthOnShrink ) { webView->getContainer()->getPixelsSize().getWidth(), 0.5f ); EXPECT_LT( documentScene->getViewportPixelsSize().getWidth(), 900.f ); EXPECT_NEAR( documentScene->getRoot()->getPixelsSize().getWidth(), - documentScene->getPixelsSize().getWidth(), 0.5f ); + documentScene->getViewportPixelsSize().getWidth(), 0.5f ); EXPECT_LT( html->fitMinMaxSizePx( html->getPixelsSize() ).getWidth(), 900.f ); EXPECT_LT( body->fitMinMaxSizePx( body->getPixelsSize() ).getWidth(), 900.f ); EXPECT_LT( documentScene->getPixelsSize().getWidth(), 900.f ); @@ -855,6 +867,177 @@ UTEST( UIWebView, NavigationAfterGrowDoesNotKeepMaximizedWidthOnShrink ) { Engine::destroySingleton(); } +UTEST( UIWebView, NavigationFromTallToShortShrinksDocumentExtent ) { + auto win = Engine::instance()->createWindow( + WindowSettings( 640, 480, "UIWebView Navigation Extent Shrink Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); + font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" ); + ASSERT_TRUE( font != nullptr && font->loaded() ); + FontFamily::loadFromRegular( font ); + + UISceneNode* sceneNode = UISceneNode::New(); + SceneManager::instance()->add( sceneNode ); + sceneNode->getUIThemeManager()->setDefaultFont( font ); + + UIWebView* webView = UIWebView::New(); + webView->setParent( sceneNode->getRoot() ); + webView->setPixelsSize( 400, 300 ); + webView->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + + const std::string tallPath = Sys::getTempPath() + "eepp_uiwebview_tall.html"; + FileSystem::fileWrite( tallPath, R"html( + + + +
+ +)html" ); + const std::string shortPath = Sys::getTempPath() + "eepp_uiwebview_short.html"; + FileSystem::fileWrite( shortPath, R"html( + + + +
+ +)html" ); + + auto pump = [&]() { + for ( int i = 0; i < 20; i++ ) { + win->getInput()->update(); + SceneManager::instance()->update( Seconds( 1.f / 60.f ) ); + } + }; + + webView->loadURI( URI( "file://" + tallPath ) ); + UISceneNode* documentScene = webView->getDocumentSceneNode(); + ASSERT_TRUE( documentScene != nullptr ); + pump(); + ASSERT_TRUE( documentScene->getParent() != nullptr && documentScene->getParent()->isUINode() ); + UINode* scrollTarget = documentScene->getParent()->asType(); + EXPECT_GT( scrollTarget->getPixelsSize().getHeight(), 1700.f ); + + webView->loadURI( URI( "file://" + shortPath ) ); + pump(); + EXPECT_NEAR( scrollTarget->getPixelsSize().getHeight(), + webView->getContainer()->getPixelsSize().getHeight(), 1.f ); + EXPECT_NEAR( documentScene->getPixelsSize().getHeight(), + webView->getContainer()->getPixelsSize().getHeight(), 1.f ); + EXPECT_FALSE( webView->getVerticalScrollBar()->isVisible() ); + + Engine::destroySingleton(); +} + +UTEST( UIWebView, AsyncCSSCanShrinkDocumentExtent ) { + auto win = Engine::instance()->createWindow( + WindowSettings( 640, 480, "UIWebView CSS Extent Shrink Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); + font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" ); + ASSERT_TRUE( font != nullptr && font->loaded() ); + FontFamily::loadFromRegular( font ); + + UISceneNode* sceneNode = UISceneNode::New(); + SceneManager::instance()->add( sceneNode ); + sceneNode->getUIThemeManager()->setDefaultFont( font ); + + UIWebView* webView = UIWebView::New(); + webView->setParent( sceneNode->getRoot() ); + webView->setPixelsSize( 400, 300 ); + webView->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + + const std::string path = Sys::getTempPath() + "eepp_uiwebview_css_shrink.html"; + FileSystem::fileWrite( path, R"html( + + + +
+ +)html" ); + + auto pump = [&]() { + for ( int i = 0; i < 20; i++ ) { + win->getInput()->update(); + SceneManager::instance()->update( Seconds( 1.f / 60.f ) ); + } + }; + + webView->loadURI( URI( "file://" + path ) ); + UISceneNode* documentScene = webView->getDocumentSceneNode(); + ASSERT_TRUE( documentScene != nullptr ); + pump(); + ASSERT_TRUE( documentScene->getParent() != nullptr && documentScene->getParent()->isUINode() ); + UINode* scrollTarget = documentScene->getParent()->asType(); + EXPECT_GT( scrollTarget->getPixelsSize().getHeight(), 1700.f ); + + documentScene->combineStyleSheet( "#content { height: 120px; }", true ); + pump(); + EXPECT_NEAR( scrollTarget->getPixelsSize().getHeight(), + webView->getContainer()->getPixelsSize().getHeight(), 1.f ); + EXPECT_NEAR( documentScene->getPixelsSize().getHeight(), + webView->getContainer()->getPixelsSize().getHeight(), 1.f ); + EXPECT_FALSE( webView->getVerticalScrollBar()->isVisible() ); + + Engine::destroySingleton(); +} + +UTEST( UIWebView, CoalescesViewportResizeDocumentMetrics ) { + auto win = Engine::instance()->createWindow( + WindowSettings( 800, 600, "UIWebView Resize Metrics Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); + font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" ); + ASSERT_TRUE( font != nullptr && font->loaded() ); + FontFamily::loadFromRegular( font ); + + UISceneNode* sceneNode = UISceneNode::New(); + SceneManager::instance()->add( sceneNode ); + sceneNode->getUIThemeManager()->setDefaultFont( font ); + + UIWebView* webView = UIWebView::New(); + webView->setParent( sceneNode->getRoot() ); + webView->setPixelsSize( 800, 500 ); + webView->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + webView->loadURI( URI( "file://" + Sys::getProcessPath() + "assets/html/hn_frontpage.html" ) ); + + auto pump = [&]() { + for ( int i = 0; i < 30; i++ ) { + win->getInput()->update(); + SceneManager::instance()->update( Seconds( 1.f / 60.f ) ); + } + }; + pump(); + ASSERT_GE( webView->getDocumentSceneNode()->getRoot()->querySelectorAll( ".athing" ).size(), + (size_t)30 ); + + UILayout::resetMetrics(); + webView->setPixelsSize( 900, 530 ); + webView->setPixelsSize( 700, 480 ); + webView->setPixelsSize( 1000, 600 ); + webView->setPixelsSize( 800, 500 ); + for ( int frame = 0; frame < 6; frame++ ) { + win->getInput()->update(); + SceneManager::instance()->update( Seconds( 1.f / 60.f ) ); + } + + auto metrics = UILayout::getMetrics(); + UILayout::setMetricsEnabled( false ); + EXPECT_LE( metrics.treeUpdates, (Uint64)4 ); + EXPECT_EQ( metrics.synchronousUpdates, (Uint64)0 ); + EXPECT_EQ( metrics.richTextRebuilds, (Uint64)0 ); + + Engine::destroySingleton(); +} + UTEST( UIWebView, HackerNewsSingleStepRestoreSettlesViewportInOneFrame ) { auto win = Engine::instance()->createWindow( WindowSettings( 1024, 650, "UIWebView Single Step Restore Test", WindowStyle::Default, @@ -982,7 +1165,7 @@ UTEST( UIWebView, LayoutDrivenResizeKeepsDocumentRootAtViewport ) { pump(); EXPECT_GT( documentScene->getViewportPixelsSize().getWidth(), 2000.f ); EXPECT_NEAR( documentScene->getRoot()->getPixelsSize().getWidth(), - documentScene->getPixelsSize().getWidth(), 0.5f ); + documentScene->getViewportPixelsSize().getWidth(), 0.5f ); webView->loadURI( URI( "file://" + Sys::getProcessPath() + "assets/html/hn_frontpage.html" ) ); pump(); @@ -997,7 +1180,7 @@ UTEST( UIWebView, LayoutDrivenResizeKeepsDocumentRootAtViewport ) { ASSERT_TRUE( body != nullptr ); EXPECT_LT( documentScene->getViewportPixelsSize().getWidth(), 900.f ); EXPECT_NEAR( documentScene->getRoot()->getPixelsSize().getWidth(), - documentScene->getPixelsSize().getWidth(), 0.5f ); + documentScene->getViewportPixelsSize().getWidth(), 0.5f ); EXPECT_NEAR( html->getPixelsSize().getWidth(), documentScene->getViewportPixelsSize().getWidth(), 0.5f ); EXPECT_LE( body->getPixelsSize().getWidth(), @@ -1015,13 +1198,15 @@ UTEST( UIWebView, LayoutDrivenResizeKeepsDocumentRootAtViewport ) { webView->getVerticalScrollBar()->setValue( 1.f ); webView->getHorizontalScrollBar()->setValue( 1.f ); pump(); - EXPECT_NEAR( -documentScene->getPosition().y + + ASSERT_TRUE( documentScene->getParent() != nullptr && documentScene->getParent()->isUINode() ); + UINode* scrollTarget = documentScene->getParent()->asType(); + EXPECT_NEAR( -scrollTarget->getPixelsPosition().y + webView->getContainer()->getPixelsSize().getHeight(), - documentScene->getPixelsSize().getHeight(), 1.f ); + scrollTarget->getPixelsSize().getHeight(), 1.f ); if ( webView->getHorizontalScrollBar()->isVisible() ) { - EXPECT_NEAR( -documentScene->getPosition().x + + EXPECT_NEAR( -scrollTarget->getPixelsPosition().x + webView->getContainer()->getPixelsSize().getWidth(), - documentScene->getPixelsSize().getWidth(), 1.f ); + scrollTarget->getPixelsSize().getWidth(), 1.f ); } Engine::destroySingleton();