Skip to content

Nucleus 2.6 - #628

Draft
kdroidFilter wants to merge 113 commits into
mainfrom
nucleus-2.6
Draft

Nucleus 2.6#628
kdroidFilter wants to merge 113 commits into
mainfrom
nucleus-2.6

Conversation

@kdroidFilter

Copy link
Copy Markdown
Collaborator

inprogress

Tao is now the single window backend. Removes the three AWT-based modules
(`decorated-window-awt`, `-jbr`, `-jni`) with their native sources, API
dumps, detekt baselines and GraalVM metadata, plus the `jni-demo` sample.

BREAKING CHANGE: `NucleusBackend`, `LocalNucleusBackend`, the `backend =`
parameter of `nucleusApplication`, `NucleusApplicationScope.backend` and
`NucleusWindowUnsafe.awtWindow` / `awtDialog` are gone, as are the AWT
overloads of `MaterialDecoratedWindow` / `MaterialDecoratedDialog` (M2, M3)
and `JewelDecoratedWindow` / `JewelDecoratedDialog` — only the
`NucleusApplicationScope` receivers remain. Compose Desktop's AWT `Window`,
`Dialog` and `Tray` are unsupported; use `DecoratedWindow`, `HostedWindow` /
`HostedDialog` and an AWT-free tray.

- nucleus-application: drops the AWT scope/window/dialog adapters and takes
  `api(project(":decorated-window-tao"))`, since the backend is no longer a
  consumer choice and a missing runtime module would only fail at launch
- material2: ports `MaterialDecoratedDialog` to a `NucleusApplicationScope`
  receiver — it existed only in AWT form, so M2 keeps parity with M3
- removing the AWT overloads also retires the `LowPriorityInOverloadResolution`
  / `INVISIBLE_REFERENCE` workarounds they needed
- taskbar-progress-tao: single Tao dispatch path
- core-runtime keeps `WindowBackend.Awt`: it still describes a plain Compose
  Desktop / Swing host embedding Nucleus libraries
- scheduler-demo moves to Tao (drops the `java.desktop/sun.*` add-opens);
  service-management-demo swaps `java.awt.EventQueue` for
  `rememberCoroutineScope().launch`, as SMAppService completion handlers must
  hop to the Tao main thread
- drops the now-unused `jbr-api` catalog entry, the jni/jbr native build steps
  and verify entries from CI, and rewrites the backend docs
- also adds `rect-stress-demo` / `widget-demo` to `apiValidation.ignoredProjects`
  alongside the other demos, fixing their pre-existing `apiCheck` failures
Compose hardcodes grayscale as the Windows font-smoothing default. Enable
ClearType end to end without runtime reflection:

- plugin: LcdTextDefaultTransform (artifact transform + ASM) patches
  FontRasterizationSettings.PlatformDefault in ui-text-desktop jars on
  non-test runtime classpaths — SubpixelAntiAlias on Windows, opt-outs
  -Dnucleus.text.lcd=false (runtime) / -Pnucleus.text.lcd.patch=false
  (build). Android/HotReload/KMP guards mirror the CleanNativeLibs
  transform; referenced ctor/enum fields are verified so Compose layout
  drift fails the build. Canary test patches both the plugin's Compose
  and the consumer version from the root version catalog.
- tao: lcdSurfaceProps attaches the OS-queried pixel geometry (cached,
  RGB/BGR, grayscale on any unknown) to opaque Windows window surfaces
  only; per-pixel-alpha surfaces (popups, NativeView overlay, Mica or
  Acrylic backdrops, transparent windows) keep unknown geometry so Skia
  falls back to grayscale. renderGlFrame now requires windowTransparent.
- jewel-demo: use JewelDecoratedWindow instead of hand-rolled theming.
DecoratedWindow, DecoratedDialog, HostedWindow and HostedDialog take
androidx.compose.ui.window.v2 state. Requested geometry is applied
asynchronously; observed bounds/placement publish once the window is shown.
tao-demo uses the v2 rememberWindowState and requestPlacement path.
…text

feat(tao): LCD/ClearType text on Windows
WindowState.requestSize()/requestPosition() build the two-arg
WindowBoundsProvider, whose getBounds dereferences an AWT-backed
WindowGeometryProviderScope. Tao has none, so the request was dropped —
but the placement was already rewritten to Floating, knocking a maximized
window out of maximized for a request that never applied. Skip the whole
request when the provider cannot be evaluated, and log it at WARNING
(rememberWindowStateWithBounds hits the same path).

The observed bounds now fall back to the native window rectangle when the
v1 position never turns Absolute: a WM that emits no move event for a
PlatformDefault window left WindowState.isInitialized false and bounds /
size / position throwing forever.

Also: narrow the constantBoundsOrNull catch to NullPointerException so a
provider's own failure is not reported as "needs live metrics"; move
dialog size clamping out of composition into an effect; move
inspectableWindowBounds to dev.nucleusframework.window.tao so apiCheck
covers it and the split package with compose-ui is gone; document that
setMinimumSize/setMaximumSize clear per window, not per axis; register
ComposeWindowV2BridgeTest with the scene test battery drift guard.
Compose v2 documents WindowState.bounds as the whole window, insets
included, but the bridge published the v1 state's outer position paired
with its inner size — so bounds.size changed meaning once the WM emitted
its first move event, and requestBounds(state.bounds) or a
WindowState.Saver restore resized the window by the decoration insets.
Observed bounds now always come from the native outer rect, and the
request path converts back to the inner size the v1 state expects.

Hosts that never expose the TaoWindow (rememberSyncedWindowState) left
isInitialized false forever on a window manager that emits no initial
move, making every bounds / size / position read throw. They now publish
an approximate outer rect instead.

The initial v2 -> v1 conversion drains the request channels, so a window
that left and re-entered composition before ever being visible fell back
to the 800x600 platform default. Memoize the drained geometry per state.

constantBoundsOrNull treated any NullPointerException as "this provider
needs AWT window metrics", hiding real provider bugs behind a dropped
geometry request. Only the shapes that come from the null scope we pass
in count now.

requestSize / requestPosition stay inert: their providers live in a
synthetic lambda's captures, so honouring them would need reflection,
and building a WindowGeometryProviderScope would need a displayable AWT
window. Add requestInspectableBounds() as the working equivalent and
point the diagnostics at it.
Compose 1.12's `androidx.compose.ui.window.v2` is anchored to AWT: `Screen`
wraps a `java.awt.GraphicsDevice` and reads its insets through
`Toolkit.getDefaultToolkit()`, and `WindowGeometryProviderScope` takes a
`java.awt.Window` that must already be displayable. The Tao backend has
neither, so every provider that touches the scope was accepted, logged and
dropped, and `requestScreen` was drained into the void.

Mirror the package instead, member for member, as
`dev.nucleusframework.window.tao.v2`, backed by our own monitor enumeration
and `TaoWindow` rather than by AWT. Migrating is a single import change, and
deleting the package restores the upstream import unchanged if JetBrains
decouples its own types.

- `TaoMonitors` / `TaoMonitor`: multi-monitor enumeration via a new
  `nativeGetMonitors` on each platform bridge (`EnumDisplayMonitors` +
  `GetDpiForMonitor` on Windows, `NSScreen.screens` on macOS, GDK monitors on
  Linux), one tab-separated descriptor per monitor. Physical pixels, top-left
  origin, work area included — the conventions the existing primary-monitor
  calls already used. Never reports zero monitors.
- `v2`: `Screen`, `WindowScreenProvider(Scope)`, `WindowMetrics`,
  `WindowGeometryProviderScope`, `WindowBoundsProvider` /
  `WindowSizeProvider` / `WindowPositionProvider` with their companions,
  `WindowState`, `DialogState`, savers and `remember*` factories.
- `DecoratedWindow` / `DecoratedDialog` / `HostedWindow` / `HostedDialog` /
  `NucleusWindowHost` overloads for the cloned states. The host default
  bodies fall back to the v1 surface, so themed hosts keep working; the
  default host overrides them for the full path.
- Size and position stay split instead of folding into a `DpRect`
  (`CombinedBoundsProvider`): a rectangle cannot carry an unspecified
  position or a wrap-content axis without turning both into `NaN`.
- Wrap-content sizing routes through the window's own path rather than a
  one-shot content measurement, so `Unconstrained` / `PreferredWidth` /
  `PreferredHeight` keep re-measuring.

The Compose-typed overloads stay as they are — best effort with the warning
— and their KDoc now points at the clone.

Verified headfully on a real window (`taoHeadfulTest`, 5 new cases):
initial provider centring, `requestSize` / `requestPosition`, a scoped bounds
provider reading live window metrics, `requestScreen` landing on the target
monitor, and `screenId` tracking the hosting monitor.
Brings the 2.6 line up to date with the released one (#629 MSI installer
options, #630 NSIS menu category, #632 clean-frame present skip, #633
alwaysOnTop stickiness).

Conflict: `nucleus_tao_windows_deco.c` — 2.6's ClearType pixel-geometry probe
and main's #631 topmost helpers were appended at the same spot. Both kept.

Verified on Windows: rebuilt natives, `check` on decorated-window-tao and
nucleus-application, headful suite 27 run / 0 failed.
Observed geometry was only published from an effect keyed on the v1 state, so
a move or resize the window manager applies without the v1 state changing left
`WindowState.bounds` / `position` / `size` reporting a stale rectangle for the
rest of the window's life. The initial geometry apply is exactly that case: it
lands after the effect has already run.

Bump a counter from the window's own move / resize callbacks and key the
publishing effect on it too. Both binders get it — the Compose-typed one has
the same shape and the same gap.

Caught by the headful suite, which only reproduced it with the full case list:
the filtered run happened to settle in time.
Both conflicts are additive registries where 2.6 and this branch appended at
the same spot: the JVM-only test list (its LCD capture test vs our monitor /
bridge tests) and the headful suite registry (AlwaysOnTopHeadfulCases vs
WindowApiV2HeadfulCases). Both sides kept.

Verified on Windows: rebuilt natives, `check` on both modules, headful suite
32 run / 0 failed, twice.
… API

Written blind on a Windows box and caught by CI. Three mistakes, all verified
this time against the gdk 0.18.2 sources:

- `gtk::gdk::prelude::DisplayExt` does not exist — `Display`'s monitor
  accessors are inherent in gdk3-rs. This is the E0432 that failed the build.
- `Monitor::is_primary()` does exist, so the primary flag no longer has to be
  matched on geometry — which would not have compiled either, since
  `gdk::Rectangle` implements no `PartialEq`.
- `Rectangle` is a boxed inline type, so selecting between the geometry and
  the work area *by value* moved a rectangle still read afterwards. Read the
  four numbers out first and pick between tuples.

Also spell the tab/newline sanitiser as two `replace` calls: the char-array
`Pattern` impl is newer than the toolchain floor this crate builds with.
`gdk::Display::default()` is `assert_initialized_main_thread!()`, and a failed
Rust assertion crossing FFI aborts: the enumeration took the whole test JVM
down with SIGABRT on a headless CI box (exit 134). Guard the no-window path
with `gtk::is_initialized_main_thread()` and report "no monitors" instead, so
a tray-only app or a unit test gets the synthesized fallback rather than a
dead process.

The X11 work-area fallback behind that synthesized monitor is already
headless-safe (`XOpenDisplay(NULL)` returning NULL).
`LcdTextTest > Compose LCD text on an RGB surface has chromatic edges` has
been failing the macOS tao-tests job since #626 merged, which leaves every PR
targeting this branch red.

Skia can only fringe where the platform font host produces subpixel glyph
masks: DirectWrite and FreeType do, CoreText does not — macOS dropped
subpixel antialiasing in Mojave and renders grayscale whatever the surface's
PixelGeometry says. So `lcdScore == grayScore` there, which is this feature's
documented behaviour (`macOS and Linux stay grayscale` asserts the same thing
on the surface-props side) rather than a regression. Skip the pixel assertion
on macOS only; Windows and Linux keep it.
`tao-headful (ubuntu-latest)` timed out on the centring case while the four
other clone cases passed — so positioning works there; it is the *initial*
position that openbox overrides with its own placement policy. The v1 path
retries its Aligned centring for the same reason.

Split the assertion: the size and the strict centre where the platform honours
the request, and containment in the target work area on Linux. Prints the
observed rectangle so the CI log carries the numbers.
The native move / resize callbacks bumped a snapshot-state counter, and those
callbacks run on the event-loop thread from within the platform's resize
handling — which can be *inside* a Compose measure/layout pass. The
recomposition that write schedules then re-entered layout:
`IllegalArgumentException: performMeasureAndLayout called during measure
layout`, which took down the GraalVM headful battery on all three platforms.

Signal through a conflated channel instead. A send carries no snapshot
obligation, and the receiving coroutine resumes on the dispatcher once the
native frame has unwound, so publication happens outside the pass.
`setMenu` reaches `g_bus_get_sync(G_BUS_TYPE_SESSION, …)`, which takes no
timeout: on a runner with no session bus it blocks forever. The test task then
never finishes and hangs the whole `preMerge` job until the 30-minute cap kills
it — the failure mode pre-merge.yaml's own comment records ("`:launcher-linux:test`
has done exactly that three times"), and it just cost another PR two runs.

Skip when neither `DBUS_SESSION_BUS_ADDRESS` nor `$XDG_RUNTIME_DIR/bus` is
there: without a bus there is nothing to register against anyway.
…ow hooks

Rename DecoratedDialog's applyDialogOwnerRelationship to
applyWindowOwnerRelationship and add its inverse,
clearWindowOwnerRelationship, so a second secondary-window archetype can
reuse the Win32 / AppKit / GTK owner plumbing.

TaoWindow gains what a window that observes *another* window needs:
setOuterPositionPx (physical-pixel positioning, SetWindowPos on Windows so
a second-monitor DPI never leaks in), remove*Listener counterparts for the
multi-cast moved / resized / destroyed / fullscreen-prepare hooks, and an
onClosing hook fired at the start of requestClose() so owned windows can
sever their owner link before the OS would take them down with it.
Add SatelliteWindow, the floating tool-palette / inspector archetype on
Tao, with a Nucleus-level overload in nucleus-application:

- WindowPositioner / WindowAnchor / WindowConstraintAdjustment: pure
  placement geometry with a flip → slide → resize cascade, pinned by
  12 unit tests (registered in the scene battery and drift test).
- Anchored initial placement, parent-relative follow in physical pixels
  with echo filtering, offset re-capture when the user drags the
  satellite, suppression while the parent is fullscreen or maximized,
  and SatelliteWindowState.reanchor() to re-apply the rule.
- Reparenting keeps the satellite where it is on screen, including when
  the previous owner closes in the same frame: the owner link is severed
  before the old window is destroyed and the close decision is taken
  from composition, where the new owner is already known.
- Headful coverage: anchoring + follow, maximize suppression + restore,
  reanchor, and reparent-as-the-owner-closes. The harness gains a
  selectable satellite owner, a closable dialog and onCloseRequest
  routing for that last case.
- examples/satellite-demo: two document windows sharing one inspector.
…ble (#576)

The outer gate exists to catch chrome drift — TitleBar and frame disagreeing.
But the outer rectangle is a separate query from the resize event the scene
tracks: on a loaded Xvfb the X server's geometry lagged the scene by 3px over
two consecutive samples while `maxSceneVsInner` stayed at 0, and
`SUSTAINED_FRAMES = 2` promoted that into a failure. Only the outer query can
see such a lag; the scene has nothing to correct.

Fail on outer drift only when the scene also lost the inner size. The metric
line keeps reporting it either way.
Nine of the last nine `preMerge` jobs that hit the 30-minute cap — on main as
much as on feature branches, two of them running the full 6 hours before the
cap existed — were stuck in `:launcher-linux:test`. The culprit is
`g_bus_get_sync(G_BUS_TYPE_SESSION)` with `DBUS_SESSION_BUS_ADDRESS` unset:
GDBus then autolaunches, spawning `dbus-launch --autolaunch`, which waits on an
X display a headless process never provides. No timeout, so the JNI entry
point never returns, and with `nativeRegisterQueryHandler` the calling thread
also sits in a condvar wait for a worker thread that is itself stuck there.

Refuse to connect when the address is unset: a session bus that exists is
always advertised through that variable, so "unset" means "no bus", and the
bridge already treats a NULL connection as "launcher unavailable". This fixes
the headless-app case too — a service or CI process must not spawn dbus-launch.

Guard the second native test the same way (the quicklist one already was), and
drop the `$XDG_RUNTIME_DIR/bus` probe from its check: that fallback is libdbus
behaviour, not GDBus's.
`requestSize` and `WindowBoundsProvider(sizeProvider = …)` pair the size with
`WindowPositionProvider.Current`. Before the window exists that was resolved
against the placeholder rectangle the initial scope hands out, which pinned the
window to an absolute point. The v1 `rememberWindowState(size = …)` idiom this
replaces leaves placement to the window manager — keep that: an initial
size-only request now resolves to `WindowPosition.PlatformDefault`. Once the
window is up, `Current` reads the live outer rectangle as before.

Review follow-up on #634; the other points (AWT on the Tao thread, dummy peer,
dialog constraints, partial min size, host fallback, KDoc) were already
addressed by the clone.
The Linux headful job timed out once on the initial size converging and
passed the run before with the exact requested size; the diagnostic sat
after that wait, so the log had nothing. Print the outer rectangle once a
second during the wait.
Two X11 facts make it say nothing there. openbox applies its own placement
policy to a client's initial position (the window lands at 0,0 — the v1 path
retries Aligned centring for the same reason), so the centre is never
observable. And this is the only headful case whose window receives an
absolute position *before* `show()`: under Xvfb/openbox that window
intermittently stays at GTK's unallocated 1×1 for the whole 15 s budget —
the sizing trace shows it — while the very next window of the same run maps
normally. That is a pre-map race in the v1 create → move → show sequence,
independent of the clone, and not reproducible from a Windows box.

