Skip to content

fix(tao): place native popup layers against the screen (#569) - #651

Merged
kdroidFilter merged 10 commits into
nucleus-2.6from
fix/569-native-popup-screen-clamp
Sep 6, 2026
Merged

fix(tao): place native popup layers against the screen (#569)#651
kdroidFilter merged 10 commits into
nucleus-2.6from
fix/569-native-popup-screen-clamp

Conversation

@kdroidFilter

@kdroidFilter kdroidFilter commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #569

Summary

nativePopupLayers turns every Compose Popup into a real OS window, but everything downstream of that still reasoned in window coordinates. This branch makes native popup layers behave like the OS surfaces they are — placed against the screen, dressed like in-scene layers, and, on Linux, driven by the compositor — and gives the context menu its own opt-in so an app doesn't have to switch its whole scene to get an unclipped menu.

Placement against the screen (#569)

  • Layers re-provide LocalWindowInfo inside the replayed owner locals, so Popup.skiko.kt finally sees the work-area-sized box the design always intended instead of the window's containerSize.
  • popupScreenClampOffset clamps the native frame into the work area of the display it lands on, at the single point where the frame is pushed — re-clamped on every push, so an open popup survives an owner drag across monitors. boundsInWindow stays what Compose believes, keeping hit-testing and surface content consistent.
  • Dialogs must not follow the display: layers report the window size for dialogs and the work area for popups, discriminated on scrimColor.
  • macOS reads the NSView's own origin (nativeGetContentRect); Wayland reports no geometry and is left to the compositor.
  • nativePopupLayers is now exposed on JewelDecoratedWindow, which had no such parameter at all.

Native dialogs that look like in-scene ones (#569)

  • Scrims painted by the owner window and by each layer above (PopupScrimRegistry, TaoSceneBundle.renderOverlay); a scrim change marks the owner scene visually dirty so the Windows clean-frame present skip no longer eats the fade.
  • A 32 dp draw margin past boundsInWindow so shadows and the slide-in are not clipped; the clamp and the interactive region still use the content rect.
  • Dialog layers run their inner scene at the owner window size so the appearance animation pivots on the scene centre, and re-centre on resize via the owner's snapshot-backed WindowInfo.
  • A closing dialog kept its surface where it was: Dialog.skiko.kt reports a zero-size boundsInWindow at the window centre during the fade-out, so each layer now sizes and places on the last non-empty bounds.

Native context menu flyout (opt-in per popup)

  • NativePopupLayers { } provides, for its subtree only, the window scene's own LocalComposeSceneContext with createLayer routed to the native popup layer factory. A friend-package Java accessor reaches that internal local without reflection. The context menu representation wraps the Windows and Linux flyouts in it; macOS stays on NSMenu.

Linux menus behaving like GNOME's own

  • The popup layer maps as an xdg_popup instead of a wl_subsurface, so the compositor flips and slides it on screen — the X11 clamp has nothing to work with on native Wayland. The tao patch carries anchor, surface size and shadow margins in one request, since GDK builds the positioner from the geometry as it stands at map time. One popup per parent takes that path; dialogs keep the subsurface.
  • A second right click now moves the menu instead of only closing it: the host recomposes and re-lays-out between the dismissal and the press, so contextMenuOpenDetector is listening again when the press arrives.
  • The layer renders as soon as its GPU side is up rather than waiting on the owner's next redraw. Press to first present: 40 ms steady, 108 ms for the first menu of a session.
  • The menu has a shadow again. The themes passed the OS box-shadow alphas to Modifier.shadow, whose desktop implementation multiplies them by fixed elevation factors (0.039 / 0.19), leaving a ~1 % darkening nobody could see. The flyout now draws real CSS box-shadow layers (rounded rect under a Gaussian mask, sigma = blur / 2) with each OS's own declarations: libadwaita _popovers.scss, Breeze ShadowLarge, and for Windows the ThemeShadow recipe WinUI applies to a MenuFlyout at Translation.Z = 32 (DropShadowRecipe.h: one directional layer, blur 16 + 1, offset 8, 0.14 light / 0.26 dark). The Windows values are checked against the WinUI source, not on a Windows machine.
  • The Fluent flyout is laid out as WinUI lays out a MenuFlyout opened with the mouse (NarrowPadding): 28 px rows with the 4,2,4,2 item margin, 11 px label inset, FlyoutThemeMinWidth 96 and no max width, presenter padding 0,2, edge-to-edge separators, the E974 chevron 24 px from the label, CaptionTextBlockStyle shortcuts, and the bound colour resources themselves (TextFillColor*, SubtleFillColorSecondary, DividerStrokeColorDefault, SurfaceStrokeColorFlyout). The border ring is now painted outside the surface for every theme, as WinUI (InnerBorderEdge) and libadwaita do; Breeze's pixels are unchanged. The acrylic backdrop is not reproduced — the surface is the brush's FallbackColor.

Test plan

  • Unit: PopupScreenClampTest (18 clamp-geometry cases), PopupDrawInflateTest, PopupScrimRegistryTest, NativePopupLayersTest
  • Headful: NativePopupPlacementHeadfulCases — 13 cases driving real windows parked at real work-area edges, including reading the popup HWND rect back through Win32 and two pinning the dialog contract
  • Headful: DialogAppearanceHeadfulCases — films both layer modes with java.awt.Robot, comparing slide-in, scrim ramp, settle time, dropped-frame stalls, and the smallest height spanned while fading out
  • E2E: scripts/context-menu-wayland-e2e.py against a nested GNOME Shell, driving a real right click and reading back screenshots + the app trace
  • Manual: the new "Popups" tab in nucleus-demo — park the window at any corner, open menus anchored at each window edge
  • Manual: Jewel combo boxes / menus / tooltips with nativePopupLayers = true (jewel-demo)
  • Per-OS: Windows, macOS and Linux (X11 + native Wayland)
  • ./gradlew preMerge

Review follow-up

Six findings from a code review of this branch, each reproduced by a test that fails first (commit 22d6c02):

  • Linux draw margin swallowed input. A press 20 px beside an open menu closed the menu and never pressed the button; hovering past the menu's edge froze the owner's hover state. Windows/macOS get the pass-through from the OS (they hand it the content rect); GTK's input shaping does not take on a popup toplevel, so the layer routes the event to the owner itself. New NativePopupMarginInputHeadfulCases (4 cases, Robot-driven).
  • macOS picture cull rect was rooted at the picture origin while the scene draws in owner-window coordinates. A bare popup survived it (Skia unrolls a one-op picture and a Compose scene is exactly one op); a popup dimmed by a dialog above it did not — the scrims go into the same picture and the frame is quick-rejected whole. New MacPopupPictureCullTest records and replays a real scene through the production paths and reads the pixels back.
  • Compositor-placed popups that re-measured after being mapped resized the EGL buffer while the xdg_surface geometry stayed at the anchored size. GDK positions a popup once, so the layer re-maps instead.
  • PopupScrimRegistry.unregister dropped a still-dimming layer without reporting the change, leaving the owner window dark.
  • The screen clamp read TaoMonitors.all, which invents a 1920×1080 monitor at the origin when the platform names none; it asks the new reported now and treats empty as "no geometry".
  • A missing trailing comma in nucleus-demo failed ktlintCheck, which failed the gradle job, which skipped tao-headful entirely — this branch's headful cases had never run in CI.

Known-failing on CI, pre-existing on this branch

Measured A/B against this branch before the fixes: macOS went 9 → 3 failures (the dialog appearance — native popup layer matches the in-scene layer case now passes), Windows is unchanged, and Linux halts at the same watchdog on the same case with and without them. What is left is environmental or pre-existing:

  • #569 a popup outside the owner window is left alone…, …above the top of the work area slides down, …moving the owner window re-clamps…, a Dialog whose window hangs off the display… — tuned to a 1600×1200 display; the runners are smaller, and macOS refuses to move a window above the menu bar.
  • The dialog appearance filmed cases need a real screen capture and do not get one on CI (nor under Xvfb).
  • The Linux leg exceeds the 900 s watchdog inside those film cases.

#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.
@kdroidFilter
kdroidFilter merged commit 940d85a into nucleus-2.6 Sep 6, 2026
17 of 23 checks passed
@kdroidFilter
kdroidFilter deleted the fix/569-native-popup-screen-clamp branch September 6, 2026 13:50
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