Skip to content

feat(tao): accept Compose 1.12 window API v2 - #634

Draft
kdroidFilter wants to merge 11 commits into
nucleus-2.6from
feat/compose-window-api-v2
Draft

feat(tao): accept Compose 1.12 window API v2#634
kdroidFilter wants to merge 11 commits into
nucleus-2.6from
feat/compose-window-api-v2

Conversation

@kdroidFilter

Copy link
Copy Markdown
Collaborator

Summary

Tao now accepts Compose Multiplatform 1.12's experimental window/dialog API v2 (androidx.compose.ui.window.v2).

  • DecoratedWindow / DecoratedDialog / HostedWindow / HostedDialog overloads take v2 WindowState / DialogState (no default state, so existing v1 call sites stay unambiguous).
  • Requested geometry (requestBounds, requestPlacement, requestSize, requestMinimized) is applied asynchronously through the existing v1 window plumbing.
  • Observed bounds / placement / isMinimized are published once the native window has been shown (isInitialized).
  • minSize / maxSize on the v2 overloads; max inner size is a new Tao JNI (set_max_inner_size).
  • examples/tao-demo uses v2 rememberWindowState + requestPlacement.

Internals that Compose keeps internal (request channels, _bounds) are reached the same way as TaoTransferableAccess: a Java friend-package accessor in androidx.compose.ui.window.v2.

Known gaps: requestScreen is drained but not applied (Tao only exposes the primary work area). Custom WindowBoundsProvider lambdas that need a live AWT window are evaluated via a dummy geometry peer.

Test plan

  • ComposeWindowV2BridgeTest — default v2 state maps to 800×600 / PlatformDefault; initialized state copies observed bounds
  • NucleusWindowHostTest — HostedWindow v2 forwards state/min/max to the ambient host
  • ktlint / detekt / apiDump on touched modules
  • Headful: run :examples:tao-demo:run and toggle Floating / Maximized / Fullscreen

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.
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.
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.
@kdroidFilter
kdroidFilter force-pushed the feat/compose-window-api-v2 branch from 6ec51dc to 27fc2ef Compare September 1, 2026 16:56
… 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).
`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.
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