Size, position and screen requests after mapping stay covered on every
platform by the four sibling cases.
Compose's own `androidx.compose.ui.window.v2` types are no longer accepted by
`DecoratedWindow` / `DecoratedDialog` / `HostedWindow` / `HostedDialog` or
the `NucleusWindowHost` / `NucleusDialogHost` surfaces. On Tao that surface
could only ever be half-working — every scoped geometry provider (including
the ones `requestSize` / `requestPosition` build internally) and `requestScreen`
were accepted, logged and dropped, because Compose's scope needs a displayable
`java.awt.Window`. An API that silently ignores part of its contract is worse
than one that does not exist; the supported v2 surface is the clone,
`dev.nucleusframework.window.tao.v2`, where everything is applied and
migrating is one import.

Removed: `ComposeWindowV2Bridge`, the `ComposeWindowV2Access` friend-package
accessor, the Compose-typed `DecoratedWindow` / `DecoratedDialog` overloads,
`inspectableWindowBounds` / `requestInspectableBounds`,
`rememberSyncedWindowState` / `rememberSyncedDialogState`, the matching
nucleus-application overloads, adapters and host methods, and their tests.
The geometry helpers the clone shared with that bridge move into
`NucleusWindowV2Bridge`. `examples/tao-demo` and the host tests use the clone.
- `parentWindowMetrics` for dialogs: the `DecoratedDialog` overload captures
  its owner from `LocalTaoWindow` (the same capture the dialog uses for the
  native owner relationship) and hands it to the bridge, so
  `AlignedToParentWindow` resolves instead of reporting a missing parent.
- Initial outer size: a v2 provider returns the outer rectangle, but before
  the window exists its insets are unknown, so it was applied as the inner
  size and a natively decorated frame came out larger by its chrome. Once
  mapped, the bridge measures the insets and — if the size is still the
  initial request — shrinks the inner size by them.
- `measureWindowContent` is now a real measure pass: the scene host registers
  a per-window hook onto `ComposeScene.measureContent(constraints)`
  (`TaoContentMeasurers`, same shape as `WindowSizePolicy`) and the geometry
  scope calls it, converting through the window's scale. The clamped current
  size remains only for the pre-window / no-window cases.
- X11 pre-map race: an absolute position applied before the window is mapped
  intermittently left it at GTK's unallocated 1×1 for good under Xvfb/openbox.
  The v1 Absolute path now waits for real outer bounds on Linux (≤1.5 s)
  before moving, the way Aligned already retries. The centring headful case
  runs on Linux again (size + containment; the WM still owns placement).
The extremes probe emits its frame ticker as a sibling of the content in
the window's scene column, and a `fillMaxSize` there takes the whole
height with it: the content every geometry assertion is about was laid
out at zero, so the embed's rect and the composable's were compared
stale against stale — eight of the eighteen cases were measuring
nothing, and the embed storm failed outright on the one comparison the
collapse could not satisfy. The ticker is now the smallest node that can
draw, and `awaitSettledAt` asserts the content actually filled the scene
rather than trusting the scene's own size.
A drop is answered by the topmost dock layout under the pointer, and the
case's dialog is a dock host of its own whose default placement centres
it over the parent window. On a display small enough for the two to
overlap it sits astride the very zone the drags aim at and previews
*its* top edge — the case is about two drags racing, not about which
window is under them. It now parks the dialog off the parent's right
edge and waits until it is clear of both drop points before starting.
feat(tao): satellite windows, a docking workspace, and Chrome-like tabs
…640)

`PeekMessageW` delivers pending cross-thread *sent* messages inline: the
kernel re-enters the window procedure through `KiUserCallbackDispatcher`
before the peek returns. Two non-reentrant `parking_lot::Mutex`es were held
across such a peek, so the nested dispatch deadlocked the event-loop thread
against itself — it parked in `WaitOnAddress` and never pumped a message
again, leaving the window permanently "Not Responding".

- `event_loop.rs`: the keyboard callback held the global `KEY_EVENT_BUILDERS`
  map across `KeyEventBuilder::process_message`, which peeks. The builder is
  now taken out of the map for the duration and put back afterwards (only if
  the slot still exists, so a window dropped re-entrantly is not resurrected).
  Its map value became an `Option` slot to make that take/put-back possible.
- `keyboard.rs`: the key-press and key-release arms held `LAYOUT_CACHE` across
  their peek, while every other arm — and `update_modifiers`, reached from any
  mouse or focus message — locks it too. The guard is now scoped to end before
  the peek and re-acquired after it.

Reported as a freeze when moving a window between Windows 11 virtual
desktops: that path is keyboard-triggered and makes the shell send messages
to the window mid-peek. The reporter's stack shows the two nested window
procedure chains around `NtUserPeekMessage` and the park in `WaitOnAddress`.

Only injected or sent key messages can nest here — real keyboard input is
posted to the queue rather than sent, and the peeks use `PM_NOREMOVE` — so a
nested key message now finds an empty builder slot and is dropped instead of
hanging the process.
Two more instances of the #640 bug class, both fixed upstream and ported by
hand — a non-reentrant lock held across a call that pumps or synchronously
sends messages, so a nested window-procedure dispatch re-locks it and the
event-loop thread parks in `WaitOnAddress` for good.

- IME: the window-state lock was held across `ImeHandler::process_message`,
  which peeks the queue on `WM_IME_ENDCOMPOSITION` (the Korean IME sends the
  committed string after the end message, so the queue decides whether to
  defer). The peek now happens in `ime::peek_commit_queued` before the lock is
  taken, and the result is passed in — mirroring upstream, where "IME
  character-pending detection happens before acquiring window state locks".
  Wider blast radius than #640: there only the keyboard arm took the lock,
  here nearly every arm does, so any nested message deadlocked.
  Upstream: tauri-apps/tao#1215.
- `TaskbarCreated`: the window-state lock was held across `set_skip_taskbar`,
  which goes through `CoCreateInstance` + `ITaskbarList`. An STA COM call pumps
  the message queue while it waits, so the window procedure is re-entered.
  The flag is now read out before the call. Upstream: tauri-apps/tao#1264.

`Imm32Source` carries the precomputed flag instead of peeking on demand;
`ImeSource` is unchanged, so the IME state-machine tests keep driving the same
sequences through their stub.
…ancy-deadlocks

