Skip to content

Add jpro-sticky: CSS-style sticky and fixed positioning for JavaFX nodes - #124

Open
streamingpixel wants to merge 42 commits into
mainfrom
sticky-positioning
Open

Add jpro-sticky: CSS-style sticky and fixed positioning for JavaFX nodes#124
streamingpixel wants to merge 42 commits into
mainfrom
sticky-positioning

Conversation

@streamingpixel

@streamingpixel streamingpixel commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds jpro-sticky, a new platform module that pins JavaFX nodes to the scrolling viewport, mirroring the CSS position property 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 FX ScrollPane) it's a pure-JavaFX pin. The implementation is selected per node at attach time and is invisible to the caller.

API

Scroll.setStickyPosition(header);                     // sticky, top edge
Scroll.setFixedPosition(fab, Pos.BOTTOM_RIGHT, 24);   // fixed corner, 24px inset
Scroll.setFixedPosition(bar, ScrollAnchor.of().bottom(0).left(0).right(0)); // fixed full-width bottom bar
Scroll.setFixedFullscreen(overlay);                   // fixed, fills the viewport
Scroll.clearScrollPosition(header);                   // back to normal flow

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:

  • the :stuck JavaFX pseudo-class (style a pinned header in CSS, no Java), and
  • Scroll.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

  • 99 headless unit / JavaFX tests covering the positioning state machine, the anchor model, the desktop FX path, observability, and the overlay host. All green against the released JPro runtime.
  • 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 new jpro-sticky subproject (and its example) for build, docs, and publishing.
  • .gitignore: ignore **/example/logs/ and RUNNING_PID.

Tobias Horak 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.
Tobias Horak 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant