Add jpro-sticky: CSS-style sticky and fixed positioning for JavaFX nodes - #124
Open
streamingpixel wants to merge 42 commits into
Open
Add jpro-sticky: CSS-style sticky and fixed positioning for JavaFX nodes#124streamingpixel wants to merge 42 commits into
streamingpixel wants to merge 42 commits into
Conversation
added 30 commits
July 27, 2026 20:15
Scaffold the jpro-scroll platform module for scroll-aware positioning (sticky/fixed) of JavaFX nodes on the web. Adds the ScrollPosition mode enum, a Scroll facade (compositor wiring stubbed for M3), an example app, README, and root build/settings wiring.
…nical setter) Replace the single setPosition entry point with a layered surface: a canonical setScrollPosition(Node, ScrollPosition, Side, double) for programmatic/data-driven callers, plus setStickyPosition/setFixedPosition convenience delegators for everyday call sites. Add getScrollPosition and clearScrollPosition, a Side+offset model, and a teardown hook so switching or clearing modes reverses any installed override. Update the example and README to the new API.
… (WIP) Wire the canonical setScrollPosition to a real compositor scroll-timeline override (ScrollOverride), ported from the M1-proven TestStickyScenegraph mechanism: reparent the node into a per-scene overlay Group, leave a layout-mirroring placeholder in its flow slot, server-pin it, and override the DOM visual with an animation-timeline:scroll() keyframe animation via WebAPI. Includes teardown handle + jmemorybuddy CleanupDetector. Example serves a native-scrolling page (jpro/html/index.html) so the compositor has a scroll to track, and styles the header as a solid bar. Verified: the override installs and pins the header to the viewport top on scroll when the injected style lands. KNOWN ISSUE: first-install delivery to the browser is flaky on fresh loads (server logs the install but the <style> does not always persist in the page, likely a JPro instance-readiness/reconnect race) - next step is making install robust against that timing. Build wiring is temporary (marked TEMP(M3)): JPRO_VERSION bumped to 2026.3.1-SNAPSHOT for the M1 viewport API, and mavenLocal() added so the locally-published snapshot resolves. Revert both once M1 ships in a release.
The first install was deferred via Platform.runLater to dodge the JPro readiness race (the node's DOM peer is unregistered at first sync), but that pulse landed inconsistently on fresh loads, so the pin sometimes never installed. Install inline instead, and make the injected script self-sufficient: - write the <style> + @Keyframes unconditionally, with no dependency on the DOM peer existing yet; - resolve the element ref (which throws until JPro registers the node) inside a bounded requestAnimationFrame retry loop (~5s @ 60fps); - bind the animation via a [jpro-id] selector rule instead of inline styles, so it self-reapplies after any re-render or reconnect; - simplify teardown to just drop the stylesheet, removing the symmetric teardown-time readiness race. Verified: fresh restarts n1..n7 each report "compositor installed" cleanly, header pins on scroll on first load.
Pure-JavaFX JUnit coverage for the server-side Scroll surface, following the jpro-flexbox precedent (construct nodes, assert headlessly, no browser) rather than pioneering module-level Playwright. 17 tests over the observable state machine: getScrollPosition defaults to STATIC; each convenience delegator and the canonical setter record the right mode; STICKY/FIXED are mutually exclusive (last write wins); repeated switches don't accumulate state; clearing removes every stashed property; and the null-argument contract holds. install() calls WebAPI.getWebAPI, whose runtime impl is absent in a bare unit test, so the desktop contract (the consumer never fires -> install is inert) is modelled with a no-op static stub of WebAPI. Under it, applying a mode is verified not to reparent the node out of its flow slot. Also fixes a stale @link in ScrollPosition's javadoc (setPosition -> setScrollPosition) left over from the API rename, which would break javadoc generation for the publish.
Rewire the canonical setter around ScrollAnchor (per-axis: pin/center/stretch), retaining the (Side, double) and bare-position overloads as the single-edge case. STICKY rejects CENTER/STRETCH anchors and is bounded by its containing block; FIXED uses the full model. ScrollOverride resolves each axis independently to a viewport position (and, for STRETCH, a node size), restores bounded-sticky release from the container bottom, and re-syncs end/center/stretch anchors on viewport resize. Adds fixed convenience: setFixedPosition(ScrollAnchor|Pos), setFixedBar, setFixedFullscreen, and an explicit-container sticky overload.
Sticky page header, a bounded sticky section sub-header that releases at the section end, and fixed elements at every anchor kind (bottom bar, corner FAB, centered toast, full-viewport frame). Several are click-counter buttons so picking through the mouse-transparent overlay is checkable.
jpro-scroll collided with jpro-html-scrollpane. jpro-sticky names the headline feature (position: sticky) and is what users search for; fixed positioning rides along as sticky's sibling, the way jpro-file covers open and save. Renames the directory, package (one.jpro.platform.scroll -> one.jpro.platform.sticky), JPMS module, publish wiring, and the module's log/DOM identity tokens. The public Scroll/ScrollPosition/ScrollAnchor class names are kept as-is (neutral umbrella over sticky + fixed). Also finalizes README.md to document the ScrollAnchor per-axis model.
All sticky/fixed nodes are reparented into one shared overlay Group, and paint order there was the order installs happened to finish - which is async (WebAPI resolve + scene wait), so stacking was timing-dependent and could differ run to run. Give each override a monotonic stackOrder at construction (the order setScrollPosition is called) and insert into the overlay at its sorted position instead of appending on install-completion. Paint order now follows source order: a later-declared node paints on top, matching CSS's source-order tiebreaker. The library takes no fixed-vs-sticky stance; viewOrder stays the explicit per-node override. Reversible on teardown.
Style the example with the AtlantaFX CupertinoLight theme, matching the other platform examples (the atlantafx-base dependency was already declared; this wires the module requires and uses it). Content is now striped table-style rows (index / description / status pill) instead of cramped text lines, and the fixed elements use themed severity colours. Fixes found while styling: the FAB used Styles.BUTTON_CIRCLE (icon-only, hid its click counter) -> rounded via explicit radius; the bottom bar used Styles.WARNING (AtlantaFX has no .button.warning accent) -> amber from the theme colour, and floated 5px off the edges; the full-viewport overlay label moved to centre so it stays legible off the sticky bars.
The module was web-only: on desktop the WebAPI consumer never fires, so sticky/fixed positioning was a silent no-op. Add a pure-JavaFX path so the same code works on desktop and web, with the split invisible to callers. - ScrollImpl: package-private seam the factory selects on, stashed per node. - ScrollDispatcher: factory picking FX vs web per node once the scene is realised. FIXED picks the desktop overlay off-web; STICKY picks the FX ScrollPane impl whenever a ScrollPane ancestor exists (desktop, and the browser when the scroll is a server-side FX ScrollPane), else the web override in the browser, else inert (nothing scrolls, nothing to pin). - FXStickyImpl: CSS sticky clamp in ScrollPane content coordinates, both axes, with containing-block release; no reparenting. - FXFixedImpl: scene-anchored overlay for the full fixed anchor model. - AnchorGeometry: shared anchor -> geometry resolver (web resolves against the browser viewport, desktop against the scene: identical geometry). - StickyOverlay: shared per-scene overlay + source-order stacking, lifted out of ScrollOverride so desktop and web share one stacking model. - Scroll/ScrollOverride: route through the dispatcher; ScrollOverride now implements ScrollImpl and delegates overlay/geometry to the shared helpers. Public API and convenience layer unchanged.
Runtime-verify the desktop impls headlessly (Platform.startup, no TestFX): - AnchorGeometryTest: the per-axis resolver maths for every anchor mode. - StickyOverlayTest: overlay creation/caching and source-order stacking. - DesktopScrollImplTest: drives the public Scroll facade with WebAPI.isBrowser() stubbed false, asserting FIXED reparent+restore, STICKY pin at the viewport edge, and bounded-STICKY release at the containing block. - FxTestSupport: runs actions on the FX thread and surfaces their assertion failures as test failures. A JavaFX ScrollPane yields real viewport bounds and scrolls under applyCss()+layout() with no window, so pin and release are both assertable without a browser.
Wrap a bounded sticky sub-header in a titled, bordered card holding a JavaFX ScrollPane, placed high so it is visible in the desktop window. This is the section that exercises sticky on the desktop (desktop content scrolls only through a ScrollPane), and the same code runs in the browser when the scroll is a server-side FX ScrollPane. Card styling uses theme colours only, no new dependencies.
Rewrite the platform framing now that fixed and sticky run on the desktop and inside a ScrollPane, not just the web: user-perspective Usage notes with the two setup caveats, a Stacking order subsection (source order plus the viewOrder override), and an Under the hood note that pinned nodes are reparented. Move the STICKY edge-only rule to a note at the canonical setter, where the exception is reachable.
The jpro 2026.3.1-SNAPSHOT is now published to the sandec artifactory, so drop the temporary mavenLocal() wiring that pulled it from a local publish. Resolution now comes purely from the remote repo.
…class)
Publish a sticky node's pinned ("stuck") state through two channels driven from one
write point so they cannot drift: a stable ReadOnlyBooleanProperty (Scroll.stuckProperty
/ isStuck) and an auto-toggled :stuck JavaFX pseudo-class (Scroll.STUCK_PSEUDO_CLASS).
A package-private StuckState holder owns both channels; Scroll creates it for STICKY nodes
and threads its sink through ScrollDispatcher into the two sticky impls. FXStickyImpl fires
it at its existing pin transition; ScrollOverride derives stuck from the server pin vs the
flow top (same rule, no new JS). FIXED is always pinned, so it gets no sink and reads false.
Drive both channels through the FXStickyImpl path: they flip together across the pin line and reset on scroll-back, stay stuck through the containment release (still displaced), clear on teardown, and the property instance is stable across clear / re-apply. FIXED and STATIC read false.
Give the sticky page header and the ScrollPane sub-header a drop-shadow via the :stuck pseudo-class (sticky-sample.css, the CSS channel), and log the page header's stuckProperty transitions (the Java channel).
Add an Observability section (the :stuck CSS channel and the stuckProperty / isStuck Java channel) and a web-lag caveat under "Under the hood".
Add Scroll.registerOverlayHost(Pane): a pinned node reparents into the nearest registered host on its parent chain, else the scene root, so it can stay within its route CSS scope and parent-chain contexts. - host resolution + a per-host overlay, removed when its last pin detaches - host-local compositor coordinates so a non-root host pins correctly - Placeholders.mirror carries the node's pane constraints and width onto the flow placeholder - tear down when the placeholder leaves the scene (route unmount) - guard the async web attach against a node still mounted in an overlay - fixed paints above sticky by default; viewOrder still overrides - the overlay paints above host content so a routing content swap can't bury a pinned node
Cover host resolution and scene-root fallback, per-host overlay cleanup, the fixed-over-sticky stacking tier, the overlay viewOrder, isOverlay, placeholder constraint/width mirroring, and placeholder-lifecycle teardown.
Note registerOverlayHost and where a pinned node reparents, and that fixed paints above sticky by default with viewOrder still the override.
Remove em-dashes, internal design-doc references (STICKY_DESIGN.md) and session-narrative comments (old-bug / rationale asides) across the module. Tighten the verbose inline implementation comments in the impl classes and make them read naturally, cross-referencing StickyOverlay's ordering contract rather than restating it. Comment-only; no behaviour change.
jpro-sticky needs the browserViewport()/documentBounds() Viewport API, which was unreleased when the module was first wired up (2026.3.1-SNAPSHOT). That version is now released, so drop the -SNAPSHOT. jpro-sticky compiles and its full test suite passes against the release.
… names Split the public API (Scroll, ScrollAnchor, ScrollPosition) from the implementation, which now lives in a non-exported one.jpro.platform.sticky.impl package. ScrollAnchor exposes its axis read-view (Mode, Axis, horizontal/vertical) for the impl resolver. Rename the positioning strategies to say what selects each, rather than a platform token: ScrollOverride -> WebScrollImpl (web compositor, both modes) FXStickyImpl -> ScrollPaneStickyImpl (sticky inside an FX ScrollPane) FXFixedImpl -> DesktopFixedImpl (scene-anchored fixed) White-box tests move alongside their targets in the impl test package; FxTestSupport is shared across both. No behaviour change.
A reparenting delegate (WebScrollImpl / DesktopFixedImpl) watches its placeholder and, on route unmount, tore itself down terminally. The dispatcher kept IMPL_KEY but held a dead delegate, so on route re-entry nothing re-attached: the node reverted to plain flow while getScrollPosition() still reported STICKY/FIXED. Centralise the lifecycle in ScrollDispatcher. The reparenting impls now report "flow slot left the scene" through an onDetach callback instead of self-uninstalling; the dispatcher uninstalls the delegate but stays alive and re-selects + re-installs when the node's subtree returns to a scene, so a positioned node survives a navigate-away / back. The same-pulse detach/reattach guard is unchanged. ScrollPaneStickyImpl does not reparent and survives re-mount on its own, so it is left as-is. Also drop DesktopFixedImpl's redundant inner scene wait (the dispatcher already guarantees the node is in a scene at install()). Tests: add a desktop and a web route round-trip test asserting the node re-pins into the overlay; the web path is exercised headless with a stubbed WebAPI. 99 tests green.
added 12 commits
August 24, 2026 19:44
WebScrollImpl and DesktopFixedImpl carried an identical ~40-line reparent-into-overlay block: parent-is-Pane check, overlayForNode, capture of the flow slot, placeholder create + mirror + swap, setManaged(false), insertSorted; and the mirror-image restore on uninstall. Pull it into a small package-private OverlayMount (mount() -> placeholder, unmount()) so each impl is left with just its own geometry sync and reactive wiring. OverlayMount owns only the mount bookkeeping and positions nothing; the placeholder's vertical footprint stays each strategy's concern (FIXED collapses to 0, STICKY reserves the node height), as before. No behaviour change; 99 tests green, including the route round-trip tests that exercise mount, unmount, and re-mount on both paths.
Drop four setters (16 -> 12): - setFixedBar(Node, Side) and setFixedBar(Node, Side, double). A "bar" is not a CSS concept; it is just a stretch anchor (pin one edge, stretch the perpendicular axis), and the named method hid that behind a word the API didn't otherwise use. Bars are now written as the anchor, ScrollAnchor.of().top(0).left(0).right(0), which reads as what it does. - setScrollPosition(Node, ScrollPosition) and setScrollPosition(Node, ScrollPosition, Side, double). Both existed only as retained legacy overloads, and a position parameter that ignores the anchor for STATIC and ignores within for FIXED is awkward. Data-driven callers keep the canonical (Node, ScrollPosition, ScrollAnchor) and its 4-arg form; hand-written call sites use the sticky/fixed helpers. Kept setFixedFullscreen: unlike a bar it maps to a real CSS shorthand (inset: 0) and is the one common span Pos cannot express, so it earns a named shortcut. The rule is now clean: Pos for a point, an anchor for a span, with fullscreen the single blessed span shortcut. clearScrollPosition now routes through the 3-arg canonical. README and ScrollApiTest updated to the trimmed surface; setFixedBar's internal Side switch is removed with the method. No behaviour change. 97 tests green.
The API trim removed setScrollPosition(Node, ScrollPosition), but ScrollPosition's class javadoc still linked to it, so :jpro-sticky:javadoc failed on CI (reference not found) even though the tests passed. Point the link at the surviving canonical setScrollPosition(Node, ScrollPosition, ScrollAnchor). :jpro-sticky:build (test + javadoc) is green.
…, doc claim Small cleanups from the independent review, no behavior change: - Drop the jpro-utils dependency (api + requires transitive). Nothing in the module referenced it directly; it was only serving as the transitive path to javafx.controls (for ScrollPane, used in the non-exported impl package). Declare requires javafx.controls directly instead, which also stops re-exporting it to consumers that never needed it. - Remove the "anchorpane-" constraint prefix in Placeholders. AnchorPane stashes its anchors under pane-top-anchor etc., already covered by "pane-"; the extra prefix matched nothing and only misled. - Make Scroll.STUCK_PSEUDO_CLASS the single definition of the pin pseudo-class; StuckState now toggles that instance instead of holding its own duplicate. - Rephrase the README's "no platform-specific branches" so it scopes the claim to the caller's code (the library does branch on the platform underneath).
ScrollPaneStickyImpl captured the ScrollPane's content node once at install and listened only to that node's layout bounds. Replacing the content (e.g. a routing container swapping its child) left the pin measuring the detached old subtree, so it mis-pinned against stale geometry. Listen to scrollPane.contentProperty() and, on a swap, move the flow-geometry listener to the new content and re-sync. A swap that carries the sticky node into the new content also changes its parent, so the containing block is re-resolved too when it defaults to that parent (an explicit within is left untouched). Adds a headless test that swaps in a taller content node and asserts the pin tracks it; the test pins at the wrong offset without the rebind.
A web sticky node that was pinned (stuck) when its route left the scene kept reporting stuck: stuckProperty stayed true and the :stuck pseudo-class stayed on. The reparenting teardown runs off the dispatcher's detach path (placeholder scene -> null -> onDelegateDetached), which never goes through Scroll.setScrollPosition's central stuck reset. WebScrollImpl.uninstall() does not touch the sink, and the re-pinned instance starts lastStuck = false, so a "was stuck, returns unstuck" transition is never emitted and the channel latches on an off-screen node. Clear the sink in onDelegateDetached after uninstalling the delegate. An off-screen node is not pinned, and the re-pin (same pulse or on scene re-entry) re-asserts stuck if the returning node is stuck again. Desktop is unaffected: the ScrollPane path does not reparent and is not torn down on navigate-away. Adds a headless web regression test that scrolls the node into the stuck state, navigates the route out of the scene, and asserts the stuck state resets; it fails without the reset (stays true).
Rework the inline comments and terse field notes in the non-exported impl package into a developer-comment register rather than full prose: lowercase-start fragments, no semicolons, drop leading articles, prefer =/+/vs and short forms, and collapse multi-line comments to fewer lines where it reads cleanly. No behavior change; no code lines touched (verified the diff is comment-only, including the injected compositor JS). Kept intact: the public API javadoc (Scroll / ScrollAnchor / ScrollPosition, the published contract) and the load-bearing "why" content in every comment (the web compositor readiness race and host-offset math, the reentrancy-safe natural position, and the reparenting lifecycle rationale) - only the register tightens.
The API-surface trim dropped the legacy setScrollPosition(Node, ScrollPosition)
and (Node, ScrollPosition, Side, double) overloads, but two Javadoc references
outlived them:
- Scroll class doc implied setScrollPosition still had (Side, double) / bare
overloads. The only single-edge and bare forms are setStickyPosition /
setFixedPosition; reword to say so (and drop the duplicate "canonical" label,
which the 4-arg setter's own doc already carries).
- setStickyPosition(Node) said "Convenience for setScrollPosition(node, STICKY,
Side.TOP, 0)", a signature that no longer exists; point it at the real
delegation setStickyPosition(node, Side.TOP, 0).
Also tighten StickyOverlay's {@linkplain #nextStackOrder()} to the actual
one-arg signature. Docs only, no behavior change.
A reparented sticky/fixed node is a real div in the browser, so a mouse-transparent one (e.g. a fixed-fullscreen overlay) still caught clicks meant for the content beneath it. Mirror the FX property into the node's injected [jpro-id] compositor rule as pointer-events:none; desktop already honors it via FX picking.
Seven browser tests (StickyPlaywrightTest) drive the ScrollSample example: sticky/fixed pinning, bounded release, in-ScrollPane sticky, picking on a pinned node, and the mouse-transparent overlay staying click-through. Adds a shared JProScroll helper (wheel scrolling + viewport-geometry reads) since sticky is the first scroll consumer. The example is made testable: stable ids on the driven nodes, a jpro.conf enabling mirrorCSSToDOM, and a test-port block.
A page-level sticky (or fixed) node is reparented into an overlay, but its placeholder collapsed to zero height, so the content below rendered behind the pinned node. The height was set after the placeholder was inserted (a re-layout the render pulse can drop) and the plain Region had minHeight 0 (a space-tight parent shrank it away). Set both min and pref height at mount, before insertion, so the first layout honours it and nothing can shrink the slot below the node.
With the placeholder now reserving its slot, the in-flow button clears the sticky header, so assert clicks reach it through the mouse-transparent overlay instead of only checking the overlay's pointer-events. Drive page scrolling via window.scrollBy (the scroller the compositor timeline tracks) so it can't land on the nested ScrollPane once the layout shifts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
jpro-sticky, a new platform module that pins JavaFX nodes to the scrolling viewport, mirroring the CSSpositionproperty for nodes rendered by JPro:STICKY— scrolls with the content until it reaches an edge, then stays pinned within its containing block (position: sticky).FIXED— always pinned to the viewport (position: fixed).The same call sites work on web and desktop with no platform branches in user code. On the web the pin is a compositor effect (
animation-timeline: scroll()), so scrolling stays smooth with no JavaFX layout pass per scroll event; on desktop (and inside an FXScrollPane) it's a pure-JavaFX pin. The implementation is selected per node at attach time and is invisible to the caller.API
Layered under the convenience methods:
ScrollAnchor— an immutable per-axis anchor value object (pin start / pin end / center / stretch) that resolves the horizontal and vertical axes independently. Covers corners, bars, toasts, and full-viewport overlays.Scroll.setScrollPosition(node, position, anchor, within)— the canonical setter every convenience method delegates to, for programmatic / data-driven callers.Observability
A sticky node exposes when it is currently pinned ("stuck") through two synchronized channels driven from one write point:
:stuckJavaFX pseudo-class (style a pinned header in CSS, no Java), andScroll.stuckProperty(node)/Scroll.isStuck(node)for logic and bindings.Overlay host
Pinned nodes reparent into an overlay. By default that's the scene root;
Scroll.registerOverlayHost(pane)lets an app register an ancestor pane (e.g. a routing popup container) so a pinned node keeps its route-scoped CSS and parent-chain context.Testing
ScrollSample(run with./gradlew jpro-sticky:example:jproRun) exercises every mode plus both observability channels.Dependency / build changes outside the module
gradle.properties:JPRO_VERSION 2026.2.0 -> 2026.3.1. jpro-sticky depends on the JPro Viewport API (browserViewport()/documentBounds()), released in 2026.3.1. The module compiles and its full suite passes against the release.settings.gradle/build.gradle: register the newjpro-stickysubproject (and its example) for build, docs, and publishing..gitignore: ignore**/example/logs/andRUNNING_PID.