fix(tao): port the Windows re-entrancy deadlock fixes to 2.6 (#640, #644)
Spamming requestPlacement faster than the OS animation and then asking
for bounds left the window maximized on macOS, with the bounds request
silently dropped: `window v2 clone: rapid maximize/restore toggling then
a bounds request converges` timed out at 30 s, 100% reproducible on one
machine and intermittently on CI.

Two defects, both needed for the failure.

The bridge decided whether it had a placement to leave from a single
sample of `v1.placement` plus the native flags. AppKit clears `isZoomed`
at the *start* of the un-zoom animation, so right after a toggle both
read Floating while a queued `zoom:` has not landed yet. `leftPlacement`
was therefore false, the bounds were applied to a window that was about
to re-zoom, and `confirmBounds` — the whole point of which is to catch
exactly that — was skipped because it is gated on the same flag. A
placement applied within the last second is now its own reason to
confirm.

`confirmBounds` could not have recovered anyway. It returned as soon as
the geometry matched, without ever asking whether a placement had come
back, and its size check cannot detect one: a maximized window ignores
v1's size, so `decorationInsets` derives the insets from a target that
never landed (2560 outer - 820 target = 1740 dp of "decoration") and the
subtraction reports sizeOk for any outer rectangle at all. It now treats
a re-asserted placement as a failure to converge in its own right, tested
before the geometry, and clears it by handing Floating back to v1 so the
window composable's placement effect issues the single `zoom:` — the
same "who issues the restore matters" rule the bounds path already
follows, and the reason poking the native flag here would re-zoom.

The case now passes in 1.9 s. Full headful suite on macOS: 205 run, 0
failed.

Also give that wait a `detail` snapshot, since a bare "timed out" is what
made this expensive to diagnose in the first place.
…ggle-storm

Tao: converge a v2 bounds request after a placement toggle storm
The Metal and GL scene hosts never purged Skia's GPU resource cache;
only the Windows host did, off the resize path (#347, #477). This brings
macOS up to that level and pulls the shared policy into one file.

Measured first: Ganesh already hands out 256 MiB by default, exactly the
value the Windows host writes at attach. So that write was a no-op and
the comment claiming it "forces purgeAsNeeded on every flush ... which is
what keeps the steady state bounded at all" was wrong — Skia purges to
fit its budget whether or not we set one. What reclaims is the purge.
The comment is corrected rather than propagated.

macOS gets the same mechanism, adapted to the backend:

  - the budget is anchored inside the same runOnRenderThread hop as
    makeMetal, since writing it purges to fit and so belongs on the
    owning thread;
  - purgeGpuResourceCache() submits the limit-toggle to the render
    executor instead of awaiting it — Metal has no current context, so
    none of the #514 foreign-context hazard applies here, but the
    context is thread-affine, and blocking the Tao main thread would
    park the drag behind the in-flight replay;
  - onResizeStreamAdvanced() purges every 250 ms while sizes stream and
    once more 500 ms after the last one, standing in for the
    WM_EXITSIZEMOVE macOS never sends.

One deliberate divergence from Windows: the settle's System.gc() is
gated on the burst having carried at least 8 resize events. Windows only
sees WM_EXITSIZEMOVE after a real drag, but here every size change
settles, including the single event a zoom, a snap or a programmatic
resize produces, and a stop-the-world collection after each of those
costs more than it returns.

The Windows resize path is untouched beyond the constant move and the
comment fix.
The settle pair (a 500 ms timer standing in for the WM_EXITSIZEMOVE macOS
never sends, then a purge and a System.gc()) does not pay for itself on
this backend.

macOS paces its resize frames through the display link, so it never
accumulates the way Windows' unpaced modal loop does: a 60-step resize
storm on tao-demo moved the graphics footprint 68 MB -> 72 MB, and a
purge + GC at the end of it returned essentially none of that. Against
that nil benefit sits a real cost — a full cache purge re-mints the glyph
atlas and layer backings, and the GC is stop-the-world, both landing half
a second after every resize, including the single event a zoom, a snap or
a programmatic resize produces.

Keep the in-drag periodic purge, which is free (every frame of a drag is
re-rastering anyway) and bounds a long drag on a large display. Drop the
settle timer, the GC nudge and the burst-count gate that tried to make
the nudge affordable; with them go the two constants and the host scope
they needed, so startRenderLoop goes back to its local scope.

The reclaim #638 actually wants is at rest, not at drag end, and belongs
on the idle path.
Port of 4126663 to 2.6, extended to the branch's own window openers.

The window and dialog openers had their composition target inferred, so a
single non-UI-targeted composable called in the `nucleusApplication` scope
(the Compose compiler bakes `@ComposableTarget` onto any unmarked factory
forwarding a target-marked content lambda) reclassified the whole scope —
nested windows included — and every `@UiComposable` call in it warned, which
is fatal under `-Werror`.

Declare each opener `@ComposableOpenTarget(-1)` with `@UiComposable` content
lambdas, the way Compose Desktop's own `Window`/`Dialog` are open: they are
callable from any applier and always compose UI content in the new window's
own composition, so neither direction of the applier leak survives. Covers
`DecoratedWindow`/`DecoratedDialog`, the `NucleusWindowHost` /
`NucleusDialogHost` interfaces with their default implementations,
`HostedWindow`/`HostedDialog`, `SatelliteWindow`, `Satellite`, `Tab`, and
Tao's equivalents plus `TaoStandalonePopup`. `TabWindows`, the v2
`DecoratedWindow`/`DecoratedDialog` and `DragGhostWindow` already infer the
same scheme (`[_[UiComposable]]`), as do the Material/Jewel wrappers.

Once a declaration carries an explicit target, inference stops for all of it:
every composable lambda parameter needs the annotation, not just `content`.
`Satellite`'s `floatingContentWrapper` was the one case where leaving one
unmarked kept dragging the caller's applier into the satellite content.

`COMPOSE_APPLIER_CALL_MISMATCH` is only a warning, so both test compilations
escalate it to an error and a fixture per module compiles the reported shape
(non-UI factory in the application scope, UI content in every window).
…-target-window-scope

fix(application): keep window content on the UI applier (#636) [2.6]
#569)

`nativePopupLayers` made every Compose `Popup` a real OS window, but the
*decision* of where to put it stayed window-rooted, in two stacked ways.

The layers each built a work-area-sized `WindowInfo` so a popup could lay
out and flip against the display — and then `setContent` replayed the owner
window's composition locals over it, so `Popup.skiko.kt` read the window's
`containerSize` and clipped every popup back inside the window. The
intended design had never taken effect. The layers now re-provide
`LocalWindowInfo` inside the replayed locals.

With that in force the box is screen-sized but still rooted at the window's
content origin, so a `DropdownMenu` in a window near the bottom of the
display did not flip up — Compose saw a whole work area of room below the
anchor — and walked off the screen. Each layer now clamps its native frame
into the work area of the display it lands on, at the single point where it
pushes that frame: `popupScreenClampOffset`, fed the owner's content origin
on screen plus every display's work area. Only the native frame moves;
`boundsInWindow` stays what Compose believes, which is what hit-testing and
the surface content are expressed in. Re-clamped on every push, so an open
popup survives an owner drag, across monitors included.

Dialogs go through the same layers but must not follow the display:
`Dialog.skiko.kt` places at `containerSize.center`, so a window-owned
dialog centred on the screen would sit visibly off-centre and drift as the
window moved. Layers report the window size for dialogs and the work area
for popups, discriminated on `scrimColor` — only `Dialog.skiko.kt` writes
it, from `DialogAppearanceController.properties` during `DialogLayout`'s
composition, before `layer.Content { }` reads the container.

macOS needs the NSView's own origin on screen (a native title bar sits
between it and the window frame), hence `nativeGetContentRect`. Wayland
reports no geometry and is left unclamped: a popup there is a
`wl_subsurface` placed relative to the parent, with no global position.

Also exposes `nativePopupLayers` on `JewelDecoratedWindow`, which had no
such parameter at all — Jewel apps could not opt in. Jewel needs nothing
further: `DefaultPopupRenderer` delegates to `androidx.compose.ui.window.Popup`,
so its combo boxes, menus and tooltips flow through the fixed layers.

Tests: 18 unit cases on the clamp geometry, and 13 headful cases driving
real windows parked at real work-area edges — including one that reads the
popup HWND's rect back through Win32 and asserts it matches the reported
frame to the pixel, and two that pin the dialog contract. A new "Popups"
tab in nucleus-demo parks the window at any corner and opens menus
anchored at each window edge.
A Compose Dialog opened through nativePopupLayers had no scrim, clipped its
shadow and its appearance animation at the layout edge, slid diagonally
towards the display centre while scaling in, and stayed put when the window
was resized.

- Scrims: the owner window paints every layer's scrim after its content and
  each layer paints the scrims of the layers above it (PopupScrimRegistry,
  TaoSceneBundle.renderOverlay). A scrim change marks the owner scene
  visually dirty, so the Windows clean-frame present skip no longer eats the
  fade.
- Draw margin: the native surface extends 32 dp past boundsInWindow so
  shadows and the 10 dp slide-in are not clipped. Compose 1.12 renders a
  scene as one RenderNode drawable with unbounded bounds, so the R-tree
  cull-rect measurement upstream uses reports the whole canvas; a constant
  margin replaces it. The screen clamp is decided on the content rect; the
  interactive region stays the content (Windows content rect, macOS region
  hit-test, Linux press filter).
- Dialog scene size: a dialog's root Layout fills the layer scene's
  constraints and carries the appearance GraphicsLayer, so its scale pivots
  on the scene centre. Dialog layers now run their inner scene at the owner
  window size, popups keep the work area.
- Resize: the dialog container size is read from the owner's snapshot-backed
  WindowInfo, so the dialog re-centres when the window is resized.

DialogAppearanceHeadfulCases films both layer modes with java.awt.Robot and
compares slide-in, scrim ramp and settle time; PopupFrameRecord gains the
content frame next to the inflated native frame.
The appearance film now also records the hide animation and counts grabs
that repeat the previous frame during either animation — dropped frames
show up as a stall count the native layer must not exceed. The owner
window carries forty rows of text so its per-frame present costs something.
The first-visible check is one-sided: the native surface legitimately shows
its first frame before the owner's next present.
…t fades out (#569)

Dialog.skiko.kt's disappearance swaps the layer's content for an empty
Layout that replays the recorded picture, and Compose then reports a
zero-size boundsInWindow at the window centre for the whole fade-out. An
in-scene layer draws into the window canvas and does not care; the native
surface followed the bounds and shrank to a 32 dp square around a point, so
a closing dialog collapsed and vanished instead of fading out. Each layer
now sizes and places its surface on the last non-empty bounds.

The appearance film gains the Material 3 AlertDialog nucleus-demo opens,
a warm-up before filming, a duration-based comparison, and the smallest
height the dialog spanned while fading out.
…face

The Windows and Linux context menu flyouts drew inside the window's render
target whenever the window ran without nativePopupLayers, so a menu opened
near an edge was clipped by the window like any in-scene popup. An
OS-looking menu has to leave the window like the menus it imitates, and the
application's choice for its other popups must not decide that.

nativePopupLayers is a whole-scene switch (platform vs canvas layers), so a
per-popup opt-in needs the seam Compose 1.12 actually uses: Popup picks its
layer through LocalComposeSceneContext. A friend-package Java accessor
reaches that internal local without reflection; NativePopupLayers { } then
provides, for its subtree only, the window scene's own context with
createLayer routed to the window's native popup layer factory — the same
factory attach() uses when nativePopupLayers is on. The context menu
representation wraps the Windows and Linux flyouts in it; macOS stays on
NSMenu.
…tates

Three things the menu got wrong on a Linux desktop, found by driving a real
right click against a nested GNOME Shell and reading back both screenshots
and the app's own trace (scripts/context-menu-wayland-e2e.py, with the
fixture it drives in nucleus-application's tests).

A menu opened near the bottom of the screen was cut off. On native Wayland a
client cannot know where its own window is, so it cannot keep a popup on
screen by itself — the X11 clamp of #569 has nothing to work with there. The
popup layer now maps as an xdg_popup instead of a wl_subsurface and lets the
compositor place it: it flips above the pointer when there is no room below
and slides along an edge, which is what GTK's own menus do. The tao patch
carries the anchor point, the surface size and the shadow margins in one
request, because GDK builds the positioner from the window's geometry as it
stands at map time — a popup still sized 1x1 there asks the compositor to
constrain a 1x1 rectangle and is never flipped. One popup per parent takes
that path (an xdg_popup must be its parent's topmost popup); a dialog keeps
the subsurface, since it belongs to its window rather than to the display.

A second right click only closed the menu instead of moving it. The press
that dismisses a popup is delivered to the scene in the same turn as the
dismissal, so Compose's contextMenuOpenDetector — disabled while the menu is
open — was still disabled when the press arrived, and the press did nothing.
The host now recomposes and re-lays-out the scene between the two, so the
detector is listening again by the time it sees the press.

The menu also appeared a beat late: the layer painted its first frame only on
the owner window's next redraw, though that first render is what measures the
content and puts the popup on screen at all. It renders as soon as its GPU
side is up. Measured against the fixture's trace, press to first present is
now 40 ms steady, 108 ms for the first menu of a session.
The flyout drew its shadow with Modifier.shadow and the themes passed the OS
box-shadow alphas (Adwaita 9 % / 5 %) as ambientColor / spotColor. Compose
desktop multiplies those alphas by its fixed elevation factors (0.039 ambient,
0.19 spot), so the menu on GNOME darkened the pixels next to it by about 1 % —
measured on the E2E screenshots — and read as having no shadow at all. An
elevation shadow also cannot reproduce a CSS box-shadow, which is how GTK,
Breeze and Fluent all describe theirs.

The themes now carry those declarations as box-shadow layers (offset, blur,
spread, colour), drawn as the menu's rounded rectangle under a Gaussian mask
with the CSS standard deviation of half the blur radius: libadwaita's
_popovers.scss for Adwaita, Breeze's ShadowLarge for KDE, Fluent 2's shadow16
token for Windows. On the nested GNOME Shell the bottom edge now darkens the
backdrop by about 10 %, tapering over 17 px, as the GTK menus next to it do.
…eb token

The Fluent flyout took its shadow from Fluent 2's shadow16 token, and got that
wrong too (its ambient layer is 0 0 8px, not 0 0 2px). The menu imitates the
Windows 11 context menu, whose shadow is WinUI's ThemeShadow at
Translation.Z = 32: GetDropShadowRecipe gives a single directional layer,
blur radius 16 (+1) shifted down 8, at 0.14 in light and 0.26 in dark, and no
ambient layer at that elevation. The composition blur radius is the ~3 sigma
extent WinUI reserves around the caster, so it becomes an 11 dp CSS blur.
Checked field by field against MenuFlyout_themeresources.xaml and
Common_themeresources_any.xaml in microsoft-ui-xaml. The flyout had the touch
metrics and a few guesses; a right click opens a MenuFlyout with the mouse,
and GetShouldBeNarrow then puts every item in its NarrowPadding state.

  item row       36 -> 28 (MenuFlyoutItemThemePaddingNarrow 11,4,11,5 around a
                 14 px label), with the 4,2,4,2 MenuFlyoutItemMargin the rows
                 had no vertical part of
  label inset    12 -> 11
  presenter      MinWidth 168 -> 96 (FlyoutThemeMinWidth), no MaxWidth,
                 padding 4 -> 0,2 inside the 1 px border
  separator      12 px insets, 4 px above and below -> edge to edge, 1 px
  chevron        E76C -> E974 (ChevronRightMed), 12 px, 24 px from the label,
                 TextFillColorSecondary rather than the label colour
  shortcut       CaptionTextBlockStyle 12 px, 24 px gap, margin 24,4,0,0,
                 TextFillColorSecondary
  colours        the bound resources themselves, translucent where WinUI's
                 are: TextFillColorPrimary/Secondary/Disabled,
                 SubtleFillColorSecondary for pointer-over,
                 DividerStrokeColorDefault, SurfaceStrokeColorFlyout

The presenter border is BackgroundSizing=InnerBorderEdge: the ring is outside
the background and blends with what is behind the menu. The flyout now paints
it that way for every theme, which is also how libadwaita's 0 0 0 1px
box-shadow ring works; Breeze strokes over its own fill, so its border colours
are pre-composited and its menu padding drops by the ring it now sits inside,
leaving its pixels as they were.

The chevron and shortcut colours move from per-theme alphas into the colour
set, since WinUI's differ between light and dark. The acrylic backdrop is not
reproduced: the surface is the brush's FallbackColor.
…er it is

Six findings from a review of the #569 branch, each reproduced by a test
that fails first.

The margin a native popup layer's surface carries past `boundsInWindow` is
transparent, but on Linux it is still the popup's window as far as the
display server is concerned, and the layer swallowed everything that landed
there: a click on a button 20 px beside an open menu closed the menu and
never pressed the button, and hovering past the menu's edge froze the
owner's hover state. Windows and macOS get the pass-through from the OS,
which is handed the *content* rect. GTK's own input shaping does not take on
a popup toplevel — the region reaches GDK and the X window keeps its full
input shape — so the layer routes the event to the owner itself
(`TaoPopupHostLinux.forwardMarginPointer`), and only while the point is over
the owner's content: a press over another application is not ours to
deliver. The press path is the one an owner press already takes, dismissal
and mid-turn recompose included, now shared as
`dismissPopupsBeforePress`.

`NativePopupMarginInputHeadfulCases` drives a real pointer with `Robot`
against a real popup and asserts on what the owner window's scene received —
including a case with no popup open, without which "the owner saw nothing"
would be as consistent with a broken driver as with a swallowed press.

The macOS layer recorded its picture with a cull rect rooted at the picture
origin while the scene draws in owner-window coordinates, so the rect the
replay matrix maps lands off the drawable. Skia unrolls a one-op picture and
never consults the rect, and a Compose scene is exactly one op, so a bare
popup survived it; a popup dimmed by a dialog above it does not — the scrims
go into the same picture and the whole frame is quick-rejected.
`MacPopupPictureCullTest` runs the layer's frame against a real scene
through the production record and replay paths and reads the pixels back.

A compositor-placed popup (`xdg_popup`) that re-measured after it was mapped
resized its EGL buffer while the `xdg_surface` geometry stayed at the
anchored size — the buffer/geometry disagreement of #502. GDK positions a
popup once, so the layer re-maps instead: hide, re-anchor at the new size,
show.

Also: closing a layer that was still dimming left the owner window dark
until an unrelated invalidation, because `PopupScrimRegistry.unregister`
removed the entry without reporting the change; the popup screen clamp read
`TaoMonitors.all`, which invents a 1920x1080 monitor at the origin when the
platform names none, and would have dragged a popup onto a display that does
not exist — it asks `reported` now, and treats empty as "no geometry"; and
nucleus-demo was missing a trailing comma, which failed `ktlintCheck` and
took the whole `tao-headful` job down with it.
…reen-clamp

fix(tao): place native popup layers against the screen (#569)
…inst

`examples/tao-native-test` compiles decorated-window-tao's test suites into a
native image, but `taoTestArtifacts` published only the classes, so every
dependency of that test source set had to be repeated in the consumer. Material
3 was not, and the first case that reaches an `AlertDialog` throws
`NoClassDefFoundError: androidx/compose/material3/MaterialThemeKt` **on the Tao
main thread**, which closes the loop and fails the whole `test-graalvm` job on
all three platforms. The configuration now extends `testImplementation`, so a
dependency added to the suites reaches the image without being repeated —
reproduced and verified on the JVM (`:examples:tao-native-test:run
--args=headful`), which shares the classpath.

While the film cases were finally running, three things they had never been
green on:

- Their content is composed into a `ColumnScope` next to the harness's default
  `fillMaxSize` background, so it was laid out at zero height: the forty rows of
  text that exist to make the owner's per-frame present cost something rendered
  nowhere. `paintDefaultBackground = false`.
- The grabber started *before* the warm-up and spent its whole frame budget on
  it, leaving the curve with nothing after the timestamp it measures from —
  reported as "the dialog never showed up on screen".
- One capture session covered both halves, so the appearance consumed the budget
  and the disappearance got no frames at all, which is what the comparison cases
  were failing on. Each half films in its own session now, within the same total
  budget.

Under Xvfb + openbox the `appearance` series goes from 6 failures to 0. The
`native popup layer matches the in-scene layer` comparison is borderline there
(slide-in measured 15 px vs 10 px against a 4 px tolerance) because a 6 ms
sample period is coarse for a 10 dp slide; the tolerances are left as calibrated.

Not addressed: `graphicsLayer translation filmed — native popup layer` hangs on
Linux, which is what makes that leg reach the 900 s watchdog. It draws a
`GraphicsLayer` created from the *owner window's* `GraphicsContext` inside the
popup's own GL context, with alpha forcing the saveLayer path; the in-scene
variant of the same case passes. Pre-existing and its own investigation.
…-material3

fix(tao): give the GraalVM test image the classes it was compiled against
Third and last host of the shared policy from GpuResourceCache: the EGL
host now anchors the same budget at attach and purges the per-size
scratch periodically while resize events stream, like Windows inside the
modal resize/move loop and macOS on the display-link-paced one.

The purge is armed in onResized but performed in the render pass. That
is not a detail: onResized runs on the event-loop thread with no EGL
context bound and the swap thread may be holding ours for its
eglSwapBuffers, while the render pass is the one point where this host's
context is current on this thread. It also lands right after
applyPendingNativeResize has closed the previous size's Surface and
BackendRenderTarget, which is exactly when their backing memory is
unlocked and the toggle has something to return.

Unlike macOS, the Linux resize path IS where the memory sits. On a
60-step storm, then a second storm shrinking back through the same
sizes (NVIDIA graphics memory for the process, native Wayland / GNOME):

    without purge   13 MB -> 106 MB -> 114 MB
    with purge      13 MB ->  71 MB ->  27 MB

Without the purge the footprint ratchets: the second storm revisits
sizes the first already paid for and still grows, because nothing ever
releases the scratch of a size no frame will ask for again. With it the
footprint tracks the current window instead of the high-water mark of
every size ever seen. Same shape on the X11 attach path (112 MB -> 23
MB). Frame throughput is unchanged in both regimes (~1050 frames per
1000 ms on Wayland, ~93 on the vsync-paced X11 path), so the reclaim
costs no frames.

No settle purge and no System.gc() nudge, for the reason macOS has
none: GTK offers no drag-end signal, and a timer standing in for one
buys a stop-the-world collection after every zoom, snap and
programmatic resize.

Refs #638
…cache-purge

Tao: reclaim the GPU resource cache on the macOS and Linux hosts
`graphicsLayer translation filmed — native popup layer` never returned on
Linux/X11 and the 900 s watchdog took the whole suite down with it. The
hang was not the cross-context GraphicsLayer: the case was the only one
calling `Robot.createScreenCapture` on the Tao event-loop thread.

The JDK's Linux Robot grabs pixels through GTK, and loading GTK from AWT
calls `gdk_threads_init()`, which retroactively installs GDK's global lock
in the process — GDK then holds it around every event it dispatches on the
loop thread. The capture takes the same non-recursive mutex through
`gdk_threads_enter()`, so a driver resumed from inside a GDK dispatch parks
its own thread for good. Timing decides whether the case is inside a
dispatch, which is why it passed alone and hung after the in-scene variant.

- HeadfulRobot.capture: screen grabs run on an IO thread under the same
  timeout and unavailability latch as gestures
- translated(): capture through it and fail the case, not the suite
- watchdog: dump every thread's stack before halting so the next wedge is
  diagnosable from a CI log
- document that an owner-context GraphicsLayer with alpha inside a native
  popup is supported (picture replay, no foreign GPU resource)
# Conflicts:
#	decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/ComposableTargetIsolationFixture.kt
#	nucleus-application/src/main/kotlin/dev/nucleusframework/application/DecoratedDialog.kt
#	nucleus-application/src/main/kotlin/dev/nucleusframework/application/DecoratedWindow.kt
#	nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusWindowHost.kt
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